mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911a09c743 | ||
|
|
2672dbdcf6 | ||
|
|
3ddd5f3cfd | ||
|
|
31f4d4aafa | ||
|
|
e06dc933b8 | ||
|
|
1418be999d | ||
|
|
df06a49949 | ||
|
|
b6dcc74383 | ||
|
|
fead8381bc | ||
|
|
2ab9f8ace6 | ||
|
|
81effcb349 | ||
|
|
3dd1f8151a | ||
|
|
272a7121a9 | ||
|
|
52a7850c1d | ||
|
|
b297ad8564 | ||
|
|
a3810353f7 | ||
|
|
d4dddb1f38 | ||
|
|
21560a1f3a | ||
|
|
bdfb6bb073 | ||
|
|
aac0590017 | ||
|
|
afba33dd83 | ||
|
|
5de5dee52f | ||
|
|
ba3e253569 | ||
|
|
4488af0ab7 | ||
|
|
aae8ad62bd | ||
|
|
9606a33f15 | ||
|
|
f626d70538 | ||
|
|
a91ef44686 | ||
|
|
e3bef4e74b | ||
|
|
358bf5e244 | ||
|
|
fba2d3a59d | ||
|
|
0abba4ece2 | ||
|
|
e9b0694b67 | ||
|
|
49bbf1bc85 | ||
|
|
f5b6a5c605 | ||
|
|
fccc6cedd5 | ||
|
|
a03766448e | ||
|
|
f0c9d318ff | ||
|
|
5c5cc6f28e | ||
|
|
246b96bfb4 | ||
|
|
352e020b54 | ||
|
|
72f5690ea6 | ||
|
|
1ed1199574 | ||
|
|
fd4a43de96 | ||
|
|
e406fd4cc1 | ||
|
|
1e555cc712 | ||
|
|
227a6b2a7b | ||
|
|
5f661d71a6 | ||
|
|
2804543b9d | ||
|
|
380397321c | ||
|
|
d9c8902d34 | ||
|
|
a1b9abd4cb | ||
|
|
5d30844fa8 | ||
|
|
af1b1b4249 | ||
|
|
2cce9ae56c | ||
|
|
4bab7c61a9 | ||
|
|
81e2bc1dba | ||
|
|
df2783d8a1 | ||
|
|
20a6953b54 | ||
|
|
fdd4f69247 | ||
|
|
5ee777b196 | ||
|
|
dd827af3cd | ||
|
|
948205667b | ||
|
|
dbbfa75429 | ||
|
|
78790f73df | ||
|
|
2950d8b3f9 | ||
|
|
4d3a9bc165 | ||
|
|
7d57845502 | ||
|
|
83ef111599 | ||
|
|
0e6848d886 | ||
|
|
1eeeb63a76 | ||
|
|
a4ba302223 | ||
|
|
8c9094d85a | ||
|
|
4529b107dd | ||
|
|
17663bc759 | ||
|
|
278257998b | ||
|
|
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 |
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 760 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 761 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 605 KiB |
+5
-8
@@ -4,10 +4,7 @@ Android app that detects and monitors AirPods via Bluetooth LE. Displays battery
|
|||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
| Module | Description |
|
Single Gradle module `app/` with multiple source sets (`main`, `foss`, `gplay`, `debug`, `test`, `testFoss`, `testGplay`, `screenshotTest`). A previous `app-common/` module was merged into `app/`.
|
||||||
|--------|-------------|
|
|
||||||
| `app/` | Main Android app (FOSS and Google Play flavors) |
|
|
||||||
| `app-common/` | Shared code between phone and Wear OS apps |
|
|
||||||
|
|
||||||
## Build Flavors
|
## Build Flavors
|
||||||
|
|
||||||
@@ -20,10 +17,10 @@ Quick build check: `./gradlew assembleFossDebug`
|
|||||||
|
|
||||||
| Path | Contains |
|
| Path | Contains |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `app/src/main/java/` | Main app source (activities, fragments, services) |
|
| `app/src/main/java/` | Main app source (Compose screens, services, receivers, monitor, bluetooth, models) |
|
||||||
| `app-common/src/main/java/` | Shared logic (monitor, bluetooth, models) |
|
| `app/src/foss/java/`, `app/src/gplay/java/` | Flavor-specific code (e.g. upgrade/billing) |
|
||||||
| `app/src/main/res/` | Layouts, drawables, strings |
|
| `app/src/main/res/` | Layouts, drawables, strings |
|
||||||
| `app-common/src/test/` | Unit tests |
|
| `app/src/test/`, `app/src/testFoss/`, `app/src/testGplay/` | Unit tests (shared + flavor-specific) |
|
||||||
| `app/build.gradle.kts` | App build config, dependencies, flavors |
|
| `app/build.gradle.kts` | App build config, dependencies, flavors |
|
||||||
| `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables |
|
| `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables |
|
||||||
| `fastlane/` | Screenshot generation scripts, Play Store metadata |
|
| `fastlane/` | Screenshot generation scripts, Play Store metadata |
|
||||||
@@ -31,7 +28,6 @@ Quick build check: `./gradlew assembleFossDebug`
|
|||||||
## Development Tips
|
## Development Tips
|
||||||
|
|
||||||
- Use `assembleFossDebug` as the fastest build variant for iteration
|
- Use `assembleFossDebug` as the fastest build variant for iteration
|
||||||
- Shared code goes in `app-common/`, app-specific code in `app/`
|
|
||||||
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
|
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
|
||||||
- Always use string resources for user-facing text (see localization rules)
|
- Always use string resources for user-facing text (see localization rules)
|
||||||
- Check `git log --oneline -20` for commit message style before committing
|
- Check `git log --oneline -20` for commit message style before committing
|
||||||
@@ -47,3 +43,4 @@ Detailed guidelines are in `.claude/rules/`:
|
|||||||
- `pull-requests.md` — PR title and description conventions
|
- `pull-requests.md` — PR title and description conventions
|
||||||
- `agent-instructions.md` — Sub-agent delegation and critical thinking
|
- `agent-instructions.md` — Sub-agent delegation and critical thinking
|
||||||
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
|
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
|
||||||
|
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
|
||||||
|
|||||||
@@ -2,16 +2,22 @@
|
|||||||
description: Architecture overview, module structure, key components, data flow, and dependencies
|
description: Architecture overview, module structure, key components, data flow, and dependencies
|
||||||
globs:
|
globs:
|
||||||
- "app/**/*.kt"
|
- "app/**/*.kt"
|
||||||
- "app-common/**/*.kt"
|
|
||||||
- "**/*.gradle.kts"
|
- "**/*.gradle.kts"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
## Multi-Module Structure
|
## Single-Module Structure
|
||||||
|
|
||||||
- **app/**: Main Android application with FOSS and Google Play flavors
|
One Gradle module: `app/`. Source sets:
|
||||||
- **app-common/**: Shared code between main app and Wear OS app
|
|
||||||
|
- `main` — shared code (Compose UI, services, monitor, bluetooth, AAP protocol, widgets)
|
||||||
|
- `foss` / `gplay` — flavor-specific code (e.g. upgrade/billing implementations)
|
||||||
|
- `debug` — debug-only code including screenshot content composables
|
||||||
|
- `test` / `testFoss` / `testGplay` — unit tests
|
||||||
|
- `screenshotTest` — Compose Preview Screenshot tests for Play Store assets
|
||||||
|
|
||||||
|
A previous `app-common/` module was merged into `app/` (commit `be8f4919`).
|
||||||
|
|
||||||
## Core Patterns
|
## Core Patterns
|
||||||
|
|
||||||
@@ -22,18 +28,62 @@ globs:
|
|||||||
|
|
||||||
## Key Components
|
## Key Components
|
||||||
|
|
||||||
### PodMonitor System
|
### Device Monitoring
|
||||||
|
|
||||||
- `PodMonitor`: Core service that detects and tracks AirPods via Bluetooth LE
|
`monitor/core/` is split into two data-source siblings that `DeviceMonitor` merges:
|
||||||
- `MonitorControl`: Manages MonitorService lifecycle
|
|
||||||
- `MonitorService`: Foreground service that continuously scans for AirPods
|
- `monitor/core/ble/BlePodMonitor` — passive BLE scanning; reads Apple advertisement beacons (battery, case state, in-ear, etc.). Works for any pod in range; no pairing required
|
||||||
- `BluetoothEventReceiver`: Handles system Bluetooth events
|
- `monitor/core/aap/` — AAP connection lifecycle layer on top of `AapConnectionManager`:
|
||||||
|
- `AapLifecycleManager` — starts/stops the AAP subsystem
|
||||||
|
- `AapAutoConnect` — auto-opens AAP sessions for bonded/known devices
|
||||||
|
- `AapKeyPersister`, `AapLearnedSettingsPersister` — persist session keys and learned pod settings across app restarts
|
||||||
|
- `StemConfigSender`, `StemPressReaction`, `AncGestureResolver` — push config and react to stem/HID events
|
||||||
|
- `monitor/core/cache/DeviceStateCache` — persisted last-known state so profiles still show data when a device is out of range
|
||||||
|
- `DeviceMonitor` — singleton that `combine`s `BlePodMonitor.devices + AapConnectionManager.allStates + DeviceStateCache + profiles` into unified `PodDevice` objects. ViewModels observe `DeviceMonitor.devices`; they do **not** reach into `BlePodMonitor` or the AAP layer directly
|
||||||
|
- `MonitorControl` / `MonitorService` — foreground service lifecycle holding the scan awake
|
||||||
|
- `BluetoothEventReceiver`, `BootCompletedReceiver` — system triggers that wake the service
|
||||||
|
|
||||||
|
**BLE vs AAP — what each path gives you:**
|
||||||
|
|
||||||
|
| | BLE (advertisements) | AAP (L2CAP session) |
|
||||||
|
|---|---|---|
|
||||||
|
| Direction | Read-only, passive | Bidirectional commands + events |
|
||||||
|
| Prerequisite | Bluetooth on | Bonded + `BLUETOOTH_CONNECT` + active L2CAP socket |
|
||||||
|
| Data | Battery, case open, in-ear, pod model | Settings, ANC mode control, press controls, stem events, device info |
|
||||||
|
| Availability | Any pod in range | Only your own paired pods |
|
||||||
|
|
||||||
### Reaction System
|
### Reaction System
|
||||||
|
|
||||||
- `ReactionSettingsFragment`: Configuration for popup notifications
|
- `ReactionsCard`: Compose UI for reaction settings, embedded in the device settings screen
|
||||||
- `PopUpWindow`: Displays AirPods status when case is opened
|
- `PopUpWindow`: Displays AirPods status when case is opened
|
||||||
- `PopUpPodViewFactory`: Creates UI components for different pod models
|
- `PopUpContent`: Compose pod rendering — model-specific UI branches inline, no factory class
|
||||||
|
|
||||||
|
### Widget System (Glance)
|
||||||
|
|
||||||
|
- `BatteryGlanceWidget`, `AncGlanceWidget`: Jetpack Glance-based home-screen widgets
|
||||||
|
- `WidgetConfigurationActivity`: Configuration UI launched on widget placement
|
||||||
|
- Lives under `app/src/main/java/eu/darken/capod/main/ui/widget/`
|
||||||
|
|
||||||
|
### Upgrade / Pro Features
|
||||||
|
|
||||||
|
- `UpgradeRepo` interface with two flavor implementations:
|
||||||
|
- `UpgradeRepoGplay` — billing-client backed, includes grace-period handling for interrupted purchases
|
||||||
|
- `UpgradeControlFoss` — cache/sponsor-backed; users are `isPro = false` until they call `upgrade()`, after which the pro flag is persisted via DataStore
|
||||||
|
- FOSS is **not** "always pro" — it's opt-in via a local sponsor flow
|
||||||
|
|
||||||
|
### AAP (Apple Accessory Protocol) Stack
|
||||||
|
|
||||||
|
Three-layer structure under `pods/core/apple/aap/`:
|
||||||
|
|
||||||
|
- **`protocol/`** — pure data: `AapMessage`, `AapCommand`, `AapSetting`, `AapDeviceProfile`, `AapDeviceInfo`, `StemPressEvent`, `KeyExchangeResult`. Plus `DefaultAapDeviceProfile` and `Model.Features` capturing per-model capability
|
||||||
|
- **`engine/`** — session state machine for one connection:
|
||||||
|
- `AapConnection` — the L2CAP socket wrapper
|
||||||
|
- `AapSessionEngine` — drives the session lifecycle; tested in `AapSessionEngineTest`
|
||||||
|
- `AapInboundInterpreter` / `AapOutboundController` — decode incoming messages, encode outgoing
|
||||||
|
- `AapSettingsCoordinator`, `AapAncController`, `HidTracker`, `AapDeviceInfoDiagnostics` — feature-specific coordinators that sit on top of the session
|
||||||
|
- **`AapConnectionManager`** (singleton) — owns all open AAP sessions keyed by `BluetoothAddress`, uses `L2capSocketFactory` to create sockets. Consumers don't touch `AapConnection` directly — they call `sendCommand(...)` and observe `allStates`
|
||||||
|
|
||||||
|
The monitor-layer glue (`monitor/core/aap/`) described above wires this stack into the foreground service and persists its learned state.
|
||||||
|
|
||||||
### Common Utilities
|
### Common Utilities
|
||||||
|
|
||||||
@@ -54,31 +104,20 @@ globs:
|
|||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
The app follows a unidirectional data flow:
|
1. `BluetoothEventReceiver` / `BootCompletedReceiver` wake `MonitorService` (foreground)
|
||||||
|
2. `MonitorService` keeps `BlePodMonitor` scanning (passive advertisements) and `AapLifecycleManager` running (active L2CAP sessions via `AapConnectionManager`)
|
||||||
1. `BluetoothEventReceiver` detects Bluetooth events
|
3. `DeviceMonitor` merges BLE + AAP + cached state + profiles into `PodDevice` objects
|
||||||
2. `MonitorService` scans for AirPods beacon data
|
4. ViewModels (`OverviewViewModel`, `DeviceSettingsViewModel`, `PressControlsViewModel`, widget view models) observe `DeviceMonitor.devices`; settings/command changes are sent back through `AapConnectionManager.sendCommand(...)`
|
||||||
3. `PodMonitor` processes and stores device information
|
5. Reaction triggers (case-open popup, auto-play, notifications) and widget state updates react to the merged flow
|
||||||
4. ViewModels observe monitor data via repositories
|
|
||||||
5. UI components react to ViewModel state changes
|
|
||||||
6. `ReactionSystem` triggers popups and notifications
|
|
||||||
|
|
||||||
## Bluetooth LE Implementation
|
|
||||||
|
|
||||||
The app uses Android's Bluetooth LE APIs to scan for Apple device advertisements. The core scanning logic is in `MonitorService` which runs as a foreground service.
|
|
||||||
|
|
||||||
## Multi-Platform Considerations
|
|
||||||
|
|
||||||
Code shared between phone and Wear OS apps is placed in `app-common`. When modifying shared functionality, ensure compatibility across both platforms.
|
|
||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
||||||
- **Unit Tests**: Located in `app-common/src/test/` for shared logic
|
- **Unit Tests**: `app/src/test/` (shared), `app/src/testFoss/`, `app/src/testGplay/` (flavor-specific — e.g. `UpgradeRepoGplayTest`, `FossUpgradeSerializationTest`)
|
||||||
- **Test Flavors**: Separate test configurations for FOSS and Google Play variants
|
- **Screenshot Tests**: `app/src/screenshotTest/` — Compose Preview Screenshot Testing, powers the Play Store screenshot pipeline
|
||||||
|
|
||||||
## Key Dependencies
|
## Key Dependencies
|
||||||
|
|
||||||
- **Hilt**: Dependency injection framework
|
- **Hilt**: Dependency injection framework
|
||||||
- **AndroidX Navigation**: Fragment navigation with SafeArgs
|
- **Navigation**: Navigation3 (`addNavigation3()`) drives current Compose screen routing. Some legacy `androidx.navigation` helpers still exist (`NavDirectionsExtensions`, `ViewModel3`) — don't assume SafeArgs is fully gone
|
||||||
- **kotlinx.serialization**: JSON serialization for configuration and caching
|
- **kotlinx.serialization**: JSON serialization for configuration and caching
|
||||||
- **Material Design**: UI components following Material Design guidelines
|
- **Material Design 3**: Compose Material3 UI components
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Use the existing commit history as reference. Common prefixes:
|
|||||||
- **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`)
|
- **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`)
|
||||||
- **feat**: New features
|
- **feat**: New features
|
||||||
- **refactor**: Code restructuring without behavior change
|
- **refactor**: Code restructuring without behavior change
|
||||||
|
- **ui**: Visual/layout-only tweaks that aren't a full feature or refactor (e.g., `ui(overview): ...`)
|
||||||
- **chore**: Maintenance, dependency updates, build config
|
- **chore**: Maintenance, dependency updates, build config
|
||||||
- **docs**: Documentation changes
|
- **docs**: Documentation changes
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ globs:
|
|||||||
When adding new user-facing strings:
|
When adding new user-facing strings:
|
||||||
|
|
||||||
- **Always use string resources**: Never hardcode user-facing text in layouts or code
|
- **Always use string resources**: Never hardcode user-facing text in layouts or code
|
||||||
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_bluetooth_enabled`)
|
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_monitor_mode_label`)
|
||||||
- **Provide context**: String names should indicate usage and location
|
- **Provide context**: String names should indicate usage and location
|
||||||
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
|
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
|
||||||
|
|
||||||
@@ -17,5 +17,7 @@ When adding new user-facing strings:
|
|||||||
|
|
||||||
- `profiles_create_title` (screen title)
|
- `profiles_create_title` (screen title)
|
||||||
- `profiles_name_label` (form field label)
|
- `profiles_name_label` (form field label)
|
||||||
- `profiles_delete_confirmation` (dialog message)
|
- `profiles_name_default` (default value)
|
||||||
- `error_network_unavailable` (error message)
|
- `troubleshooter_ble_result_failure_title` (status/error title)
|
||||||
|
|
||||||
|
Common prefixes currently in use: `device_`, `settings_`, `support_`, `profiles_`, `press_`, `general_`, `pods_`, `upgrade_`, `widget_`, `debug_`, `permission_`, `troubleshooter_`, `overview_`, `anc_`, `onboarding_`. There is no `error_*` prefix — error labels live under the relevant feature (e.g. `general_error_label`, `troubleshooter_*_failure_*`).
|
||||||
|
|||||||
@@ -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 |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (8 screens) |
|
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (7 screens) |
|
||||||
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions that wire content to locale annotations |
|
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions (currently: `DashboardLight`, `DashboardDark`, `CasePopUp`, `DeviceProfiles`, `AddProfile`, `DeviceSettingsReactions`, `WidgetConfiguration`) |
|
||||||
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
|
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
|
||||||
| `fastlane/generate_screenshots.sh` | Batched generation across 68 locales |
|
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
|
||||||
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
||||||
|
|
||||||
|
## 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
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full run — all 68 locales, ~12 batches, ~7 minutes
|
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
|
||||||
./fastlane/generate_screenshots.sh
|
# Use this for local iteration and PRs that touch screenshot content.
|
||||||
|
|
||||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
|
||||||
./fastlane/generate_screenshots.sh --smoke
|
./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)
|
# Copy into fastlane directories (run after generate)
|
||||||
./fastlane/copy_screenshots.sh
|
./fastlane/copy_screenshots.sh
|
||||||
|
|
||||||
# Clean copy (removes old screenshots first)
|
# Clean copy (removes old screenshots first) — REQUIRED when screens are removed or renamed
|
||||||
./fastlane/copy_screenshots.sh --clean
|
./fastlane/copy_screenshots.sh --clean
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,20 +55,39 @@ ScreenshotContent.kt (mock data + composables)
|
|||||||
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
||||||
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
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)
|
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
|
## 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
|
```bash
|
||||||
./fastlane/generate_screenshots.sh
|
./fastlane/generate_screenshots.sh --smoke
|
||||||
./fastlane/copy_screenshots.sh --clean
|
./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
|
## Technical Notes
|
||||||
|
|
||||||
- Batch size defaults to 2 locales (16 renders) to avoid layoutlib memory leak (~10MB/image)
|
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
|
||||||
- Gradle daemon is stopped between batches to release memory
|
- Gradle daemon is stopped between batches to release memory
|
||||||
- `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
|
- `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
|
||||||
- Device spec: 1080x2400px @ 428 DPI (Pixel-class phone)
|
- Device spec: 1080x2400px @ 428 DPI (Pixel-class phone)
|
||||||
|
|||||||
@@ -31,12 +31,3 @@ runs:
|
|||||||
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-gradle-caches-
|
${{ runner.os }}-gradle-caches-
|
||||||
|
|
||||||
- name: Cache Android Global Build-Cache
|
|
||||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.android/build-cache
|
|
||||||
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-android-build-cache-
|
|
||||||
@@ -3,6 +3,9 @@ name: Code tests & eval
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
paths-ignore:
|
||||||
|
- VERSION
|
||||||
|
- version.properties
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
@@ -79,3 +82,26 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Validate metadata lengths
|
- name: Validate metadata lengths
|
||||||
run: bash fastlane/check_metadata_length.sh
|
run: bash fastlane/check_metadata_length.sh
|
||||||
|
|
||||||
|
check-release-tooling:
|
||||||
|
name: Release tooling
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install bats and shellcheck
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y bats shellcheck
|
||||||
|
|
||||||
|
- name: Shellcheck bump.sh
|
||||||
|
run: shellcheck tools/release/bump.sh
|
||||||
|
|
||||||
|
- name: Run bump.sh unit tests
|
||||||
|
run: bats tools/release/bump.bats
|
||||||
|
|
||||||
|
- name: Verify version.properties + VERSION are consistent
|
||||||
|
run: ./tools/release/bump.sh --mode=check
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- VERSION
|
||||||
|
- version.properties
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- 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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
validate-tag:
|
||||||
|
name: Validate tag
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Check tag-name format
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then
|
||||||
|
echo "Tag '${REF_NAME}' does not match v<M.m.p-(rc|beta)n>" >&2
|
||||||
|
echo "Releases must be cut via the 'Release prepare' workflow." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify version.properties matches tag
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Strip leading 'v' to get the bare version name.
|
||||||
|
tag_name="${REF_NAME#v}"
|
||||||
|
# bump.sh in check mode emits current_name=...
|
||||||
|
parsed=$(./tools/release/bump.sh --mode=check)
|
||||||
|
current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2)
|
||||||
|
if [[ "$current_name" != "$tag_name" ]]; then
|
||||||
|
echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2
|
||||||
|
echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
release-github:
|
release-github:
|
||||||
|
needs: validate-tag
|
||||||
name: Create GitHub release
|
name: Create GitHub release
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
actions: write
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
environment: foss-production
|
environment: foss-production
|
||||||
steps:
|
steps:
|
||||||
@@ -61,7 +104,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create pre-release
|
- name: Create pre-release
|
||||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
@@ -73,7 +116,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||||
with:
|
with:
|
||||||
prerelease: false
|
prerelease: false
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
@@ -83,10 +126,19 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Trigger GitHub Pages deployment
|
||||||
|
if: "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: gh workflow run pages.yml --ref main
|
||||||
|
|
||||||
release-gplay:
|
release-gplay:
|
||||||
|
needs: validate-tag
|
||||||
name: Create Google Play release
|
name: Create Google Play release
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
environment: gplay-production
|
environment: gplay-production
|
||||||
|
env:
|
||||||
|
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
|
||||||
steps:
|
steps:
|
||||||
- name: Decode Keystore
|
- name: Decode Keystore
|
||||||
env:
|
env:
|
||||||
@@ -122,6 +174,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ruby-version: 3.3.6
|
ruby-version: 3.3.6
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
working-directory: fastlane
|
||||||
|
|
||||||
|
- name: Verify fastlane Bundler wiring
|
||||||
|
run: bundle exec fastlane --version
|
||||||
|
|
||||||
- name: Assemble beta and upload to Google Play
|
- name: Assemble beta and upload to Google Play
|
||||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||||
|
|||||||
+13
@@ -17,5 +17,18 @@
|
|||||||
.kotlin
|
.kotlin
|
||||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||||
app/src/screenshotTest*/reference/
|
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
|
.codex
|
||||||
protocol-research/
|
protocol-research/
|
||||||
|
_site/
|
||||||
|
.jekyll-cache/
|
||||||
|
vendor/bundle/
|
||||||
@@ -81,8 +81,7 @@ Currently supported models:
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
|
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
|
||||||
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
|
|
||||||
|
|
||||||
## Thanks to
|
## Thanks to
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -20,9 +20,6 @@ exclude:
|
|||||||
- buildSrc
|
- buildSrc
|
||||||
- gradle/wrapper
|
- gradle/wrapper
|
||||||
- fastlane
|
- fastlane
|
||||||
- Gemfile
|
|
||||||
- Gemfile.lock
|
|
||||||
- crowdin*
|
- crowdin*
|
||||||
- app
|
- app
|
||||||
- app-common
|
|
||||||
- CONTRIBUTING.md
|
- CONTRIBUTING.md
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ android {
|
|||||||
//noinspection WrongGradleMethod
|
//noinspection WrongGradleMethod
|
||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
maxHeapSize = "4g"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,21 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import eu.darken.capod.common.theming.CapodTheme
|
import eu.darken.capod.common.theming.CapodTheme
|
||||||
import eu.darken.capod.main.ui.overview.OverviewScreen
|
import eu.darken.capod.main.ui.overview.OverviewScreen
|
||||||
import eu.darken.capod.main.ui.overview.OverviewViewModel
|
import eu.darken.capod.main.ui.overview.OverviewViewModel
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
||||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
||||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
||||||
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
||||||
import eu.darken.capod.main.ui.widget.WidgetTheme
|
import eu.darken.capod.main.ui.widget.WidgetTheme
|
||||||
|
import eu.darken.capod.main.ui.widget.withLocalizedPreviewEstimates
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.profiles.ui.DeviceManagerScreen
|
import eu.darken.capod.profiles.ui.DeviceManagerScreen
|
||||||
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
||||||
@@ -37,17 +41,49 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
|||||||
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||||
|
|
||||||
@Composable
|
@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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Plausible estimates for the mock battery levels: runtime under each gauge, charge ETA in
|
||||||
|
// the charging chip of whichever mock pod is charging.
|
||||||
|
val estimates = if (showAap) {
|
||||||
|
mapOf(
|
||||||
|
"preview-dual-aap" to BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE),
|
||||||
|
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
"preview-single-aap" to BatteryEstimate(
|
||||||
|
headset = BatteryEstimate.Pod(480, 0.05f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 45),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
mapOf(
|
||||||
|
"preview-dual-mixed" to BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 20),
|
||||||
|
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
"preview-single" to BatteryEstimate(
|
||||||
|
headset = BatteryEstimate.Pod(1020, 0.05f, BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
OverviewScreen(
|
OverviewScreen(
|
||||||
state = OverviewViewModel.State(
|
state = OverviewViewModel.State(
|
||||||
now = MOCK_NOW,
|
now = MOCK_NOW,
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = listOf(
|
devices = devices,
|
||||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
isDebug = false,
|
||||||
MockPodDataProvider.singlePodMonitored(),
|
|
||||||
MockPodDataProvider.unknownMonitored(),
|
|
||||||
),
|
|
||||||
isDebugMode = false,
|
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(
|
profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
@@ -55,6 +91,7 @@ internal fun DashboardContent() = PreviewWrapper {
|
|||||||
),
|
),
|
||||||
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
|
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
|
||||||
showUnmatchedDevices = false,
|
showUnmatchedDevices = false,
|
||||||
|
batteryEstimates = estimates,
|
||||||
),
|
),
|
||||||
onRequestPermission = {},
|
onRequestPermission = {},
|
||||||
onBluetoothSettings = {},
|
onBluetoothSettings = {},
|
||||||
@@ -83,19 +120,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddProfileContent() = PreviewWrapper {
|
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(
|
DeviceProfileCreationScreen(
|
||||||
state = DeviceProfileCreationViewModel.State(
|
state = DeviceProfileCreationViewModel.State(
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
name = "",
|
name = "My AirPods Pro",
|
||||||
nameError = null,
|
nameError = null,
|
||||||
selectedModel = null,
|
selectedModel = PodModel.AIRPODS_PRO2,
|
||||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||||
identityKey = null,
|
identityKey = null,
|
||||||
encryptionKey = null,
|
encryptionKey = null,
|
||||||
selectedDevice = null,
|
selectedDevice = pairedAirPods,
|
||||||
bondedDeviceItems = emptyList(),
|
bondedDeviceItems = bondedItems,
|
||||||
minimumSignalQuality = 0.15f,
|
minimumSignalQuality = 0.15f,
|
||||||
canSave = false,
|
canSave = true,
|
||||||
),
|
),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSave = {},
|
onSave = {},
|
||||||
@@ -118,6 +174,7 @@ internal fun DeviceSettingsReactionsContent() = PreviewWrapper {
|
|||||||
now = MOCK_NOW,
|
now = MOCK_NOW,
|
||||||
isPro = true,
|
isPro = true,
|
||||||
isNudgeAvailable = true,
|
isNudgeAvailable = true,
|
||||||
|
isClassicallyConnected = true,
|
||||||
),
|
),
|
||||||
onNavigateUp = {},
|
onNavigateUp = {},
|
||||||
)
|
)
|
||||||
@@ -199,7 +256,7 @@ internal fun HomescreenWidgetContent() {
|
|||||||
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
|
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
|
||||||
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||||
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||||
),
|
).withLocalizedPreviewEstimates(LocalContext.current),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,7 +268,7 @@ private fun PreviewDashboardLight() = DashboardContent()
|
|||||||
|
|
||||||
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviewDashboardDark() = DashboardContent()
|
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
|
||||||
|
|
||||||
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="foss_upgrade_donate_label">تبرُّع</string>
|
<string name="foss_upgrade_donate_label">تبرُّع</string>
|
||||||
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string>
|
<string name="foss_upgrade_alreadydonated_label">لقد تبرّعتُ بالفعل</string>
|
||||||
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string>
|
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على سمّاعات AirPods</string>
|
||||||
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيداً، فكر في دعم التطوير للمساعدة في استمرار المشروع.</string>
|
<string name="upgrade_foss_preamble">برنامج CAPod FOSS مجاني ومفتوح المصدر. إذا وجدتهُ مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
|
||||||
<string name="upgrade_foss_sponsor_action">ادعم التطوير</string>
|
<string name="upgrade_foss_sponsor_action">دعم التطوير</string>
|
||||||
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود على Google Play.</string>
|
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبُّع. لا قيود من جوجل بلاي.</string>
|
||||||
<string name="upgrade_foss_sponsor_returned_early">عدت بسرعة؟ دعمك يبقي CAPod حياً.</string>
|
<string name="upgrade_foss_sponsor_returned_early">هل عدت بالفعل؟ دعمك يُبقي CAPod حيًّا.</string>
|
||||||
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
|
<string name="upgrade_badge_label">البرمجيات الحرة والمفتوحة المصدر</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<string name="foss_upgrade_no_money_label">Es iztērēju visu savu naudu AirPods austiņām</string>
|
<string name="foss_upgrade_no_money_label">Es iztērēju visu savu naudu AirPods austiņām</string>
|
||||||
<string name="upgrade_foss_preamble">CAPod FOSS ir bezmaksas un atkļējkoda. Ja tā ir noderba, apsveriet attīstības sponsēšanu, lai palīdzētu turpināt projektu.</string>
|
<string name="upgrade_foss_preamble">CAPod FOSS ir bezmaksas un atkļējkoda. Ja tā ir noderba, apsveriet attīstības sponsēšanu, lai palīdzētu turpināt projektu.</string>
|
||||||
<string name="upgrade_foss_sponsor_action">Sponsorēt attīstību</string>
|
<string name="upgrade_foss_sponsor_action">Sponsorēt attīstību</string>
|
||||||
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez seklōšanas. Bez Google Play saistībām.</string>
|
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez izsekošanas. Bez Google Play saistībām.</string>
|
||||||
<string name="upgrade_foss_sponsor_returned_early">Jau atpakaļ? Jūsu atbalsts uztur CAPod pie dzīvības.</string>
|
<string name="upgrade_foss_sponsor_returned_early">Jau atpakaļ? Tavs atbalsts uztur CAPod esamību.</string>
|
||||||
<string name="upgrade_badge_label">FOSS</string>
|
<string name="upgrade_badge_label">FOSS</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متاحة.</string>
|
<string name="upgrades_gplay_unavailable_error">خدمات جوجل بلاي غير متوفّرة.</string>
|
||||||
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
|
<string name="upgrades_no_purchases_found_check_account">لم يُعثَر على أيّ مشتريات. هل تستخدم الحساب الصحيح؟</string>
|
||||||
<string name="upgrades_gplay_billing_error_label">خطأ في Google Play</string>
|
<string name="upgrades_gplay_billing_error_label">خطأ في جوجل بلاي</string>
|
||||||
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يرجى المحاولة مرة أخرى لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
|
<string name="upgrades_gplay_billing_error_description">حدث خطأ في سوق جوجل بلاي. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nالخطأ: %s</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string>
|
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة جوجل بلاي</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ عند طلب تفاصيل الشراء من Google Play. امسح ذاكرة التخزين المؤقت لـ Google Play وأعد تشغيل هاتفك.\n\nخطأ %s</string>
|
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من سوق جوجل بلاي. يُرجى مسح ذاكرة التخزين المؤقّت لسوق جوجل بلاي وإعادة تشغيل هاتفك.\n\nالخطأ %s</string>
|
||||||
<string name="upgrade_restore_action">استعادة المُشتَرَى</string>
|
<string name="upgrade_restore_action">استعادة عملية الشراء</string>
|
||||||
<string name="upgrade_badge_label">احترافي</string>
|
<string name="upgrade_badge_label">النسخة الاحترافية</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="upgrades_gplay_unavailable_error">Google Play pakalpojumi nav pieejami.</string>
|
<string name="upgrades_gplay_unavailable_error">Google Play pakalpojumi nav pieejami.</string>
|
||||||
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmantojat pareizo kontu?</string>
|
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmanto pareizo kontu?</string>
|
||||||
<string name="upgrades_gplay_billing_error_label">Google Play kļūda</string>
|
<string name="upgrades_gplay_billing_error_label">Google Play kļūda</string>
|
||||||
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūdzu, mēģiniet vēlreiz vēlāk vai pārstartējiet tālruni.\n\nKļūda: %s</string>
|
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūgums mēģināt vēlreiz vēlāk vai pārsāknēt tālruni.\n\nKļūda: %s</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_label">Google Play norēķinu kļūda</string>
|
<string name="upgrades_gplay_billing_result_error_label">Google Play norēķinu kļūda</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play jūsu pirkuma informāciju. Notīriet Google Play kešatmiņu un pārstartējiet tālruni.\n\nKļūda %s</string>
|
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play pirkuma informāciju. Jāiztīra Google Play kešatmiņa un jāpārsāknē tālrunis.\n\nKļūda %s</string>
|
||||||
<string name="upgrade_restore_action">Atjaunot pirkumu</string>
|
<string name="upgrade_restore_action">Atjaunot pirkumu</string>
|
||||||
<string name="upgrade_badge_label">Pro</string>
|
<string name="upgrade_badge_label">Pro</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -43,6 +43,11 @@
|
|||||||
android:name="android.hardware.bluetooth_le"
|
android:name="android.hardware.bluetooth_le"
|
||||||
android:required="true" />
|
android:required="true" />
|
||||||
|
|
||||||
|
<!-- Package visibility (API 30+): resolving the MIUI/HyperOS autostart settings screen. -->
|
||||||
|
<queries>
|
||||||
|
<package android:name="com.miui.securitycenter" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -141,6 +146,20 @@
|
|||||||
android:name=".monitor.core.worker.MonitorService"
|
android:name=".monitor.core.worker.MonitorService"
|
||||||
android:foregroundServiceType="connectedDevice"
|
android:foregroundServiceType="connectedDevice"
|
||||||
android:exported="false" />
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,33 +1,37 @@
|
|||||||
package eu.darken.capod
|
package eu.darken.capod
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.Looper
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
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.autoreport.AutomaticBugReporter
|
||||||
import eu.darken.capod.common.debug.logging.LogCatLogger
|
import eu.darken.capod.common.debug.logging.LogCatLogger
|
||||||
import eu.darken.capod.common.debug.logging.Logging
|
import eu.darken.capod.common.debug.logging.Logging
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
import eu.darken.capod.common.debug.logging.Logging.Priority.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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.throttleLatest
|
import eu.darken.capod.common.flow.throttleLatest
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||||
|
import eu.darken.capod.main.ui.widget.toWidgetKey
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.displayKey
|
||||||
|
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||||
|
|
||||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.system.exitProcess
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
open class App : Application() {
|
open class App : Application() {
|
||||||
@@ -36,25 +40,26 @@ open class App : Application() {
|
|||||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||||
@Inject lateinit var widgetManager: WidgetManager
|
@Inject lateinit var widgetManager: WidgetManager
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||||
|
@Inject lateinit var batteryEstimator: BatteryEstimator
|
||||||
@Inject @AppScope lateinit var appScope: CoroutineScope
|
@Inject @AppScope lateinit var appScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
||||||
|
|
||||||
var foregroundExceptionHandled = false
|
|
||||||
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler(
|
||||||
if (throwable.isForegroundServiceTimingException() && !foregroundExceptionHandled) {
|
CapodUncaughtExceptionHandler(
|
||||||
foregroundExceptionHandled = true
|
previousHandler = oldHandler,
|
||||||
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
cancelBeforeDelegate = { throwable ->
|
||||||
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
|
// Best-effort shutdown: the system handler may terminate the process immediately,
|
||||||
Looper.loop()
|
// but cancellation can still close sockets if it gets a scheduling window.
|
||||||
return@setDefaultUncaughtExceptionHandler
|
if (::appScope.isInitialized) {
|
||||||
}
|
appScope.cancel(CancellationException("Uncaught exception", throwable))
|
||||||
log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" }
|
}
|
||||||
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
|
},
|
||||||
}
|
)
|
||||||
|
)
|
||||||
|
|
||||||
autoReporting.setup(this)
|
autoReporting.setup(this)
|
||||||
|
|
||||||
@@ -62,11 +67,18 @@ open class App : Application() {
|
|||||||
|
|
||||||
appScope.launch { widgetManager.refreshWidgets() }
|
appScope.launch { widgetManager.refreshWidgets() }
|
||||||
|
|
||||||
deviceMonitor.devicesWithProfiles()
|
combine(
|
||||||
.distinctUntilChanged()
|
deviceMonitor.devicesWithProfiles(),
|
||||||
|
batteryEstimator.estimates,
|
||||||
|
) { devices, estimates -> devices to estimates }
|
||||||
|
// Estimate display values can change while the device key is stable (e.g. a charge
|
||||||
|
// ETA gets suppressed after a stall) — refresh on those too, but only on visible ones.
|
||||||
|
.distinctUntilChangedBy { (devices, estimates) ->
|
||||||
|
devices.map { device -> device.toWidgetKey() to estimates.estimateFor(device)?.displayKey(device) }
|
||||||
|
}
|
||||||
.throttleLatest(1000)
|
.throttleLatest(1000)
|
||||||
.onEach {
|
.onEach {
|
||||||
log(TAG) { "Main device changed, refreshing widgets." }
|
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
|
||||||
widgetManager.refreshWidgets()
|
widgetManager.refreshWidgets()
|
||||||
}
|
}
|
||||||
.launchIn(appScope)
|
.launchIn(appScope)
|
||||||
@@ -83,14 +95,5 @@ open class App : Application() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val TAG = logTag("CAP")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package eu.darken.capod.common
|
package eu.darken.capod.common
|
||||||
|
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
|
import android.media.AudioPlaybackConfiguration
|
||||||
|
import android.os.Build
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -14,37 +17,103 @@ class MediaControl @Inject constructor(
|
|||||||
private val audioManager: AudioManager,
|
private val audioManager: AudioManager,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
) {
|
) {
|
||||||
private var capPauseExpiryElapsedRealtime: Long = 0L
|
/**
|
||||||
|
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
|
||||||
|
* dispatches a resume or when music transitions inactive→active from any source. Read by
|
||||||
|
* [PlayPause] to gate auto-resume on pod-in: only resume if we're the ones who paused.
|
||||||
|
*
|
||||||
|
* "Sticky" — no time-based expiry. The original 15-second window was too short for typical
|
||||||
|
* pod-out conversations and had a known race where music starting again via another source
|
||||||
|
* (e.g. user manually resumed) could trigger a stray play-key dispatch on the next pod-in.
|
||||||
|
*/
|
||||||
|
@Volatile private var capPaused: Boolean = false
|
||||||
|
@Volatile private var lastKnownMusicActive: Boolean = false
|
||||||
|
|
||||||
|
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
|
||||||
|
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
|
||||||
|
val nowActive = audioManager.isMusicActive
|
||||||
|
if (!lastKnownMusicActive && nowActive) {
|
||||||
|
// Music started by some source (could be us via sendPlay or someone else).
|
||||||
|
// Either way, our pause memory is stale — drop it so a future pod-in doesn't
|
||||||
|
// re-fire a play key over already-active music.
|
||||||
|
capPaused = false
|
||||||
|
}
|
||||||
|
lastKnownMusicActive = nowActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Seed the active flag from current state so we won't miss the next inactive→active
|
||||||
|
// transition if music is already playing when MediaControl is constructed.
|
||||||
|
lastKnownMusicActive = audioManager.isMusicActive
|
||||||
|
audioManager.registerAudioPlaybackCallback(playbackCallback, null)
|
||||||
|
}
|
||||||
|
|
||||||
val isPlaying: Boolean
|
val isPlaying: Boolean
|
||||||
get() = audioManager.isMusicActive
|
get() = audioManager.isMusicActive
|
||||||
|
|
||||||
val wasRecentlyPausedByCap: Boolean
|
val wasRecentlyPausedByCap: Boolean
|
||||||
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime()
|
get() = capPaused
|
||||||
|
|
||||||
suspend fun sendPlay() {
|
suspend fun sendPlay() {
|
||||||
log(TAG, INFO) { "sendPlay()" }
|
log(TAG, INFO) { "sendPlay()" }
|
||||||
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
|
if (audioManager.isMusicActive && !capPaused) {
|
||||||
log(TAG, INFO) { "Music is already playing, not sending play" }
|
log(TAG, INFO) { "Music is already playing, not sending play" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
|
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
|
||||||
clearRecentCapPause()
|
capPaused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendPause() {
|
/**
|
||||||
log(TAG, INFO) { "sendPause()" }
|
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
|
||||||
|
*
|
||||||
|
* @param rememberForResume When `true`, arms [wasRecentlyPausedByCap] so a subsequent
|
||||||
|
* pod-in transition can auto-resume — set this only from the auto-pause / ear-detection
|
||||||
|
* flow. When `false` (default), the dispatched pause clears any pending auto-resume —
|
||||||
|
* this is the path for stem-press play/pause, sleep detection, and anywhere else CAPod
|
||||||
|
* is relaying an explicit user choice to stop playback. Matches Apple's iOS/macOS
|
||||||
|
* behavior where only ear-removal auto-pauses are eligible for auto-resume.
|
||||||
|
*
|
||||||
|
* Returns `true` when a key event was actually dispatched, `false` when the call was a
|
||||||
|
* no-op because nothing was playing. A no-op leaves the existing flag state untouched
|
||||||
|
* (so a sleep-reaction firing while music is already paused doesn't accidentally cancel
|
||||||
|
* a pending auto-resume from a recent ear-removal pause). 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(rememberForResume: Boolean = false): Boolean {
|
||||||
|
log(TAG, INFO) { "sendPause(rememberForResume=$rememberForResume)" }
|
||||||
if (!audioManager.isMusicActive) {
|
if (!audioManager.isMusicActive) {
|
||||||
log(TAG, INFO) { "Music is not playing, not sending pause" }
|
log(TAG, INFO) { "Music is not playing, not sending pause" }
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
// Set BEFORE the suspending sendKey() call. If we set after, an inactive→active
|
||||||
|
// playback callback that fires during the dispatch (e.g. a fast user resume on the
|
||||||
|
// phone, or another app grabbing audio focus and immediately starting) could clear
|
||||||
|
// capPaused mid-dispatch and we'd then overwrite it back to true on a stale pause.
|
||||||
|
// This single explicit assignment also covers the contract that an explicit user
|
||||||
|
// pause cancels a pending auto-resume.
|
||||||
|
capPaused = rememberForResume
|
||||||
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
||||||
markRecentCapPause()
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches MEDIA_STOP and clears any pending auto-resume — Stop is an explicit user
|
||||||
|
* "stay stopped" action, so a later pod-in must not auto-resume from a prior auto-pause.
|
||||||
|
*/
|
||||||
|
suspend fun sendStop() {
|
||||||
|
log(TAG, INFO) { "sendStop()" }
|
||||||
|
capPaused = false
|
||||||
|
sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendPlayPause() {
|
suspend fun sendPlayPause() {
|
||||||
log(TAG) { "sendPlayPause()" }
|
log(TAG) { "sendPlayPause()" }
|
||||||
if (wasRecentlyPausedByCap) {
|
if (capPaused) {
|
||||||
sendPlay()
|
sendPlay()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -90,16 +159,71 @@ class MediaControl @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun markRecentCapPause() {
|
/** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */
|
||||||
capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS
|
fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns
|
||||||
|
* the prior + the volume actually applied, so the caller can later restore it and detect whether
|
||||||
|
* the user changed the volume in the meantime.
|
||||||
|
*
|
||||||
|
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed
|
||||||
|
* target wouldn't actually lower the volume. No [AudioManager.FLAG_SHOW_UI] — this fires on a
|
||||||
|
* frequent push event and the volume panel flashing would be noisy. The applied target is read
|
||||||
|
* back from the system because Bluetooth absolute-volume routes can quantize the requested value.
|
||||||
|
*/
|
||||||
|
fun duckMusicVolume(reductionPercent: Int): VolumeDuck? {
|
||||||
|
if (!audioManager.isMusicActive) {
|
||||||
|
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (audioManager.isVolumeFixed) {
|
||||||
|
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val percent = reductionPercent.coerceIn(0, 100)
|
||||||
|
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
val min = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
val prior = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
|
||||||
|
if (target >= prior) {
|
||||||
|
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
|
||||||
|
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
|
||||||
|
VolumeDuck(priorVolume = prior, appliedVolume = applied)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// setStreamVolume throws under Do-Not-Disturb without notification policy access.
|
||||||
|
log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearRecentCapPause() {
|
/** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */
|
||||||
capPauseExpiryElapsedRealtime = 0L
|
fun restoreMusicVolume(priorVolume: Int) {
|
||||||
|
if (audioManager.isVolumeFixed) return
|
||||||
|
try {
|
||||||
|
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, priorVolume, 0)
|
||||||
|
log(TAG, INFO) { "restoreMusicVolume($priorVolume)" }
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
log(TAG, WARN) { "restoreMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */
|
||||||
|
data class VolumeDuck(
|
||||||
|
val priorVolume: Int,
|
||||||
|
val appliedVolume: Int,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("MediaControl")
|
private val TAG = logTag("MediaControl")
|
||||||
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ fun BleScanResult.logSummary(): String {
|
|||||||
.sortedBy { it.key }
|
.sortedBy { it.key }
|
||||||
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
|
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
|
||||||
.ifEmpty { "-" }
|
.ifEmpty { "-" }
|
||||||
return "addr=${address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
|
return "addr=${address.redactedForLogs()}, rssi=$rssi, gen=$generatedAtNanos, payloads=[$payloadSummary]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmName("logBleScanResultCollectionSummary")
|
@JvmName("logBleScanResultCollectionSummary")
|
||||||
@@ -34,7 +34,7 @@ fun ScanResult.logSummary(): String {
|
|||||||
.sorted()
|
.sorted()
|
||||||
.joinToString(separator = ",")
|
.joinToString(separator = ",")
|
||||||
.ifEmpty { "-" }
|
.ifEmpty { "-" }
|
||||||
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
|
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, gen=$timestampNanos, payloads=[$payloadSummary]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmName("logFrameworkScanResultCollectionSummary")
|
@JvmName("logFrameworkScanResultCollectionSummary")
|
||||||
|
|||||||
@@ -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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -32,7 +33,6 @@ import javax.inject.Singleton
|
|||||||
class BleScanner @Inject constructor(
|
class BleScanner @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
private val fakeBleData: FakeBleData,
|
|
||||||
private val scanResultForwarder: BleScanResultForwarder,
|
private val scanResultForwarder: BleScanResultForwarder,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
) {
|
) {
|
||||||
@@ -113,22 +113,7 @@ class BleScanner @Inject constructor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val flushJob = if (!disableDirectScanCallback) {
|
var flushJob: Job? = null
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
val filterList = when {
|
val filterList = when {
|
||||||
useOffloadedFiltering -> filters.toList()
|
useOffloadedFiltering -> filters.toList()
|
||||||
@@ -167,31 +152,59 @@ class BleScanner @Inject constructor(
|
|||||||
setReportDelay(delay)
|
setReportDelay(delay)
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
if (disableDirectScanCallback) {
|
try {
|
||||||
val callbackIntent = createStartIntent()
|
if (disableDirectScanCallback) {
|
||||||
log(TAG) {
|
val callbackIntent = createStartIntent()
|
||||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
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)
|
} catch (e: SecurityException) {
|
||||||
} else {
|
log(TAG, WARN) { "startScan() denied: ${e.message}" }
|
||||||
log(TAG) {
|
forwarderConsumer?.cancel()
|
||||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
close(e)
|
||||||
}
|
return@callbackFlow
|
||||||
scanner.startScan(filterList, scanSettings, callback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
forwarderConsumer?.cancel()
|
forwarderConsumer?.cancel()
|
||||||
flushJob?.cancel()
|
flushJob?.cancel()
|
||||||
if (disableDirectScanCallback) {
|
try {
|
||||||
scanner.stopScan(createStopIntent())
|
if (disableDirectScanCallback) {
|
||||||
} else {
|
scanner.stopScan(createStopIntent())
|
||||||
scanner.stopScan(callback)
|
} else {
|
||||||
|
scanner.stopScan(callback)
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
|
||||||
}
|
}
|
||||||
log(TAG) { "BleScanner stopped" }
|
log(TAG) { "BleScanner stopped" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map { fakeBleData.maybeAddfakeData(it) }
|
|
||||||
|
|
||||||
private val receiverIntent by lazy {
|
private val receiverIntent by lazy {
|
||||||
Intent(context, BleScanResultReceiver::class.java).apply {
|
Intent(context, BleScanResultReceiver::class.java).apply {
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
data class BluetoothDevice2(
|
data class BluetoothDevice2(
|
||||||
internal val internal: BluetoothDevice,
|
val address: BluetoothAddress,
|
||||||
|
val name: String?,
|
||||||
val seenFirstAt: Instant,
|
val seenFirstAt: Instant,
|
||||||
) {
|
internal val internal: BluetoothDevice? = null,
|
||||||
val address: BluetoothAddress
|
)
|
||||||
get() = internal.address
|
|
||||||
|
|
||||||
val name: String?
|
|
||||||
get() = internal.name
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.HandlerThread
|
import android.os.HandlerThread
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@@ -23,6 +24,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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
|
import eu.darken.capod.common.permissions.Permission
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
@@ -255,6 +257,8 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
}
|
}
|
||||||
.map { device ->
|
.map { device ->
|
||||||
BluetoothDevice2(
|
BluetoothDevice2(
|
||||||
|
address = device.address,
|
||||||
|
name = device.name,
|
||||||
internal = device,
|
internal = device,
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
seenFirstAt = seenDevicesLock.withLock {
|
||||||
seenDevicesCache[device.address] ?: run {
|
seenDevicesCache[device.address] ?: run {
|
||||||
@@ -304,6 +308,8 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
val wrappedDevices = rawDevices.map { device ->
|
val wrappedDevices = rawDevices.map { device ->
|
||||||
|
|
||||||
BluetoothDevice2(
|
BluetoothDevice2(
|
||||||
|
address = device.address,
|
||||||
|
name = device.name,
|
||||||
internal = device,
|
internal = device,
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
seenFirstAt = seenDevicesLock.withLock {
|
||||||
seenDevicesCache[device.address] ?: run {
|
seenDevicesCache[device.address] ?: run {
|
||||||
@@ -318,8 +324,73 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
emit(wrappedDevices)
|
emit(wrappedDevices)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _isNudgeAvailable: Boolean = true
|
@android.annotation.SuppressLint("MissingPermission")
|
||||||
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
|
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
|
||||||
@@ -340,9 +411,10 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
@android.annotation.SuppressLint("MissingPermission")
|
@android.annotation.SuppressLint("MissingPermission")
|
||||||
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
|
val target = device.internal ?: return false
|
||||||
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
||||||
.apply { isAccessible = true }
|
.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" }
|
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
||||||
result
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -352,28 +424,42 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
suspend fun nudgeConnection(device: BluetoothDevice2): NudgeAttemptResult =
|
||||||
try {
|
getBluetoothProfile().map { bluetoothProfile ->
|
||||||
log(TAG) { "Nudging Android connection to $device" }
|
try {
|
||||||
|
log(TAG) { "Nudging Android connection to $device" }
|
||||||
|
|
||||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
val target = device.internal ?: return@map NudgeAttemptResult.Rejected
|
||||||
"connect", BluetoothDevice::class.java
|
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||||
).apply { isAccessible = true }
|
"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" }
|
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||||
accepted
|
if (accepted) NudgeAttemptResult.Accepted else NudgeAttemptResult.Rejected
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val isSecurityException = e is SecurityException ||
|
val isSecurityException = e is SecurityException ||
|
||||||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
||||||
if (isSecurityException) {
|
if (!isSecurityException) {
|
||||||
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
|
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||||
_isNudgeAvailable = false
|
return@map NudgeAttemptResult.Rejected
|
||||||
|
}
|
||||||
|
// BLUETOOTH_CONNECT is only a runtime permission on Android 12+. On older
|
||||||
|
// versions a SecurityException can't be a missing-permission failure for that
|
||||||
|
// permission — it's hidden-API enforcement (e.g. MODIFY_PHONE_STATE) and we
|
||||||
|
// should persist BROKEN.
|
||||||
|
val missingBtConnect = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||||
|
!Permission.BLUETOOTH_CONNECT.isGranted(context)
|
||||||
|
if (missingBtConnect) {
|
||||||
|
log(TAG, WARN) { "nudgeConnection failed because BLUETOOTH_CONNECT is not granted" }
|
||||||
|
NudgeAttemptResult.UnavailableMissingPermission
|
||||||
|
} else {
|
||||||
|
log(TAG, ERROR) { "nudgeConnection is permanently unavailable on this device: $e" }
|
||||||
|
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||||
|
NudgeAttemptResult.UnavailableHiddenApi
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
}.first()
|
||||||
false
|
|
||||||
}
|
|
||||||
}.first()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Bluetooth", "Manager2")
|
private val TAG = logTag("Bluetooth", "Manager2")
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
package eu.darken.capod.common.bluetooth
|
|
||||||
|
|
||||||
import dagger.Reusable
|
|
||||||
import eu.darken.capod.common.TimeSource
|
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.common.fromHex
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.random.Random
|
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
|
|
||||||
@Reusable
|
|
||||||
class FakeBleData @Inject constructor(
|
|
||||||
private val debugSettings: DebugSettings,
|
|
||||||
private val timeSource: TimeSource,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
|
|
||||||
if (!debugSettings.showFakeData.valueBlocking) return originals
|
|
||||||
return originals + getFakeData()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFakeData(): Collection<BleScanResult> {
|
|
||||||
val fakeDevices = mutableListOf<BleScanResult>()
|
|
||||||
val now = timeSource.now()
|
|
||||||
val generatedAt = timeSource.elapsedRealtimeNanos()
|
|
||||||
// AirPods Gen1
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "78:73:AF:B4:85:22",
|
|
||||||
rssi = Random.nextInt(100) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 100,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AirPods Gen2
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "78:73:FF:B4:85:5E",
|
|
||||||
rssi = Random.nextInt(100) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 100,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AirPods Gen3
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "4E:9E:D1:49:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 200,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
// AirPods Max
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "7E:E5:C7:65:D2:B5",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 300,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
// BeatsFlex
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "5E:9E:D1:49:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 400,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tws i99999
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "5E:9E:D1:29:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 400,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown Device
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = now,
|
|
||||||
address = "6E:9E:D1:49:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = generatedAt + 500,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fakeDevices
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package eu.darken.capod.common.bluetooth
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class NudgeAvailability {
|
||||||
|
@SerialName("nudge.unknown") UNKNOWN,
|
||||||
|
@SerialName("nudge.available") AVAILABLE,
|
||||||
|
@SerialName("nudge.broken") BROKEN,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class NudgeAttemptResult {
|
||||||
|
Accepted,
|
||||||
|
Rejected,
|
||||||
|
UnavailableHiddenApi,
|
||||||
|
UnavailableMissingPermission,
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package eu.darken.capod.common.bluetooth
|
||||||
|
|
||||||
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
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.logging.Logging.Priority.INFO
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NudgeCapabilityStore @Inject constructor(
|
||||||
|
generalSettings: GeneralSettings,
|
||||||
|
@AppScope private val appScope: CoroutineScope,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val persistedValue = generalSettings.nudgeAvailability
|
||||||
|
|
||||||
|
// Resolve the persisted value synchronously at construction so consumers that read
|
||||||
|
// `availability.value` (resolver, AutoConnect precondition, UI state) never see the
|
||||||
|
// synthetic UNKNOWN seed before the first DataStore emission arrives. On a known-broken
|
||||||
|
// device this prevents one bonus ALWAYS-mode + nudge-attempt cycle per cold start.
|
||||||
|
val availability: StateFlow<NudgeAvailability> = persistedValue.flow
|
||||||
|
.stateIn(
|
||||||
|
scope = appScope + dispatcherProvider.IO,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = persistedValue.valueBlocking,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun record(result: NudgeAttemptResult) {
|
||||||
|
val verdict = verdictFor(result) ?: return
|
||||||
|
if (availability.value == verdict) return
|
||||||
|
log(TAG, INFO) { "Recording nudge verdict: $verdict (from $result)" }
|
||||||
|
appScope.launch(dispatcherProvider.IO) {
|
||||||
|
persistedValue.value(verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Bluetooth", "NudgeCapabilityStore")
|
||||||
|
|
||||||
|
internal fun verdictFor(result: NudgeAttemptResult): NudgeAvailability? = when (result) {
|
||||||
|
NudgeAttemptResult.Accepted -> NudgeAvailability.AVAILABLE
|
||||||
|
NudgeAttemptResult.UnavailableHiddenApi -> NudgeAvailability.BROKEN
|
||||||
|
NudgeAttemptResult.Rejected,
|
||||||
|
NudgeAttemptResult.UnavailableMissingPermission -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,11 @@
|
|||||||
package eu.darken.capod.common.bluetooth
|
package eu.darken.capod.common.bluetooth
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import eu.darken.capod.R
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class ScannerMode(
|
enum class ScannerMode {
|
||||||
val identifier: String,
|
@SerialName("scanner.mode.lowpower") LOW_POWER,
|
||||||
@StringRes val labelRes: Int
|
@SerialName("scanner.mode.balanced") BALANCED,
|
||||||
) {
|
@SerialName("scanner.mode.lowlatency") LOW_LATENCY,
|
||||||
@SerialName("scanner.mode.lowpower") LOW_POWER(
|
|
||||||
"scanner.mode.lowpower",
|
|
||||||
R.string.settings_scanner_mode_lowpower_label
|
|
||||||
),
|
|
||||||
@SerialName("scanner.mode.balanced") BALANCED(
|
|
||||||
"scanner.mode.balanced",
|
|
||||||
R.string.settings_scanner_mode_balanced_label
|
|
||||||
),
|
|
||||||
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
|
|
||||||
"scanner.mode.lowlatency",
|
|
||||||
R.string.settings_scanner_mode_lowlatency_label
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
@@ -180,18 +180,21 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-mixed",
|
profileId = "preview-dual-mixed",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProMixed(),
|
ble = airPodsProMixed(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-keys",
|
profileId = "preview-dual-keys",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProWithKeys(),
|
ble = airPodsProWithKeys(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-aap",
|
profileId = "preview-dual-aap",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProWithKeys(),
|
ble = airPodsProWithKeys(),
|
||||||
aap = AapPodState(
|
aap = AapPodState(
|
||||||
connectionState = AapPodState.ConnectionState.READY,
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
@@ -209,6 +212,37 @@ object MockPodDataProvider {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual pods with a fully-populated AAP session: ANC on, both pods in-ear, left pod as the
|
||||||
|
* primary (microphone) pod. Used to preview the card with all status flags AND a battery estimate.
|
||||||
|
*/
|
||||||
|
fun dualPodFullyLoaded(): PodDevice = PodDevice(
|
||||||
|
profileId = "preview-dual-full",
|
||||||
|
label = "My AirPods Pro",
|
||||||
|
ble = airPodsProWithKeys(),
|
||||||
|
aap = AapPodState(
|
||||||
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
|
settings = mapOf(
|
||||||
|
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||||
|
current = AapSetting.AncMode.Value.ON,
|
||||||
|
supported = listOf(
|
||||||
|
AapSetting.AncMode.Value.OFF,
|
||||||
|
AapSetting.AncMode.Value.ON,
|
||||||
|
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||||
|
AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||||
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
|
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
|
),
|
||||||
|
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(
|
||||||
|
pod = AapSetting.PrimaryPod.Pod.LEFT,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
/** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
|
/** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
|
||||||
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-reactions",
|
profileId = "preview-dual-reactions",
|
||||||
@@ -227,12 +261,14 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||||
profileId = "preview-single",
|
profileId = "preview-single",
|
||||||
|
label = "AirPods Max",
|
||||||
ble = airPodsMax(),
|
ble = airPodsMax(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
||||||
profileId = "preview-single-aap",
|
profileId = "preview-single-aap",
|
||||||
|
label = "AirPods Max",
|
||||||
ble = airPodsMaxCharging(),
|
ble = airPodsMaxCharging(),
|
||||||
aap = AapPodState(
|
aap = AapPodState(
|
||||||
connectionState = AapPodState.ConnectionState.READY,
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
@@ -255,11 +291,42 @@ object MockPodDataProvider {
|
|||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Dual pods matched to a profile that has no paired Bluetooth device selected. */
|
||||||
|
fun dualPodMissingPairedDevice(): PodDevice = PodDevice(
|
||||||
|
profileId = "preview-dual-missing-paired",
|
||||||
|
ble = MockDualBlePodSnapshot(
|
||||||
|
_model = PodModel.AIRPODS_PRO2,
|
||||||
|
_label = "My AirPods Pro",
|
||||||
|
batteryLeftPodPercent = 0.80f,
|
||||||
|
batteryRightPodPercent = 0.45f,
|
||||||
|
_batteryCasePercent = 0.60f,
|
||||||
|
_isLeftPodCharging = true,
|
||||||
|
leftPodIcon = R.drawable.device_airpods_pro2_left,
|
||||||
|
rightPodIcon = R.drawable.device_airpods_pro2_right,
|
||||||
|
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||||
|
),
|
||||||
|
aap = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Single pod matched to a profile that has no paired Bluetooth device selected. */
|
||||||
|
fun singlePodMissingPairedDevice(): PodDevice = PodDevice(
|
||||||
|
profileId = "preview-single-missing-paired",
|
||||||
|
ble = MockSingleBlePodSnapshot(
|
||||||
|
_model = PodModel.AIRPODS_MAX,
|
||||||
|
_label = "AirPods Max",
|
||||||
|
batteryHeadsetPercent = 0.85f,
|
||||||
|
_isBeingWorn = true,
|
||||||
|
),
|
||||||
|
aap = null,
|
||||||
|
)
|
||||||
|
|
||||||
/** Cached-only dual pod — device fully offline, showing last known state. */
|
/** Cached-only dual pod — device fully offline, showing last known state. */
|
||||||
fun dualPodCachedOnly(): PodDevice = PodDevice(
|
fun dualPodCachedOnly(): PodDevice = PodDevice(
|
||||||
profileId = "preview-cached",
|
profileId = "preview-cached",
|
||||||
ble = null,
|
ble = null,
|
||||||
aap = null,
|
aap = null,
|
||||||
|
profileAddress = "AA:BB:CC:DD:EE:FF",
|
||||||
|
profileModel = PodModel.AIRPODS_PRO2,
|
||||||
cached = CachedDeviceState(
|
cached = CachedDeviceState(
|
||||||
profileId = "preview-cached",
|
profileId = "preview-cached",
|
||||||
model = PodModel.AIRPODS_PRO2,
|
model = PodModel.AIRPODS_PRO2,
|
||||||
@@ -423,7 +490,6 @@ private class MockSingleBlePodSnapshot(
|
|||||||
override val isBeingWorn: Boolean = _isBeingWorn
|
override val isBeingWorn: Boolean = _isBeingWorn
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("PropertyName")
|
|
||||||
private class MockSingleAppleBlePodSnapshot(
|
private class MockSingleAppleBlePodSnapshot(
|
||||||
private val _model: PodModel,
|
private val _model: PodModel,
|
||||||
private val _label: String,
|
private val _label: String,
|
||||||
@@ -462,7 +528,6 @@ private class MockSingleAppleBlePodSnapshot(
|
|||||||
override val isBeingWorn: Boolean = _isBeingWorn
|
override val isBeingWorn: Boolean = _isBeingWorn
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("PropertyName")
|
|
||||||
private class MockDualAppleBlePodSnapshot(
|
private class MockDualAppleBlePodSnapshot(
|
||||||
private val _model: PodModel,
|
private val _model: PodModel,
|
||||||
private val _label: String,
|
private val _label: String,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -23,9 +24,10 @@ class DataStoreValue<T>(
|
|||||||
) {
|
) {
|
||||||
val keyName: String get() = key.name
|
val keyName: String get() = key.name
|
||||||
|
|
||||||
val flow: Flow<T> = dataStore.data.map { prefs ->
|
val flow: Flow<T> = dataStore.data
|
||||||
reader(prefs[key])
|
.map { prefs -> prefs[key] }
|
||||||
}
|
.distinctUntilChanged()
|
||||||
|
.map { raw -> reader(raw) }
|
||||||
|
|
||||||
data class Updated<T>(val old: T, val new: T)
|
data class Updated<T>(val old: T, val new: T)
|
||||||
|
|
||||||
|
|||||||
@@ -5,22 +5,40 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
|||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
object Bugs {
|
object Bugs {
|
||||||
var reporter: AutomaticBugReporter? = null
|
var reporter: AutomaticBugReporter? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the debug log recorder is actively recording. Surfaces extra
|
||||||
|
* diagnostic UI (e.g. raw BLE payload hex on device cards) so users helping
|
||||||
|
* to add support for new headphones can capture useful data without a
|
||||||
|
* permanent debug toggle.
|
||||||
|
*/
|
||||||
|
val isDebug = MutableStateFlow(false)
|
||||||
|
|
||||||
fun report(
|
fun report(
|
||||||
tag: String,
|
tag: String,
|
||||||
message: String,
|
message: String,
|
||||||
exception: Throwable
|
exception: Throwable
|
||||||
) {
|
) {
|
||||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
|
||||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
|
||||||
|
|
||||||
reporter?.notify(exception) ?: run {
|
val bugReporter = reporter
|
||||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
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")
|
private val TAG = logTag("Bugs")
|
||||||
|
|||||||
@@ -28,10 +28,4 @@ class DebugSettings @Inject constructor(
|
|||||||
// Reporting is opt-out for gplay, and opt-in for github builds
|
// Reporting is opt-out for gplay, and opt-in for github builds
|
||||||
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
|
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
|
||||||
)
|
)
|
||||||
val isDebugModeEnabled = dataStore.createValue("debug.mode.enabled", false)
|
|
||||||
|
|
||||||
val showFakeData = dataStore.createValue("debug.fakedata.enabled", false)
|
|
||||||
|
|
||||||
val showUnfiltered = dataStore.createValue("debug.blescanner.unfiltered.enabled", false)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,16 +61,19 @@ object Logging {
|
|||||||
message: String
|
message: String
|
||||||
) {
|
) {
|
||||||
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||||
snapshot
|
snapshot.forEach {
|
||||||
.filter { it.isLoggable(priority) }
|
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
|
||||||
.forEach {
|
if (isLoggable) {
|
||||||
it.log(
|
runCatching {
|
||||||
priority = priority,
|
it.log(
|
||||||
tag = tag,
|
priority = priority,
|
||||||
metaData = metaData,
|
tag = tag,
|
||||||
message = message
|
metaData = metaData,
|
||||||
)
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearAll() {
|
fun clearAll() {
|
||||||
@@ -110,14 +113,28 @@ inline fun log(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.asLog(): String {
|
fun Throwable.asLog(): String = runCatching {
|
||||||
val stringWriter = StringWriter(256)
|
val stringWriter = StringWriter(256)
|
||||||
val printWriter = PrintWriter(stringWriter, false)
|
val printWriter = PrintWriter(stringWriter, false)
|
||||||
printStackTrace(printWriter)
|
printStackTrace(printWriter)
|
||||||
printWriter.flush()
|
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
|
@PublishedApi
|
||||||
internal fun Any.logTagViaCallSite(): String {
|
internal fun Any.logTagViaCallSite(): String {
|
||||||
val javaClass = this::class.java
|
val javaClass = this::class.java
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import eu.darken.capod.common.SystemTimeSource
|
|||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
|
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.ERROR
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
@@ -126,6 +127,10 @@ class RecorderModule @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(appScope)
|
.launchIn(appScope)
|
||||||
|
|
||||||
|
internalState.flow
|
||||||
|
.onEach { Bugs.isDebug.value = it.isRecording }
|
||||||
|
.launchIn(appScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSessionDir(): File {
|
private fun createSessionDir(): File {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.conflate
|
|||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.scan
|
import kotlinx.coroutines.flow.scan
|
||||||
import kotlinx.coroutines.flow.shareIn
|
import kotlinx.coroutines.flow.shareIn
|
||||||
@@ -54,7 +53,6 @@ fun <T> Flow<T>.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhi
|
|||||||
|
|
||||||
fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
|
fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
|
||||||
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
|
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
|
||||||
.onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } }
|
|
||||||
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
|
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
|
||||||
.catch {
|
.catch {
|
||||||
if (it.hasCause(CancellationException::class)) {
|
if (it.hasCause(CancellationException::class)) {
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ object Nav {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object General : Settings
|
data object General : Settings
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Debug : Settings
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Support : Settings
|
data object Support : Settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package eu.darken.capod.common.permissions
|
package eu.darken.capod.common.permissions
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.PermissionChecker
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.withinApiLevel
|
import eu.darken.capod.common.withinApiLevel
|
||||||
@@ -18,7 +17,7 @@ enum class Permission(
|
|||||||
val permissionId: String,
|
val permissionId: String,
|
||||||
val isScanBlocking: Boolean = false,
|
val isScanBlocking: Boolean = false,
|
||||||
val isGranted: (Context) -> Boolean = {
|
val isGranted: (Context) -> Boolean = {
|
||||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
BLUETOOTH(
|
BLUETOOTH(
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import androidx.datastore.preferences.preferencesDataStore
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||||
import eu.darken.capod.common.datastore.createValue
|
import eu.darken.capod.common.datastore.createValue
|
||||||
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
||||||
import eu.darken.capod.common.serialization.SerializationCapod
|
import eu.darken.capod.common.serialization.SerializationCapod
|
||||||
import eu.darken.capod.common.theming.ThemeColor
|
import eu.darken.capod.common.theming.ThemeColor
|
||||||
import eu.darken.capod.common.theming.ThemeMode
|
import eu.darken.capod.common.theming.ThemeMode
|
||||||
import eu.darken.capod.common.theming.ThemeStyle
|
import eu.darken.capod.common.theming.ThemeStyle
|
||||||
|
import eu.darken.capod.monitor.core.MonitorSessionMark
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
||||||
@@ -37,15 +38,11 @@ class GeneralSettings @Inject constructor(
|
|||||||
|
|
||||||
private val dataStore: DataStore<Preferences> get() = context.dataStore
|
private val dataStore: DataStore<Preferences> get() = context.dataStore
|
||||||
|
|
||||||
val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC, json, onErrorFallbackToDefault = true)
|
|
||||||
|
|
||||||
val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
|
val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
|
||||||
|
|
||||||
val keepConnectedNotificationAfterDisconnect =
|
val keepConnectedNotificationAfterDisconnect =
|
||||||
dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false)
|
dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false)
|
||||||
|
|
||||||
val scannerMode = dataStore.createValue("core.scanner.mode", ScannerMode.BALANCED, json, onErrorFallbackToDefault = true)
|
|
||||||
|
|
||||||
val oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f)
|
val oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f)
|
||||||
|
|
||||||
val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
|
val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
|
||||||
@@ -77,8 +74,37 @@ class GeneralSettings @Inject constructor(
|
|||||||
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
|
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
|
||||||
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false)
|
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false)
|
||||||
|
|
||||||
|
/** Runtime-detected verdict for whether `BluetoothHeadset.connect()` reflection works on this device. */
|
||||||
|
val nudgeAvailability = dataStore.createValue(
|
||||||
|
"core.bluetooth.nudge.availability", NudgeAvailability.UNKNOWN, json,
|
||||||
|
onErrorFallbackToDefault = true,
|
||||||
|
)
|
||||||
|
|
||||||
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity of the currently running monitor session, null while it isn't running.
|
||||||
|
* Set atomically on monitor start, cleared on clean [android.app.Service.onDestroy] — an OS
|
||||||
|
* force-stop skips onDestroy, so a non-null value at the next monitor start is evidence of an
|
||||||
|
* unclean death (see MonitorKillDetector).
|
||||||
|
*/
|
||||||
|
val monitorSessionMark = dataStore.createValue<MonitorSessionMark?>(
|
||||||
|
"core.monitor.health.session", null, json, onErrorFallbackToDefault = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Timestamp of the newest [android.app.ApplicationExitInfo] record already processed. */
|
||||||
|
val exitInfoWatermark = dataStore.createValue("core.monitor.health.exitinfo.watermark", 0L)
|
||||||
|
|
||||||
|
/** Timestamp of the most recent detected OS kill of the monitor (0 = none). */
|
||||||
|
val lastOsKillAt = dataStore.createValue("core.monitor.health.oskill.last", 0L)
|
||||||
|
|
||||||
|
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
||||||
|
|
||||||
|
/** When the "your phone stopped CAPod" hint was dismissed; re-shown only for newer kills. */
|
||||||
|
val osKillHintDismissedAt = dataStore.createValue("ui.hint.oskill.dismissed", 0L)
|
||||||
|
|
||||||
|
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
|
||||||
|
|
||||||
val themeMode = dataStore.createValue(
|
val themeMode = dataStore.createValue(
|
||||||
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
|
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
|
||||||
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
|
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
package eu.darken.capod.main.core
|
package eu.darken.capod.main.core
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import eu.darken.capod.R
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class MonitorMode(
|
enum class MonitorMode(
|
||||||
@StringRes val labelRes: Int
|
val intensity: Int,
|
||||||
) {
|
) {
|
||||||
@SerialName("monitor.mode.manual") MANUAL(
|
@SerialName("monitor.mode.manual") MANUAL(intensity = 0),
|
||||||
R.string.settings_monitor_mode_manual_label
|
@SerialName("monitor.mode.automatic") AUTOMATIC(intensity = 1),
|
||||||
),
|
@SerialName("monitor.mode.always") ALWAYS(intensity = 2),
|
||||||
@SerialName("monitor.mode.automatic") AUTOMATIC(
|
|
||||||
R.string.settings_monitor_mode_automatic_label
|
|
||||||
),
|
|
||||||
@SerialName("monitor.mode.always") ALWAYS(
|
|
||||||
R.string.settings_monitor_mode_always_label
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.log
|
|||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.permissions.Permission
|
import eu.darken.capod.common.permissions.Permission
|
||||||
import eu.darken.capod.common.permissions.isRequired
|
import eu.darken.capod.common.permissions.isRequired
|
||||||
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.profiles.core.toReactionConfig
|
import eu.darken.capod.profiles.core.toReactionConfig
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -21,7 +22,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class PermissionTool @Inject constructor(
|
class PermissionTool @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val generalSettings: GeneralSettings,
|
private val monitorModeResolver: MonitorModeResolver,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
) {
|
) {
|
||||||
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
||||||
@@ -41,13 +42,11 @@ class PermissionTool @Inject constructor(
|
|||||||
|
|
||||||
val missingPermissions: Flow<Set<Permission>> = combine(
|
val missingPermissions: Flow<Set<Permission>> = combine(
|
||||||
permissionCheckTrigger,
|
permissionCheckTrigger,
|
||||||
generalSettings.monitorMode.flow,
|
monitorModeResolver.effectiveMode,
|
||||||
anyPopupEnabled,
|
anyPopupEnabled,
|
||||||
) { _, monitorMode, showPopUp ->
|
) { _, monitorMode, showPopUp ->
|
||||||
Permission.entries
|
Permission.entries
|
||||||
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
|
.filter { isApplicable(it, monitorMode, showPopUp) }
|
||||||
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
|
|
||||||
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
|
|
||||||
.filter { it.isRequired(context) }
|
.filter { it.isRequired(context) }
|
||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
@@ -61,5 +60,26 @@ class PermissionTool @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("PermissionTool")
|
private val TAG = logTag("PermissionTool")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [permission] is applicable for the user's current configuration.
|
||||||
|
* Three permissions are conditional on monitor mode or popup usage:
|
||||||
|
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] whenever the monitor service is expected to
|
||||||
|
* run unattended (AUTOMATIC and ALWAYS) — those users depend on a long-lived foreground
|
||||||
|
* service that aggressive vendor battery management may otherwise kill
|
||||||
|
* - [Permission.ACCESS_BACKGROUND_LOCATION] only when always-on scanning is needed
|
||||||
|
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
|
||||||
|
* Everything else is unconditionally applicable.
|
||||||
|
*/
|
||||||
|
internal fun isApplicable(
|
||||||
|
permission: Permission,
|
||||||
|
monitorMode: MonitorMode,
|
||||||
|
anyPopupEnabled: Boolean,
|
||||||
|
): Boolean = when (permission) {
|
||||||
|
Permission.IGNORE_BATTERY_OPTIMIZATION -> monitorMode != MonitorMode.MANUAL
|
||||||
|
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
|
||||||
|
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.common.flow.SingleEventFlow
|
||||||
import eu.darken.capod.common.navigation.LocalNavigationController
|
import eu.darken.capod.common.navigation.LocalNavigationController
|
||||||
import eu.darken.capod.common.navigation.Nav
|
import eu.darken.capod.common.navigation.Nav
|
||||||
import eu.darken.capod.common.navigation.NavigationController
|
import eu.darken.capod.common.navigation.NavigationController
|
||||||
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
|
|||||||
@Inject lateinit var popUpWindow: PopUpWindow
|
@Inject lateinit var popUpWindow: PopUpWindow
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
|
|||||||
navCtrl.setup(backStack)
|
navCtrl.setup(backStack)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
// Cold-start intent (the activity's launching intent).
|
||||||
consumeUpgradeExtra(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) {
|
CapodTheme(state = themeState) {
|
||||||
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(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?) {
|
private fun consumeUpgradeExtra(intent: Intent?) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package eu.darken.capod.main.ui.components
|
package eu.darken.capod.main.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.twotone.AutoAwesome
|
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
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+184
-117
@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -41,14 +42,18 @@ import eu.darken.capod.common.navigation.NavigationEventHandler
|
|||||||
import eu.darken.capod.common.settings.SettingsInfoBox
|
import eu.darken.capod.common.settings.SettingsInfoBox
|
||||||
import eu.darken.capod.common.settings.SettingsSection
|
import eu.darken.capod.common.settings.SettingsSection
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceDetailItem
|
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
|
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard
|
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard
|
||||||
|
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.SoundCard
|
import eu.darken.capod.main.ui.devicesettings.cards.SoundCard
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.cards.buildDeviceInfoDetailItems
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
|
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.cards.rememberDeviceInfoDetailLabels
|
||||||
import eu.darken.capod.main.ui.devicesettings.components.ConnectedDevicesList
|
import eu.darken.capod.main.ui.devicesettings.components.ConnectedDevicesList
|
||||||
import eu.darken.capod.main.ui.devicesettings.components.EqBarsChart
|
import eu.darken.capod.main.ui.devicesettings.components.EqBarsChart
|
||||||
import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog
|
import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog
|
||||||
@@ -60,7 +65,13 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
|||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
|
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
|
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
||||||
|
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeviceSettingsScreenHost(
|
fun DeviceSettingsScreenHost(
|
||||||
@@ -78,6 +89,19 @@ fun DeviceSettingsScreenHost(
|
|||||||
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
|
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||||
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
||||||
|
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message)
|
||||||
|
val pendingInfoMessage = stringResource(R.string.device_settings_pending_info)
|
||||||
|
|
||||||
|
val hasPendingSettings = state?.device?.hasPendingSettings
|
||||||
|
var lastPendingState by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
LaunchedEffect(hasPendingSettings) {
|
||||||
|
val current = hasPendingSettings ?: return@LaunchedEffect
|
||||||
|
val previous = lastPendingState
|
||||||
|
lastPendingState = current
|
||||||
|
if (previous == false && current) {
|
||||||
|
snackbarHostState.showSnackbar(pendingInfoMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
vm.events.collect { event ->
|
vm.events.collect { event ->
|
||||||
@@ -99,6 +123,10 @@ fun DeviceSettingsScreenHost(
|
|||||||
DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> {
|
DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> {
|
||||||
snackbarHostState.showSnackbar(offRejectedMessage)
|
snackbarHostState.showSnackbar(offRejectedMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> {
|
||||||
|
snackbarHostState.showSnackbar(chargeCapRejectedMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +151,8 @@ fun DeviceSettingsScreenHost(
|
|||||||
onNavigateUp = { vm.navUp() },
|
onNavigateUp = { vm.navUp() },
|
||||||
onAncModeChange = { vm.setAncMode(it) },
|
onAncModeChange = { vm.setAncMode(it) },
|
||||||
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
|
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
|
||||||
|
onConversationActionChange = { vm.setConversationAction(it) },
|
||||||
|
onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) },
|
||||||
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
|
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
|
||||||
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
|
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
|
||||||
onToneVolumeChange = { vm.setToneVolume(it) },
|
onToneVolumeChange = { vm.setToneVolume(it) },
|
||||||
@@ -133,19 +163,27 @@ fun DeviceSettingsScreenHost(
|
|||||||
onListeningModeCycleChange = { vm.setListeningModeCycle(it) },
|
onListeningModeCycleChange = { vm.setListeningModeCycle(it) },
|
||||||
onAllowOffOptionChange = { vm.setAllowOffOption(it) },
|
onAllowOffOptionChange = { vm.setAllowOffOption(it) },
|
||||||
onSleepDetectionChange = { vm.setSleepDetection(it) },
|
onSleepDetectionChange = { vm.setSleepDetection(it) },
|
||||||
|
onDynamicEndOfChargeChange = { vm.setDynamicEndOfCharge(it) },
|
||||||
onDeviceNameChange = { vm.setDeviceName(it) },
|
onDeviceNameChange = { vm.setDeviceName(it) },
|
||||||
onPressControlsClick = { vm.navToPressControls() },
|
onPressControlsClick = { vm.navToPressControls() },
|
||||||
|
onEditProfile = { vm.navToEditProfile() },
|
||||||
onForceConnect = { vm.forceConnect() },
|
onForceConnect = { vm.forceConnect() },
|
||||||
onUpgrade = { vm.launchUpgrade() },
|
onUpgrade = { vm.launchUpgrade() },
|
||||||
onOnePodModeChange = { vm.setOnePodMode(it) },
|
onOnePodModeChange = { vm.setOnePodMode(it) },
|
||||||
onAutoPlayChange = { vm.setAutoPlay(it) },
|
onAutoPlayChange = { vm.setAutoPlay(it) },
|
||||||
onAutoPauseChange = { vm.setAutoPause(it) },
|
onAutoPauseChange = { vm.setAutoPause(it) },
|
||||||
|
onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) },
|
||||||
onAutoConnectChange = { vm.setAutoConnect(it) },
|
onAutoConnectChange = { vm.setAutoConnect(it) },
|
||||||
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
|
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
|
||||||
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
|
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
|
||||||
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
|
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
|
||||||
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
|
onNotifyWhenChargedChange = { vm.setNotifyWhenCharged(it) },
|
||||||
|
onChargedThresholdChange = { vm.setChargedThreshold(it) },
|
||||||
|
onChargedSlotScopeChange = { vm.setChargedSlotScope(it) },
|
||||||
onOpenIssueTracker = { vm.openIssueTracker() },
|
onOpenIssueTracker = { vm.openIssueTracker() },
|
||||||
|
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
||||||
|
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
|
||||||
|
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +196,8 @@ fun DeviceSettingsScreen(
|
|||||||
onNavigateUp: () -> Unit,
|
onNavigateUp: () -> Unit,
|
||||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
|
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
|
||||||
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
||||||
|
onConversationActionChange: (ConversationAction) -> Unit = {},
|
||||||
|
onConversationVolumeReductionChange: (Int) -> Unit = {},
|
||||||
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
|
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
|
||||||
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
|
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
|
||||||
onToneVolumeChange: (Int) -> Unit = {},
|
onToneVolumeChange: (Int) -> Unit = {},
|
||||||
@@ -168,19 +208,27 @@ fun DeviceSettingsScreen(
|
|||||||
onListeningModeCycleChange: (Int) -> Unit = {},
|
onListeningModeCycleChange: (Int) -> Unit = {},
|
||||||
onAllowOffOptionChange: (Boolean) -> Unit = {},
|
onAllowOffOptionChange: (Boolean) -> Unit = {},
|
||||||
onSleepDetectionChange: (Boolean) -> Unit = {},
|
onSleepDetectionChange: (Boolean) -> Unit = {},
|
||||||
|
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
|
||||||
onDeviceNameChange: (String) -> Unit = {},
|
onDeviceNameChange: (String) -> Unit = {},
|
||||||
onPressControlsClick: () -> Unit = {},
|
onPressControlsClick: () -> Unit = {},
|
||||||
|
onEditProfile: () -> Unit = {},
|
||||||
onForceConnect: () -> Unit = {},
|
onForceConnect: () -> Unit = {},
|
||||||
onUpgrade: () -> Unit = {},
|
onUpgrade: () -> Unit = {},
|
||||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||||
onAutoPlayChange: (Boolean) -> Unit = {},
|
onAutoPlayChange: (Boolean) -> Unit = {},
|
||||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||||
|
onStartMusicOnWearChange: (Boolean) -> Unit = {},
|
||||||
onAutoConnectChange: (Boolean) -> Unit = {},
|
onAutoConnectChange: (Boolean) -> Unit = {},
|
||||||
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
||||||
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
||||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||||
onFixMonitorMode: () -> Unit = {},
|
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
|
||||||
|
onChargedThresholdChange: (Int) -> Unit = {},
|
||||||
|
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
|
||||||
onOpenIssueTracker: () -> Unit = {},
|
onOpenIssueTracker: () -> Unit = {},
|
||||||
|
onOpenAapTracker: () -> Unit = {},
|
||||||
|
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
|
||||||
|
onResetBatteryEstimate: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val device = state.device
|
val device = state.device
|
||||||
val features = device?.model?.features
|
val features = device?.model?.features
|
||||||
@@ -237,86 +285,35 @@ fun DeviceSettingsScreen(
|
|||||||
device.firstSeenFormatted(state.now)
|
device.firstSeenFormatted(state.now)
|
||||||
} else null
|
} else null
|
||||||
val info = device.deviceInfo
|
val info = device.deviceInfo
|
||||||
val detailItems = buildList<DeviceDetailItem> {
|
val locale = LocalConfiguration.current.locales[0]
|
||||||
if (info != null) {
|
val zoneId = ZoneId.systemDefault()
|
||||||
if (info.manufacturer.isNotBlank()) {
|
val dateFormatter = remember(locale, zoneId) {
|
||||||
add(
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
DeviceDetailItem.Single(
|
.withLocale(locale)
|
||||||
stringResource(R.string.device_settings_info_manufacturer_label),
|
.withZone(zoneId)
|
||||||
info.manufacturer
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (info.serialNumber.isNotBlank()) {
|
|
||||||
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 detailItems = buildDeviceInfoDetailItems(
|
||||||
|
info = info,
|
||||||
|
labels = rememberDeviceInfoDetailLabels(),
|
||||||
|
formatDate = { instant -> dateFormatter.format(instant) },
|
||||||
|
batteryHealth = when {
|
||||||
|
state.batteryHealth != null -> BatteryHealthTexts(
|
||||||
|
left = state.batteryHealth.left?.let {
|
||||||
|
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||||
|
},
|
||||||
|
right = state.batteryHealth.right?.let {
|
||||||
|
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||||
|
},
|
||||||
|
headset = state.batteryHealth.headset?.let {
|
||||||
|
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
state.batteryHealthPending -> BatteryHealthTexts(
|
||||||
|
pending = stringResource(R.string.device_settings_info_battery_health_pending),
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
)
|
||||||
DeviceInfoCard(
|
DeviceInfoCard(
|
||||||
deviceInfo = device.deviceInfo,
|
deviceInfo = device.deviceInfo,
|
||||||
modelLabel = buildModelLabel(device),
|
modelLabel = buildModelLabel(device),
|
||||||
@@ -331,58 +328,89 @@ fun DeviceSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not connected info — BLE live but no AAP connection
|
// Profile has no paired Bluetooth device — supersedes all other state cards
|
||||||
if (device != null && device.ble != null && !device.isAapConnected && device.address != null) {
|
if (device != null && !device.hasSelectedPairedDevice) {
|
||||||
if (state.isClassicallyConnected) {
|
item("missing_paired_device") {
|
||||||
// Device is connected for audio but AAP isn't available — show passive info
|
MissingPairedDeviceBanner(
|
||||||
item("aap_unavailable_info") {
|
onClick = onEditProfile,
|
||||||
AapUnavailableCard()
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
}
|
)
|
||||||
} else {
|
|
||||||
// Device is nearby but not connected — prompt user to connect
|
|
||||||
item("not_connected_info") {
|
|
||||||
NotConnectedCard(
|
|
||||||
isNudgeAvailable = state.isNudgeAvailable,
|
|
||||||
isForceConnecting = state.isForceConnecting,
|
|
||||||
onConnect = onForceConnect,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reactions (per-profile, not gated on AAP) ─────────────────
|
// Not nearby — no live BLE; settings require the device to be present
|
||||||
if (device != null && features != null) {
|
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") {
|
item("reactions_section") {
|
||||||
ReactionsCard(
|
ReactionsCard(
|
||||||
device = device,
|
device = device,
|
||||||
features = features,
|
features = features,
|
||||||
isPro = isPro,
|
isPro = isPro,
|
||||||
monitorMode = state.monitorMode,
|
|
||||||
onAutoPlayChange = onAutoPlayChange,
|
onAutoPlayChange = onAutoPlayChange,
|
||||||
onAutoPauseChange = onAutoPauseChange,
|
onAutoPauseChange = onAutoPauseChange,
|
||||||
|
onStartMusicOnWearChange = onStartMusicOnWearChange,
|
||||||
onOnePodModeChange = onOnePodModeChange,
|
onOnePodModeChange = onOnePodModeChange,
|
||||||
onConversationalAwarenessChange = onConversationalAwarenessChange,
|
onConversationalAwarenessChange = onConversationalAwarenessChange,
|
||||||
|
onConversationActionChange = onConversationActionChange,
|
||||||
|
onConversationVolumeReductionChange = onConversationVolumeReductionChange,
|
||||||
onSleepDetectionChange = onSleepDetectionChange,
|
onSleepDetectionChange = onSleepDetectionChange,
|
||||||
onAutoConnectChange = onAutoConnectChange,
|
onAutoConnectChange = onAutoConnectChange,
|
||||||
onAutoConnectConditionChange = onAutoConnectConditionChange,
|
onAutoConnectConditionChange = onAutoConnectConditionChange,
|
||||||
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
|
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
|
||||||
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
|
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
|
||||||
onFixMonitorMode = onFixMonitorMode,
|
|
||||||
onOpenIssueTracker = onOpenIssueTracker,
|
onOpenIssueTracker = onOpenIssueTracker,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings — only show when AAP is connected
|
// ── Battery (time-remaining estimate for any live device; charge limit needs AAP) ──
|
||||||
if (features != null && device.isAapConnected) {
|
if (device != null && features != null && device.isLive) {
|
||||||
|
item("battery_section") {
|
||||||
if (device.hasPendingSettings == true) {
|
BatteryCard(
|
||||||
item("pending_info") {
|
device = device,
|
||||||
SettingsInfoBox(
|
features = features,
|
||||||
text = stringResource(R.string.device_settings_pending_info),
|
isPro = isPro,
|
||||||
)
|
chargeCapControlEnabled = enabled,
|
||||||
}
|
estimateEnabled = state.batteryEstimateEnabled,
|
||||||
|
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
|
||||||
|
onNotifyWhenChargedChange = onNotifyWhenChargedChange,
|
||||||
|
onChargedThresholdChange = onChargedThresholdChange,
|
||||||
|
onChargedSlotScopeChange = onChargedSlotScopeChange,
|
||||||
|
onEstimateEnabledChange = onBatteryEstimateEnabledChange,
|
||||||
|
onResetEstimate = onResetBatteryEstimate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings — only show when AAP is connected
|
||||||
|
if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
|
||||||
|
|
||||||
// ── Noise Control ────────────────────────────
|
// ── Noise Control ────────────────────────────
|
||||||
if (features.hasAncControl && device.ancMode != null) {
|
if (features.hasAncControl && device.ancMode != null) {
|
||||||
@@ -412,7 +440,7 @@ fun DeviceSettingsScreen(
|
|||||||
val showSoundSection =
|
val showSoundSection =
|
||||||
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
||||||
(features.hasToneVolume && toneVol != null) ||
|
(features.hasToneVolume && toneVol != null) ||
|
||||||
features.hasMicrophoneMode
|
(features.hasMicrophoneMode && device.microphoneMode != null)
|
||||||
if (showSoundSection) {
|
if (showSoundSection) {
|
||||||
item("sound_section") {
|
item("sound_section") {
|
||||||
SoundCard(
|
SoundCard(
|
||||||
@@ -430,7 +458,7 @@ fun DeviceSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Controls ─────────────────────────────────
|
// ── Controls ─────────────────────────────────
|
||||||
val showControlsSection = features.hasStemConfig ||
|
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
|
||||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
||||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||||
@@ -462,13 +490,22 @@ fun DeviceSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EQ visualization (debug only)
|
// PME Config visualization (debug only, opcode 0x53).
|
||||||
val eqBands = device.eqBands
|
// PME = Personal Medical Equipment (hearing-aid band gains for iOS
|
||||||
if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) {
|
// 18.1+ AirPods Pro 2 hearing-aid mode). The bar chart reuses the
|
||||||
|
// EQ visualization because the data shape (per-ear × per-profile ×
|
||||||
|
// band gains) renders identically; hidden on all-zero payloads, which
|
||||||
|
// just mean the user hasn't configured a hearing-aid profile yet.
|
||||||
|
val pmeConfig = device.pmeConfig
|
||||||
|
if (eu.darken.capod.BuildConfig.DEBUG &&
|
||||||
|
pmeConfig != null &&
|
||||||
|
pmeConfig.sets.isNotEmpty() &&
|
||||||
|
!pmeConfig.isAllZero
|
||||||
|
) {
|
||||||
item("eq_section") {
|
item("eq_section") {
|
||||||
SettingsSection(title = stringResource(R.string.device_settings_eq_label)) {
|
SettingsSection(title = stringResource(R.string.device_settings_eq_label)) {
|
||||||
EqBarsChart(
|
EqBarsChart(
|
||||||
sets = eqBands.sets,
|
sets = pmeConfig.sets,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -476,6 +513,16 @@ fun DeviceSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advanced settings unavailable — phone's Bluetooth lacks AAP support; passive info, shown last
|
||||||
|
if (device != null && device.hasSelectedPairedDevice &&
|
||||||
|
device.ble != null && !device.isAapConnected && device.address != null &&
|
||||||
|
state.isClassicallyConnected
|
||||||
|
) {
|
||||||
|
item("aap_unavailable_info") {
|
||||||
|
AapUnavailableCard(onOpenTracker = onOpenAapTracker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item("bottom_spacer") {
|
item("bottom_spacer") {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
@@ -496,6 +543,12 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
|
|||||||
manufacturer = "Apple Inc.",
|
manufacturer = "Apple Inc.",
|
||||||
serialNumber = "W5J7KV0N04",
|
serialNumber = "W5J7KV0N04",
|
||||||
firmwareVersion = "7A305",
|
firmwareVersion = "7A305",
|
||||||
|
hardwareVersion = "1.0.0",
|
||||||
|
leftEarbudSerial = "H3KL7HR926JY",
|
||||||
|
rightEarbudSerial = "H3KL2AYL26K0",
|
||||||
|
marketingVersion = "8454624",
|
||||||
|
leftEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
|
||||||
|
rightEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
|
||||||
),
|
),
|
||||||
settings = mapOf(
|
settings = mapOf(
|
||||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||||
@@ -520,11 +573,13 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
|
|||||||
muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
|
muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
|
||||||
endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
|
endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
|
||||||
),
|
),
|
||||||
|
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
now = MOCK_NOW,
|
now = MOCK_NOW,
|
||||||
isPro = isPro,
|
isPro = isPro,
|
||||||
|
isClassicallyConnected = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@@ -590,3 +645,15 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
|
|||||||
onNavigateUp = {},
|
onNavigateUp = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun DeviceSettingsMissingPairedDevicePreview() = PreviewWrapper {
|
||||||
|
DeviceSettingsScreen(
|
||||||
|
state = DeviceSettingsViewModel.State(
|
||||||
|
device = MockPodDataProvider.dualPodMissingPairedDevice(),
|
||||||
|
now = MOCK_NOW,
|
||||||
|
),
|
||||||
|
onNavigateUp = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+140
-25
@@ -5,8 +5,10 @@ import eu.darken.capod.common.SystemTimeSource
|
|||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.WebpageTool
|
import eu.darken.capod.common.WebpageTool
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeAttemptResult
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||||
|
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.datastore.value
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
@@ -16,10 +18,14 @@ import eu.darken.capod.common.navigation.Nav
|
|||||||
import eu.darken.capod.common.uix.ViewModel4
|
import eu.darken.capod.common.uix.ViewModel4
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.common.upgrade.isPro
|
import eu.darken.capod.common.upgrade.isPro
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
import eu.darken.capod.main.core.MonitorMode
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryHealth
|
||||||
|
import eu.darken.capod.monitor.core.battery.DrainProfile
|
||||||
import eu.darken.capod.monitor.core.resolvedAncCycleMask
|
import eu.darken.capod.monitor.core.resolvedAncCycleMask
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
@@ -29,6 +35,8 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
|||||||
import eu.darken.capod.profiles.core.ProfileId
|
import eu.darken.capod.profiles.core.ProfileId
|
||||||
import eu.darken.capod.profiles.core.ReactionConfig
|
import eu.darken.capod.profiles.core.ReactionConfig
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
|
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
||||||
|
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||||
import eu.darken.capod.reaction.core.stem.StemAction
|
import eu.darken.capod.reaction.core.stem.StemAction
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -51,7 +59,10 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
private val upgradeRepo: UpgradeRepo,
|
private val upgradeRepo: UpgradeRepo,
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
private val generalSettings: GeneralSettings,
|
private val batteryEstimator: BatteryEstimator,
|
||||||
|
private val drainStore: BatteryDrainStore,
|
||||||
|
private val monitorModeResolver: MonitorModeResolver,
|
||||||
|
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
private val webpageTool: WebpageTool,
|
private val webpageTool: WebpageTool,
|
||||||
) : ViewModel4(dispatcherProvider) {
|
) : ViewModel4(dispatcherProvider) {
|
||||||
@@ -79,6 +90,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
data class SendFailed(val command: AapCommand, val message: String?) : Event
|
data class SendFailed(val command: AapCommand, val message: String?) : Event
|
||||||
data object SystemRenameUnavailable : Event
|
data object SystemRenameUnavailable : Event
|
||||||
data object OffModeRejectedByDevice : Event
|
data object OffModeRejectedByDevice : Event
|
||||||
|
data object DynamicEndOfChargeRejectedByDevice : Event
|
||||||
}
|
}
|
||||||
|
|
||||||
val events = SingleEventFlow<Event>()
|
val events = SingleEventFlow<Event>()
|
||||||
@@ -91,6 +103,16 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
launch {
|
||||||
|
aapManager.settingRejectedEvents.collect { (address, command) ->
|
||||||
|
if (address != currentAddress()) return@collect
|
||||||
|
when (command) {
|
||||||
|
is AapCommand.SetDynamicEndOfCharge ->
|
||||||
|
events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice)
|
||||||
|
else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val state = targetProfileId.flatMapLatest { profileId ->
|
val state = targetProfileId.flatMapLatest { profileId ->
|
||||||
@@ -103,8 +125,10 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
// The Bluetooth HEADSET profile lookup can be slow to produce its first value.
|
// The Bluetooth HEADSET profile lookup can be slow to produce its first value.
|
||||||
// Seed this branch so the screen can render immediately after navigation.
|
// Seed this branch so the screen can render immediately after navigation.
|
||||||
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
|
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
|
||||||
generalSettings.monitorMode.flow,
|
monitorModeResolver.effectiveMode,
|
||||||
profilesRepo.profiles,
|
profilesRepo.profiles,
|
||||||
|
nudgeCapabilityStore.availability,
|
||||||
|
drainStore.profiles,
|
||||||
) { args ->
|
) { args ->
|
||||||
val device = args[1] as PodDevice?
|
val device = args[1] as PodDevice?
|
||||||
val upgrade = args[2] as UpgradeRepo.Info
|
val upgrade = args[2] as UpgradeRepo.Info
|
||||||
@@ -116,9 +140,13 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
|
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
|
||||||
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
|
val nudgeAvailability = args[7] as NudgeAvailability
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val drainProfiles = args[8] as Map<ProfileId, DrainProfile>
|
||||||
|
val appleProfile = profiles.filterIsInstance<AppleDeviceProfile>()
|
||||||
.firstOrNull { it.id == profileId }
|
.firstOrNull { it.id == profileId }
|
||||||
?.stemActions
|
val stemActions = appleProfile?.stemActions
|
||||||
val connectedAddresses = connectedDevices.map { it.address }.toSet()
|
val connectedAddresses = connectedDevices.map { it.address }.toSet()
|
||||||
val systemBtName = device?.address?.let { addr ->
|
val systemBtName = device?.address?.let { addr ->
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +159,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
device = device,
|
device = device,
|
||||||
now = timeSource.now(),
|
now = timeSource.now(),
|
||||||
isPro = upgrade.isPro,
|
isPro = upgrade.isPro,
|
||||||
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
|
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
|
||||||
isForceConnecting = forcing,
|
isForceConnecting = forcing,
|
||||||
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
|
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
|
||||||
monitorMode = monitorMode,
|
monitorMode = monitorMode,
|
||||||
@@ -140,6 +168,19 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
(it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) ||
|
(it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) ||
|
||||||
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
|
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
|
||||||
} == true,
|
} == true,
|
||||||
|
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
|
||||||
|
// Health rides on the same learned data as the estimate — the per-device toggle
|
||||||
|
// governs both.
|
||||||
|
batteryHealth = device
|
||||||
|
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
|
||||||
|
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
|
||||||
|
// A model with a rating WILL eventually produce a health figure — surface the
|
||||||
|
// feature as "still determining" until the listening sessions accumulate. Without
|
||||||
|
// a paired device the listening gate can never open, so no promise is made.
|
||||||
|
batteryHealthPending = device != null &&
|
||||||
|
(appleProfile?.batteryEstimateEnabled ?: true) &&
|
||||||
|
device.model.batterySpec != null &&
|
||||||
|
device.hasSelectedPairedDevice,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.asLiveState()
|
}.asLiveState()
|
||||||
@@ -165,6 +206,12 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
|
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
|
||||||
val systemBluetoothName: String? = null,
|
val systemBluetoothName: String? = null,
|
||||||
val hasCustomLongPressStemAction: Boolean = false,
|
val hasCustomLongPressStemAction: Boolean = false,
|
||||||
|
val batteryEstimateEnabled: Boolean = true,
|
||||||
|
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
|
||||||
|
val batteryHealth: BatteryHealth.PerPod? = null,
|
||||||
|
/** True when health CAN be derived for this device (rated model, feature on) — shows the
|
||||||
|
* "still determining" placeholder while [batteryHealth] is null. */
|
||||||
|
val batteryHealthPending: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
|
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
|
||||||
}
|
}
|
||||||
@@ -195,18 +242,19 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
events.tryEmit(Event.OpenBluetoothSettings)
|
events.tryEmit(Event.OpenBluetoothSettings)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
if (!bluetoothManager.isNudgeAvailable) {
|
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
|
||||||
events.tryEmit(Event.OpenBluetoothSettings)
|
events.tryEmit(Event.OpenBluetoothSettings)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val accepted = try {
|
val result = try {
|
||||||
bluetoothManager.nudgeConnection(bonded)
|
bluetoothManager.nudgeConnection(bonded)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
|
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
|
||||||
false
|
NudgeAttemptResult.Rejected
|
||||||
}
|
}
|
||||||
log(TAG, INFO) { "nudgeConnection($bonded) accepted=$accepted" }
|
log(TAG, INFO) { "nudgeConnection($bonded) result=$result" }
|
||||||
if (!accepted) {
|
nudgeCapabilityStore.record(result)
|
||||||
|
if (result != NudgeAttemptResult.Accepted) {
|
||||||
events.tryEmit(Event.OpenBluetoothSettings)
|
events.tryEmit(Event.OpenBluetoothSettings)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -283,7 +331,16 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSleepDetection(enabled: Boolean) = send(AapCommand.SetSleepDetection(enabled))
|
fun setSleepDetection(enabled: Boolean) = launch {
|
||||||
|
log(TAG, INFO) { "setSleepDetection($enabled)" }
|
||||||
|
if (enabled && !upgradeRepo.isPro()) {
|
||||||
|
navTo(Nav.Main.Upgrade)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
sendInternal(AapCommand.SetSleepDetection(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled))
|
||||||
|
|
||||||
fun setDeviceName(name: String) = launch {
|
fun setDeviceName(name: String) = launch {
|
||||||
val address = currentAddress() ?: return@launch
|
val address = currentAddress() ?: return@launch
|
||||||
@@ -362,6 +419,15 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
syncEarDetection(autoPause = enabled)
|
syncEarDetection(autoPause = enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setStartMusicOnWear(enabled: Boolean) = launch {
|
||||||
|
log(TAG, INFO) { "setStartMusicOnWear($enabled)" }
|
||||||
|
if (enabled && !upgradeRepo.isPro()) {
|
||||||
|
navTo(Nav.Main.Upgrade)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
updateProfileNow { it.copy(startMusicOnWear = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the device-side Automatic Ear Detection setting in sync with the
|
* Keeps the device-side Automatic Ear Detection setting in sync with the
|
||||||
* auto-play / auto-pause reaction toggles. When either reaction is active
|
* auto-play / auto-pause reaction toggles. When either reaction is active
|
||||||
@@ -385,15 +451,6 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
fun setAutoConnect(enabled: Boolean) = launch {
|
fun setAutoConnect(enabled: Boolean) = launch {
|
||||||
log(TAG, INFO) { "setAutoConnect($enabled)" }
|
log(TAG, INFO) { "setAutoConnect($enabled)" }
|
||||||
updateProfileNow { it.copy(autoConnect = enabled) }
|
updateProfileNow { it.copy(autoConnect = enabled) }
|
||||||
if (enabled) {
|
|
||||||
// Auto-connect requires ALWAYS mode to react to BLE/case/ear events when
|
|
||||||
// nothing is connected. We intentionally do NOT revert on disable — the user
|
|
||||||
// may have set ALWAYS manually, or another profile may still need it.
|
|
||||||
if (generalSettings.monitorMode.value() != MonitorMode.ALWAYS) {
|
|
||||||
log(TAG, INFO) { "Forcing monitorMode to ALWAYS because autoConnect was enabled" }
|
|
||||||
generalSettings.monitorMode.value(MonitorMode.ALWAYS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
|
fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
|
||||||
@@ -411,17 +468,71 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
|
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMonitorModeAutomatic() = launch {
|
fun setNotifyWhenCharged(enabled: Boolean) {
|
||||||
log(TAG, INFO) { "setMonitorModeAutomatic()" }
|
log(TAG, INFO) { "setNotifyWhenCharged($enabled)" }
|
||||||
generalSettings.monitorMode.value(MonitorMode.AUTOMATIC)
|
proGatedReaction(enabled) { it.copy(notifyWhenCharged = enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setChargedSlotScope(scope: ChargedSlotScope) = launch {
|
||||||
|
log(TAG, INFO) { "setChargedSlotScope($scope)" }
|
||||||
|
updateProfileNow { it.copy(chargedSlotScope = scope) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setChargedThreshold(percent: Int) = launch {
|
||||||
|
log(TAG, INFO) { "setChargedThreshold($percent)" }
|
||||||
|
val snapped = (percent.toFloat() / ReactionConfig.CHARGED_THRESHOLD_STEP)
|
||||||
|
.let { Math.round(it) * ReactionConfig.CHARGED_THRESHOLD_STEP }
|
||||||
|
.coerceIn(ReactionConfig.MIN_CHARGED_THRESHOLD, ReactionConfig.MAX_CHARGED_THRESHOLD)
|
||||||
|
updateProfileNow { it.copy(chargedThreshold = snapped) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConversationAction(action: ConversationAction) = launch {
|
||||||
|
log(TAG, INFO) { "setConversationAction($action)" }
|
||||||
|
// The action picker is only shown while Conversation Awareness is already enabled (the pod
|
||||||
|
// emits no speaking frames otherwise), so no need to auto-enable it here.
|
||||||
|
if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) {
|
||||||
|
navTo(Nav.Main.Upgrade)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
updateProfileNow { it.copy(conversationAction = action) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConversationVolumeReduction(percent: Int) = launch {
|
||||||
|
log(TAG, INFO) { "setConversationVolumeReduction($percent)" }
|
||||||
|
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Battery estimate (per-profile) ───────────────────────────────────────
|
||||||
|
|
||||||
|
fun setBatteryEstimateEnabled(enabled: Boolean) = launch {
|
||||||
|
log(TAG, INFO) { "setBatteryEstimateEnabled($enabled)" }
|
||||||
|
updateProfileNow { it.copy(batteryEstimateEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetBatteryEstimate() = launch {
|
||||||
|
val profileId = targetProfileId.value ?: return@launch
|
||||||
|
log(TAG, INFO) { "resetBatteryEstimate($profileId)" }
|
||||||
|
batteryEstimator.reset(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun navToPressControls() = launch {
|
fun navToPressControls() = launch {
|
||||||
log(TAG, INFO) { "navToPressControls()" }
|
log(TAG, INFO) { "navToPressControls()" }
|
||||||
val profileId = targetProfileId.value ?: return@launch
|
val profileId = targetProfileId.value ?: return@launch
|
||||||
|
val device = deviceMonitor.getDeviceForProfile(profileId)
|
||||||
|
if (device?.isAapReady != true) {
|
||||||
|
log(TAG, INFO) { "navToPressControls(): aborted, device not AAP-ready" }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
navTo(Nav.Main.PressControls(profileId = profileId))
|
navTo(Nav.Main.PressControls(profileId = profileId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun navToEditProfile() = launch {
|
||||||
|
log(TAG, INFO) { "navToEditProfile()" }
|
||||||
|
val profileId = targetProfileId.value ?: return@launch
|
||||||
|
navTo(Nav.Main.DeviceProfileCreation(profileId = profileId))
|
||||||
|
}
|
||||||
|
|
||||||
fun launchUpgrade() {
|
fun launchUpgrade() {
|
||||||
log(TAG, INFO) { "launchUpgrade()" }
|
log(TAG, INFO) { "launchUpgrade()" }
|
||||||
navTo(Nav.Main.Upgrade)
|
navTo(Nav.Main.Upgrade)
|
||||||
@@ -431,6 +542,10 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
webpageTool.open("https://github.com/d4rken-org/capod/issues")
|
webpageTool.open("https://github.com/d4rken-org/capod/issues")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openAapCompatibilityTracker() {
|
||||||
|
webpageTool.open("https://github.com/d4rken-org/capod/issues/538")
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("DeviceSettings", "VM")
|
private val TAG = logTag("DeviceSettings", "VM")
|
||||||
private const val OFF_BIT = 0x01
|
private const val OFF_BIT = 0x01
|
||||||
|
|||||||
+12
-1
@@ -9,7 +9,9 @@ import androidx.compose.material3.CardDefaults
|
|||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -18,7 +20,9 @@ import eu.darken.capod.common.compose.Preview2
|
|||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AapUnavailableCard() {
|
internal fun AapUnavailableCard(
|
||||||
|
onOpenTracker: () -> Unit = {},
|
||||||
|
) {
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -36,6 +40,13 @@ internal fun AapUnavailableCard() {
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = onOpenTracker,
|
||||||
|
modifier = Modifier.align(Alignment.End),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.device_settings_aap_unavailable_action))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package eu.darken.capod.main.ui.devicesettings.cards
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||||
|
import androidx.compose.material.icons.twotone.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.twotone.RestartAlt
|
||||||
|
import androidx.compose.material.icons.twotone.Schedule
|
||||||
|
import androidx.compose.material.icons.twotone.Workspaces
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.compose.Preview2
|
||||||
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
import eu.darken.capod.common.settings.SettingsBaseItem
|
||||||
|
import eu.darken.capod.common.settings.SettingsSection
|
||||||
|
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||||
|
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
|
||||||
|
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.profiles.core.ReactionConfig
|
||||||
|
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device's "Battery" settings, grouping everything charge/battery-related. Time remaining &
|
||||||
|
* battery health lead, then a divider, then the charging-side settings:
|
||||||
|
* - The time-remaining/health estimate: a per-device toggle (disabling pauses/hides without
|
||||||
|
* discarding learned data) and a reset that wipes the learned drain, charge and health data so
|
||||||
|
* it starts over from the model's rated battery life.
|
||||||
|
* - Apple's "Optimized Charge Limit" (AAP setting 0x3B) — only for models that advertise
|
||||||
|
* [PodModel.Features.hasDynamicEndOfCharge] over an active AAP session.
|
||||||
|
* - "Notify when charged" (a per-device reaction) — fires purely off observed charging state, so it
|
||||||
|
* works for any live device (BLE or AAP), not just when the phone is the audio source.
|
||||||
|
*
|
||||||
|
* Each row is gated independently; the whole card hides when nothing applies.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun BatteryCard(
|
||||||
|
device: PodDevice,
|
||||||
|
features: PodModel.Features,
|
||||||
|
isPro: Boolean,
|
||||||
|
chargeCapControlEnabled: Boolean,
|
||||||
|
estimateEnabled: Boolean,
|
||||||
|
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
|
||||||
|
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
|
||||||
|
onChargedThresholdChange: (Int) -> Unit = {},
|
||||||
|
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
|
||||||
|
onEstimateEnabledChange: (Boolean) -> Unit = {},
|
||||||
|
onResetEstimate: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val chargeCap = device.dynamicEndOfCharge?.takeIf { features.hasDynamicEndOfCharge }
|
||||||
|
// Per-profile controls (charge notification + estimate) apply to any live, profile-matched device.
|
||||||
|
val showLiveControls = device.isLive && device.profileId != null
|
||||||
|
if (chargeCap == null && !showLiveControls) return
|
||||||
|
|
||||||
|
val reactions = device.reactions
|
||||||
|
var showResetConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showChargedScopeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
|
||||||
|
// Time remaining & battery health lead — the headline feature. Charging-side settings
|
||||||
|
// (Apple's charge limit, the charged notification) follow below the divider.
|
||||||
|
if (showLiveControls) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon = Icons.TwoTone.Schedule,
|
||||||
|
title = stringResource(R.string.device_battery_estimate_toggle_label),
|
||||||
|
subtitle = stringResource(R.string.device_battery_estimate_card_desc),
|
||||||
|
checked = estimateEnabled,
|
||||||
|
onCheckedChange = onEstimateEnabledChange,
|
||||||
|
)
|
||||||
|
SettingsBaseItem(
|
||||||
|
icon = Icons.TwoTone.RestartAlt,
|
||||||
|
title = stringResource(R.string.device_battery_estimate_reset_action),
|
||||||
|
subtitle = stringResource(R.string.device_battery_estimate_reset_desc),
|
||||||
|
onClick = { showResetConfirm = true },
|
||||||
|
)
|
||||||
|
// Same divider style the sibling device-settings cards use (see ReactionsCard).
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (chargeCap != null) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
|
title = stringResource(R.string.device_settings_charge_cap_label),
|
||||||
|
subtitle = stringResource(R.string.device_settings_charge_cap_description),
|
||||||
|
checked = chargeCap.enabled,
|
||||||
|
onCheckedChange = onDynamicEndOfChargeChange,
|
||||||
|
enabled = chargeCapControlEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showLiveControls) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon = Icons.TwoTone.NotificationsActive,
|
||||||
|
title = stringResource(R.string.settings_charged_notification_label),
|
||||||
|
subtitle = stringResource(R.string.settings_charged_notification_description),
|
||||||
|
checked = reactions.notifyWhenCharged,
|
||||||
|
onCheckedChange = onNotifyWhenChargedChange,
|
||||||
|
requiresUpgrade = !isPro,
|
||||||
|
)
|
||||||
|
if (reactions.notifyWhenCharged) {
|
||||||
|
var thresholdValue by remember(reactions.chargedThreshold) {
|
||||||
|
mutableIntStateOf(reactions.chargedThreshold)
|
||||||
|
}
|
||||||
|
SettingsSliderItem(
|
||||||
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
|
title = stringResource(R.string.settings_charged_threshold_label),
|
||||||
|
value = thresholdValue.toFloat(),
|
||||||
|
onValueChange = { thresholdValue = it.toInt() },
|
||||||
|
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
|
||||||
|
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
|
||||||
|
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
|
||||||
|
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
|
||||||
|
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
|
||||||
|
valueLabel = { "${it.toInt()}%" },
|
||||||
|
)
|
||||||
|
if (features.hasCase) {
|
||||||
|
SettingsBaseItem(
|
||||||
|
icon = Icons.TwoTone.Workspaces,
|
||||||
|
title = stringResource(R.string.settings_charged_scope_label),
|
||||||
|
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
|
||||||
|
onClick = { showChargedScopeDialog = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showChargedScopeDialog) {
|
||||||
|
ChargedSlotScopeDialog(
|
||||||
|
current = reactions.chargedSlotScope,
|
||||||
|
onSelect = {
|
||||||
|
onChargedSlotScopeChange(it)
|
||||||
|
showChargedScopeDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showChargedScopeDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showResetConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showResetConfirm = false },
|
||||||
|
title = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_title)) },
|
||||||
|
text = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_message)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showResetConfirm = false
|
||||||
|
onResetEstimate()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.device_battery_estimate_reset_action))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showResetConfirm = false }) {
|
||||||
|
Text(text = stringResource(R.string.general_cancel_action))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun BatteryCardEnabledPreview() = PreviewWrapper {
|
||||||
|
val state = previewFullState(isPro = true)
|
||||||
|
val device = state.device!!
|
||||||
|
BatteryCard(
|
||||||
|
device = device,
|
||||||
|
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
|
||||||
|
isPro = true,
|
||||||
|
chargeCapControlEnabled = true,
|
||||||
|
estimateEnabled = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun BatteryCardDisabledPreview() = PreviewWrapper {
|
||||||
|
val state = previewFullState(isPro = true)
|
||||||
|
val device = state.device!!
|
||||||
|
BatteryCard(
|
||||||
|
device = device,
|
||||||
|
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
|
||||||
|
isPro = false,
|
||||||
|
chargeCapControlEnabled = false,
|
||||||
|
estimateEnabled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,10 +29,10 @@ internal fun ControlsCard(
|
|||||||
val volSwipe = device.volumeSwipe
|
val volSwipe = device.volumeSwipe
|
||||||
val volSwipeLen = device.volumeSwipeLength
|
val volSwipeLen = device.volumeSwipeLength
|
||||||
|
|
||||||
val showPressControlsNav = features.hasStemConfig ||
|
val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
|
||||||
features.hasPressSpeed ||
|
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||||
features.hasPressHoldDuration ||
|
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||||
features.hasEndCallMuteMic
|
(features.hasEndCallMuteMic && device.endCallMuteMic != null)
|
||||||
|
|
||||||
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
||||||
if (showPressControlsNav) {
|
if (showPressControlsNav) {
|
||||||
|
|||||||
+103
-45
@@ -18,6 +18,11 @@ import androidx.compose.ui.unit.dp
|
|||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DeviceInfoBottomSheet(
|
internal fun DeviceInfoBottomSheet(
|
||||||
@@ -28,57 +33,110 @@ internal fun DeviceInfoBottomSheet(
|
|||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
) {
|
) {
|
||||||
Column(
|
DeviceInfoDetailsContent(items)
|
||||||
modifier = Modifier
|
}
|
||||||
.fillMaxWidth()
|
}
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
@Composable
|
||||||
) {
|
private fun DeviceInfoDetailsContent(items: List<DeviceDetailItem>) {
|
||||||
Text(
|
Column(
|
||||||
text = stringResource(R.string.device_settings_info_details_label),
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.titleMedium,
|
.fillMaxWidth()
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
)
|
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
||||||
items.forEach { item ->
|
) {
|
||||||
when (item) {
|
Text(
|
||||||
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
|
text = stringResource(R.string.device_settings_info_details_label),
|
||||||
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
|
style = MaterialTheme.typography.titleMedium,
|
||||||
InfoRow(
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
label = item.start.label,
|
)
|
||||||
value = item.start.value,
|
items.forEach { item ->
|
||||||
modifier = Modifier.weight(1f),
|
when (item) {
|
||||||
)
|
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
|
||||||
InfoRow(
|
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
label = item.end.label,
|
InfoRow(
|
||||||
value = item.end.value,
|
label = item.start.label,
|
||||||
modifier = Modifier.weight(1f),
|
value = item.start.value,
|
||||||
textAlign = TextAlign.End,
|
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
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceInfoBottomSheetPreview() = PreviewWrapper {
|
private fun DeviceInfoBottomSheetPreviewSameDayPairing() = PreviewWrapper {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
val pairedAt = Instant.parse("2023-10-16T12:00:00Z")
|
||||||
Text(
|
val info = AapDeviceInfo(
|
||||||
text = "Device Details",
|
name = "AirPods Pro",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
modelNumber = "A2699",
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
manufacturer = "Apple Inc.",
|
||||||
)
|
serialNumber = "W5J7KV0N04",
|
||||||
InfoRow(label = "Manufacturer", value = "Apple Inc.")
|
firmwareVersion = "81.2675000075000000.6814",
|
||||||
InfoRow(label = "Serial Number", value = "W5J7KV0N04")
|
hardwareVersion = "1.0.0",
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
leftEarbudSerial = "H3KL7HR926JY",
|
||||||
InfoRow(label = "Firmware", value = "7A305", modifier = Modifier.weight(1f))
|
rightEarbudSerial = "H3KL2AYL26K0",
|
||||||
InfoRow(label = "Build", value = "8454624", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
|
marketingVersion = "8454768",
|
||||||
}
|
leftEarbudFirstPaired = pairedAt,
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
rightEarbudFirstPaired = pairedAt,
|
||||||
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)
|
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",
|
firmwareVersion = "7A305",
|
||||||
leftEarbudSerial = "H3KL7HR926JY",
|
leftEarbudSerial = "H3KL7HR926JY",
|
||||||
rightEarbudSerial = "H3KL2AYL26K0",
|
rightEarbudSerial = "H3KL2AYL26K0",
|
||||||
buildNumber = "8454624",
|
marketingVersion = "8454624",
|
||||||
),
|
),
|
||||||
modelLabel = "AirPods Pro 2 (A2699)",
|
modelLabel = "AirPods Pro 2 (A2699)",
|
||||||
systemBluetoothName = "AirPods Pro",
|
systemBluetoothName = "AirPods Pro",
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
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),
|
||||||
|
batteryHealth = stringResource(R.string.device_settings_info_battery_health_label),
|
||||||
|
leftBatteryHealth = stringResource(R.string.device_settings_info_battery_health_left_label),
|
||||||
|
rightBatteryHealth = stringResource(R.string.device_settings_info_battery_health_right_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,
|
||||||
|
val batteryHealth: String,
|
||||||
|
val leftBatteryHealth: String,
|
||||||
|
val rightBatteryHealth: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted.
|
||||||
|
* [pending] is a placeholder shown under the health label while NO value exists yet — the feature
|
||||||
|
* stays discoverable while the estimator is still accumulating listening sessions.
|
||||||
|
*/
|
||||||
|
internal data class BatteryHealthTexts(
|
||||||
|
val left: String? = null,
|
||||||
|
val right: String? = null,
|
||||||
|
val headset: String? = null,
|
||||||
|
val pending: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List<DeviceDetailItem> {
|
||||||
|
if (health == null) return emptyList()
|
||||||
|
return buildList {
|
||||||
|
when {
|
||||||
|
health.left != null && health.right != null -> add(
|
||||||
|
DeviceDetailItem.Paired(
|
||||||
|
start = DeviceDetailItem.Single(labels.leftBatteryHealth, health.left),
|
||||||
|
end = DeviceDetailItem.Single(labels.rightBatteryHealth, health.right),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
health.left != null -> add(DeviceDetailItem.Single(labels.leftBatteryHealth, health.left))
|
||||||
|
health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right))
|
||||||
|
}
|
||||||
|
health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
|
||||||
|
if (isEmpty() && health.pending != null) {
|
||||||
|
add(DeviceDetailItem.Single(labels.batteryHealth, health.pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildDeviceInfoDetailItems(
|
||||||
|
info: AapDeviceInfo?,
|
||||||
|
labels: DeviceInfoDetailLabels,
|
||||||
|
batteryHealth: BatteryHealthTexts? = null,
|
||||||
|
formatDate: (Instant) -> String,
|
||||||
|
): List<DeviceDetailItem> {
|
||||||
|
// Battery health is derived locally, so it's available (and shows the info button) even for
|
||||||
|
// BLE-only devices that never produce an AAP device-info response.
|
||||||
|
if (info == null) return healthItems(batteryHealth, labels)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
addAll(healthItems(batteryHealth, labels))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,7 +133,10 @@ internal fun NoiseControlCard(
|
|||||||
text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
|
text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
|
||||||
type = InfoBoxType.INFO,
|
type = InfoBoxType.INFO,
|
||||||
action = {
|
action = {
|
||||||
TextButton(onClick = onPressControlsClick) {
|
TextButton(
|
||||||
|
onClick = onPressControlsClick,
|
||||||
|
enabled = enabled,
|
||||||
|
) {
|
||||||
Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action))
|
Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import android.os.Build
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||||
|
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
|
||||||
import androidx.compose.material.icons.twotone.BluetoothConnected
|
import androidx.compose.material.icons.twotone.BluetoothConnected
|
||||||
import androidx.compose.material.icons.twotone.Hearing
|
import androidx.compose.material.icons.twotone.Hearing
|
||||||
import androidx.compose.material.icons.twotone.LooksOne
|
import androidx.compose.material.icons.twotone.LooksOne
|
||||||
import androidx.compose.material.icons.twotone.Nightlight
|
import androidx.compose.material.icons.twotone.Nightlight
|
||||||
import androidx.compose.material.icons.twotone.PauseCircle
|
import androidx.compose.material.icons.twotone.PauseCircle
|
||||||
import androidx.compose.material.icons.twotone.PlayCircle
|
import androidx.compose.material.icons.twotone.PlayCircle
|
||||||
|
import androidx.compose.material.icons.twotone.RecordVoiceOver
|
||||||
import androidx.compose.material.icons.twotone.Workspaces
|
import androidx.compose.material.icons.twotone.Workspaces
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -18,6 +20,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -29,39 +32,45 @@ import eu.darken.capod.common.compose.Preview2
|
|||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.settings.InfoBoxType
|
import eu.darken.capod.common.settings.InfoBoxType
|
||||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
import eu.darken.capod.common.settings.SettingsBaseItem
|
||||||
|
import eu.darken.capod.common.settings.SettingsCategoryHeader
|
||||||
import eu.darken.capod.common.settings.SettingsInfoBox
|
import eu.darken.capod.common.settings.SettingsInfoBox
|
||||||
import eu.darken.capod.common.settings.SettingsSection
|
import eu.darken.capod.common.settings.SettingsSection
|
||||||
|
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
|
||||||
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
|
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
|
||||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.profiles.core.ReactionConfig
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
|
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ReactionsCard(
|
internal fun ReactionsCard(
|
||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
features: PodModel.Features,
|
features: PodModel.Features,
|
||||||
isPro: Boolean,
|
isPro: Boolean,
|
||||||
monitorMode: MonitorMode,
|
|
||||||
onAutoPlayChange: (Boolean) -> Unit = {},
|
onAutoPlayChange: (Boolean) -> Unit = {},
|
||||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||||
|
onStartMusicOnWearChange: (Boolean) -> Unit = {},
|
||||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||||
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
||||||
|
onConversationActionChange: (ConversationAction) -> Unit = {},
|
||||||
|
onConversationVolumeReductionChange: (Int) -> Unit = {},
|
||||||
onSleepDetectionChange: (Boolean) -> Unit = {},
|
onSleepDetectionChange: (Boolean) -> Unit = {},
|
||||||
onAutoConnectChange: (Boolean) -> Unit = {},
|
onAutoConnectChange: (Boolean) -> Unit = {},
|
||||||
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
||||||
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
||||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||||
onFixMonitorMode: () -> Unit = {},
|
|
||||||
onOpenIssueTracker: () -> Unit = {},
|
onOpenIssueTracker: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val reactions = device.reactions
|
val reactions = device.reactions
|
||||||
val enabled = device.isAapReady
|
val enabled = device.isAapReady
|
||||||
|
|
||||||
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
|
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
|
||||||
|
var showConversationActionDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
|
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
|
||||||
if (features.hasEarDetection) {
|
if (features.hasEarDetection) {
|
||||||
@@ -73,6 +82,16 @@ internal fun ReactionsCard(
|
|||||||
onCheckedChange = onAutoPlayChange,
|
onCheckedChange = onAutoPlayChange,
|
||||||
requiresUpgrade = !isPro,
|
requiresUpgrade = !isPro,
|
||||||
)
|
)
|
||||||
|
if (reactions.autoPlay) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon = Icons.TwoTone.PlayCircle,
|
||||||
|
title = stringResource(R.string.settings_start_music_on_wear_label),
|
||||||
|
subtitle = stringResource(R.string.settings_start_music_on_wear_description),
|
||||||
|
checked = reactions.startMusicOnWear,
|
||||||
|
onCheckedChange = onStartMusicOnWearChange,
|
||||||
|
requiresUpgrade = !isPro,
|
||||||
|
)
|
||||||
|
}
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon = Icons.TwoTone.PauseCircle,
|
icon = Icons.TwoTone.PauseCircle,
|
||||||
title = stringResource(R.string.settings_autopause_label),
|
title = stringResource(R.string.settings_autopause_label),
|
||||||
@@ -117,20 +136,49 @@ internal fun ReactionsCard(
|
|||||||
}
|
}
|
||||||
if (device.isAapConnected) {
|
if (device.isAapConnected) {
|
||||||
val convAwareness = device.conversationalAwareness
|
val convAwareness = device.conversationalAwareness
|
||||||
val hasAnyAapReaction =
|
val showConversationAwareness = features.hasConversationAwareness && convAwareness != null
|
||||||
(features.hasConversationAwareness && convAwareness != null) ||
|
val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection
|
||||||
features.hasSleepDetection
|
if (showConversationAwareness) {
|
||||||
if (features.hasConversationAwareness && convAwareness != null) {
|
SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label))
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon = Icons.TwoTone.Hearing,
|
icon = Icons.TwoTone.Hearing,
|
||||||
title = stringResource(R.string.conversation_awareness_label),
|
title = stringResource(R.string.conversation_awareness_label),
|
||||||
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
|
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
|
||||||
checked = convAwareness.enabled,
|
checked = convAwareness?.enabled == true,
|
||||||
onCheckedChange = onConversationalAwarenessChange,
|
onCheckedChange = onConversationalAwarenessChange,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
)
|
)
|
||||||
|
// The "when you start speaking" reaction only fires while CA is on (the pod emits no
|
||||||
|
// speaking frames otherwise), so only surface it once CA is actually enabled.
|
||||||
|
if (convAwareness?.enabled == true) {
|
||||||
|
SettingsBaseItem(
|
||||||
|
icon = Icons.TwoTone.RecordVoiceOver,
|
||||||
|
title = stringResource(R.string.settings_conversation_action_label),
|
||||||
|
subtitle = stringResource(reactions.conversationAction.labelRes),
|
||||||
|
onClick = { showConversationActionDialog = true },
|
||||||
|
enabled = enabled,
|
||||||
|
requiresUpgrade = !isPro,
|
||||||
|
)
|
||||||
|
if (reactions.conversationAction == ConversationAction.LOWER_VOLUME) {
|
||||||
|
var sliderValue by remember(reactions.conversationVolumeReduction) {
|
||||||
|
mutableIntStateOf(reactions.conversationVolumeReduction)
|
||||||
|
}
|
||||||
|
SettingsSliderItem(
|
||||||
|
icon = Icons.AutoMirrored.TwoTone.VolumeDown,
|
||||||
|
title = stringResource(R.string.settings_conversation_volume_reduction_label),
|
||||||
|
value = sliderValue.toFloat(),
|
||||||
|
onValueChange = { sliderValue = it.toInt() },
|
||||||
|
onValueChangeFinished = { onConversationVolumeReductionChange(sliderValue) },
|
||||||
|
valueRange = ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION.toFloat()..
|
||||||
|
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION.toFloat(),
|
||||||
|
enabled = enabled,
|
||||||
|
valueLabel = { "${it.toInt()}%" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (features.hasSleepDetection) {
|
if (features.hasSleepDetection) {
|
||||||
|
if (showConversationAwareness) ReactionsDivider()
|
||||||
val sleepDet = device.sleepDetection
|
val sleepDet = device.sleepDetection
|
||||||
?: AapSetting.SleepDetection(enabled = true)
|
?: AapSetting.SleepDetection(enabled = true)
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
@@ -140,6 +188,7 @@ internal fun ReactionsCard(
|
|||||||
checked = sleepDet.enabled,
|
checked = sleepDet.enabled,
|
||||||
onCheckedChange = onSleepDetectionChange,
|
onCheckedChange = onSleepDetectionChange,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
|
requiresUpgrade = !isPro,
|
||||||
)
|
)
|
||||||
if (sleepDet.enabled) {
|
if (sleepDet.enabled) {
|
||||||
SettingsInfoBox(
|
SettingsInfoBox(
|
||||||
@@ -197,23 +246,24 @@ internal fun ReactionsCard(
|
|||||||
requiresUpgrade = !isPro,
|
requiresUpgrade = !isPro,
|
||||||
)
|
)
|
||||||
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
|
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
|
||||||
if (anyPopupEnabled && monitorMode == MonitorMode.MANUAL) {
|
if (anyPopupEnabled) {
|
||||||
SettingsInfoBox(
|
|
||||||
text = stringResource(R.string.settings_popup_warning_manual_mode),
|
|
||||||
type = InfoBoxType.WARNING,
|
|
||||||
action = {
|
|
||||||
TextButton(onClick = onFixMonitorMode) {
|
|
||||||
Text(stringResource(R.string.general_fix_it_action))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else if (anyPopupEnabled) {
|
|
||||||
SettingsInfoBox(
|
SettingsInfoBox(
|
||||||
text = stringResource(R.string.settings_popup_info_not_in_app),
|
text = stringResource(R.string.settings_popup_info_not_in_app),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showConversationActionDialog) {
|
||||||
|
ConversationActionDialog(
|
||||||
|
current = reactions.conversationAction,
|
||||||
|
onSelect = {
|
||||||
|
onConversationActionChange(it)
|
||||||
|
showConversationActionDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showConversationActionDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showAutoConnectConditionDialog) {
|
if (showAutoConnectConditionDialog) {
|
||||||
AutoConnectConditionDialog(
|
AutoConnectConditionDialog(
|
||||||
current = reactions.autoConnectCondition,
|
current = reactions.autoConnectCondition,
|
||||||
@@ -245,7 +295,6 @@ private fun ReactionsCardPreview() = PreviewWrapper {
|
|||||||
device = device,
|
device = device,
|
||||||
features = device.model.features,
|
features = device.model.features,
|
||||||
isPro = state.isPro,
|
isPro = state.isPro,
|
||||||
monitorMode = state.monitorMode,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +307,5 @@ private fun ReactionsCardNonProPreview() = PreviewWrapper {
|
|||||||
device = device,
|
device = device,
|
||||||
features = device.model.features,
|
features = device.model.features,
|
||||||
isPro = state.isPro,
|
isPro = state.isPro,
|
||||||
monitorMode = state.monitorMode,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ internal fun SoundCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (features.hasMicrophoneMode) {
|
if (features.hasMicrophoneMode && device.microphoneMode != null) {
|
||||||
if (isPro) {
|
if (isPro) {
|
||||||
val micMode = device.microphoneMode
|
val micMode = device.microphoneMode
|
||||||
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
|
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
|
||||||
|
|||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package eu.darken.capod.main.ui.devicesettings.dialogs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
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.semantics.Role
|
||||||
|
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.reaction.core.charged.ChargedSlotScope
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ChargedSlotScopeDialog(
|
||||||
|
current: ChargedSlotScope,
|
||||||
|
onSelect: (ChargedSlotScope) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(text = stringResource(R.string.settings_charged_scope_label)) },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.selectableGroup()) {
|
||||||
|
ChargedSlotScope.entries.forEach { scope ->
|
||||||
|
val isSelected = scope == current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { onSelect(scope) },
|
||||||
|
role = Role.RadioButton,
|
||||||
|
)
|
||||||
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(selected = isSelected, onClick = null)
|
||||||
|
Text(
|
||||||
|
text = stringResource(scope.labelRes),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun ChargedSlotScopeDialogPreview() = PreviewWrapper {
|
||||||
|
ChargedSlotScopeDialog(
|
||||||
|
current = ChargedSlotScope.PODS_AND_CASE,
|
||||||
|
onSelect = {},
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package eu.darken.capod.main.ui.devicesettings.dialogs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
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.semantics.Role
|
||||||
|
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.reaction.core.conversation.ConversationAction
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ConversationActionDialog(
|
||||||
|
current: ConversationAction,
|
||||||
|
onSelect: (ConversationAction) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(text = stringResource(R.string.settings_conversation_action_label)) },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.selectableGroup()) {
|
||||||
|
ConversationAction.entries.forEach { action ->
|
||||||
|
val isSelected = action == current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { onSelect(action) },
|
||||||
|
role = Role.RadioButton,
|
||||||
|
)
|
||||||
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(selected = isSelected, onClick = null)
|
||||||
|
Text(
|
||||||
|
text = stringResource(action.labelRes),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(text = stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun ConversationActionDialogPreview() = PreviewWrapper {
|
||||||
|
ConversationActionDialog(
|
||||||
|
current = ConversationAction.LOWER_VOLUME,
|
||||||
|
onSelect = {},
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -56,17 +56,37 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
|
|||||||
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
|
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
|
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
||||||
|
import eu.darken.capod.main.ui.overview.cards.MonitorKilledHintCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||||
|
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||||
|
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
|
internal fun profiledDeviceKey(
|
||||||
|
device: PodDevice,
|
||||||
|
index: Int,
|
||||||
|
duplicateProfileIds: Set<String>,
|
||||||
|
): String {
|
||||||
|
val pid = requireNotNull(device.profileId)
|
||||||
|
return if (pid !in duplicateProfileIds) {
|
||||||
|
"profiled:$pid"
|
||||||
|
} else {
|
||||||
|
"profiled:$pid:${device.identifier ?: "idx:$index"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun unmatchedDeviceKey(device: PodDevice, index: Int): String =
|
||||||
|
"unmatched:${device.identifier ?: "idx:$index"}"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||||
ErrorEventHandler(vm)
|
ErrorEventHandler(vm)
|
||||||
@@ -155,10 +175,17 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
|||||||
},
|
},
|
||||||
onManageDevices = { vm.goToDeviceManager() },
|
onManageDevices = { vm.goToDeviceManager() },
|
||||||
onSettings = { vm.goToSettings() },
|
onSettings = { vm.goToSettings() },
|
||||||
|
onTroubleShooter = { vm.goToTroubleShooter() },
|
||||||
|
onOsKillShowInstructions = { vm.onOsKillShowInstructions() },
|
||||||
|
onOsKillAutostartSettings = { vm.onOsKillAutostartSettings() },
|
||||||
|
onOsKillDismiss = { vm.dismissOsKillHint() },
|
||||||
onUpgrade = { vm.onUpgrade() },
|
onUpgrade = { vm.onUpgrade() },
|
||||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||||
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
|
onDeviceSettings = { device ->
|
||||||
|
if (currentState.showReactionsHint) vm.dismissReactionsHint()
|
||||||
|
vm.goToDeviceSettings(device)
|
||||||
|
},
|
||||||
onEditProfile = { device -> vm.goToEditProfile(device) },
|
onEditProfile = { device -> vm.goToEditProfile(device) },
|
||||||
onToggleDeviceExpansion = { device ->
|
onToggleDeviceExpansion = { device ->
|
||||||
device.profileId?.let { vm.toggleDeviceExpansion(it) }
|
device.profileId?.let { vm.toggleDeviceExpansion(it) }
|
||||||
@@ -174,6 +201,10 @@ fun OverviewScreen(
|
|||||||
onBluetoothSettings: () -> Unit,
|
onBluetoothSettings: () -> Unit,
|
||||||
onManageDevices: () -> Unit,
|
onManageDevices: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
|
onTroubleShooter: () -> Unit = {},
|
||||||
|
onOsKillShowInstructions: () -> Unit = {},
|
||||||
|
onOsKillAutostartSettings: () -> Unit = {},
|
||||||
|
onOsKillDismiss: () -> Unit = {},
|
||||||
onUpgrade: () -> Unit,
|
onUpgrade: () -> Unit,
|
||||||
onToggleUnmatched: () -> Unit,
|
onToggleUnmatched: () -> Unit,
|
||||||
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
||||||
@@ -270,6 +301,18 @@ fun OverviewScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b. OS killed the monitor — historical fact, shown regardless of current BT/scan state
|
||||||
|
if (state.showOsKillHint) {
|
||||||
|
item(key = "os_kill_hint") {
|
||||||
|
MonitorKilledHintCard(
|
||||||
|
showAutostartAction = state.showOsKillAutostartAction,
|
||||||
|
onShowInstructions = onOsKillShowInstructions,
|
||||||
|
onAutostartSettings = onOsKillAutostartSettings,
|
||||||
|
onDismiss = onOsKillDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Bluetooth disabled card
|
// 2. Bluetooth disabled card
|
||||||
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
|
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
|
||||||
item(key = "bluetooth_disabled") {
|
item(key = "bluetooth_disabled") {
|
||||||
@@ -286,18 +329,31 @@ fun OverviewScreen(
|
|||||||
|
|
||||||
// 4. Profiled device cards (limited to 1 for free users)
|
// 4. Profiled device cards (limited to 1 for free users)
|
||||||
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||||
|
if (state.showReactionsHint && state.visibleProfiledDevices.isNotEmpty()) {
|
||||||
|
item(key = "reactions_hint") {
|
||||||
|
ReactionsMovedHintCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val duplicateProfileIds = state.visibleProfiledDevices
|
||||||
|
.mapNotNull { it.profileId }
|
||||||
|
.groupingBy { it }
|
||||||
|
.eachCount()
|
||||||
|
.filterValues { it > 1 }
|
||||||
|
.keys
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = state.visibleProfiledDevices,
|
items = state.visibleProfiledDevices,
|
||||||
key = { _, device -> requireNotNull(device.profileId) },
|
key = { index, device -> profiledDeviceKey(device, index, duplicateProfileIds) },
|
||||||
) { index, device ->
|
) { index, device ->
|
||||||
val isCollapsed = !state.isExpanded(device, index)
|
val isCollapsed = !state.isExpanded(device, index)
|
||||||
val isToggleable = state.isToggleable(device, index)
|
val isToggleable = state.isToggleable(device, index)
|
||||||
PodDeviceCard(
|
PodDeviceCard(
|
||||||
device = device,
|
device = device,
|
||||||
isPro = state.upgradeInfo.isPro,
|
isPro = state.upgradeInfo.isPro,
|
||||||
showDebug = state.isDebugMode,
|
showDebug = state.isDebug,
|
||||||
now = state.now,
|
now = state.now,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
|
batteryEstimate = state.estimateFor(device),
|
||||||
onToggleCollapse = if (isToggleable) {
|
onToggleCollapse = if (isToggleable) {
|
||||||
{ onToggleDeviceExpansion(device) }
|
{ onToggleDeviceExpansion(device) }
|
||||||
} else null,
|
} else null,
|
||||||
@@ -319,32 +375,39 @@ fun OverviewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4c. Troubleshooter suggestion — connected via audio but no live data is reaching us
|
||||||
|
if (state.showTroubleshootSuggestion) {
|
||||||
|
item(key = "troubleshoot_suggestion") {
|
||||||
|
TroubleshootSuggestionCard(onTroubleShooter = onTroubleShooter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Monitoring active card
|
// 5. Monitoring active card
|
||||||
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
|
if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
|
||||||
item(key = "monitoring_active") {
|
item(key = "monitoring_active") {
|
||||||
MonitoringActiveCard()
|
MonitoringActiveCard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Unmatched devices section
|
// 6. Unmatched devices section
|
||||||
if (state.unmatchedDevices.isNotEmpty()) {
|
if (state.shouldShowUnmatchedSection) {
|
||||||
item(key = "unmatched_header") {
|
item(key = "unmatched_header") {
|
||||||
UnmatchedDevicesCard(
|
UnmatchedDevicesCard(
|
||||||
count = state.unmatchedDevices.size,
|
count = state.visibleUnmatchedDevices.size,
|
||||||
isExpanded = state.showUnmatchedDevices,
|
isExpanded = state.showUnmatchedDevices,
|
||||||
onToggle = onToggleUnmatched,
|
onToggle = onToggleUnmatched,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.showUnmatchedDevices) {
|
if (state.showUnmatchedDevices) {
|
||||||
items(
|
itemsIndexed(
|
||||||
items = state.unmatchedDevices,
|
items = state.visibleUnmatchedDevices,
|
||||||
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
|
key = { index, device -> unmatchedDeviceKey(device, index) },
|
||||||
) { device ->
|
) { _, device ->
|
||||||
PodDeviceCard(
|
PodDeviceCard(
|
||||||
device = device,
|
device = device,
|
||||||
isPro = state.upgradeInfo.isPro,
|
isPro = state.upgradeInfo.isPro,
|
||||||
showDebug = state.isDebugMode,
|
showDebug = state.isDebug,
|
||||||
now = state.now,
|
now = state.now,
|
||||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||||
onUpgrade = onUpgrade,
|
onUpgrade = onUpgrade,
|
||||||
@@ -365,6 +428,7 @@ private fun PodDeviceCard(
|
|||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
isCollapsed: Boolean = false,
|
isCollapsed: Boolean = false,
|
||||||
|
batteryEstimate: BatteryEstimate? = null,
|
||||||
onToggleCollapse: (() -> Unit)? = null,
|
onToggleCollapse: (() -> Unit)? = null,
|
||||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
||||||
onUpgrade: () -> Unit,
|
onUpgrade: () -> Unit,
|
||||||
@@ -375,6 +439,7 @@ private fun PodDeviceCard(
|
|||||||
device.hasDualPods -> DualPodsCard(
|
device.hasDualPods -> DualPodsCard(
|
||||||
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
|
batteryEstimate = batteryEstimate,
|
||||||
onToggleCollapse = onToggleCollapse,
|
onToggleCollapse = onToggleCollapse,
|
||||||
onAncModeChange = onAncModeChange,
|
onAncModeChange = onAncModeChange,
|
||||||
onUpgrade = onUpgrade,
|
onUpgrade = onUpgrade,
|
||||||
@@ -384,6 +449,7 @@ private fun PodDeviceCard(
|
|||||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(
|
device.model != PodModel.UNKNOWN -> SinglePodsCard(
|
||||||
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
|
batteryEstimate = batteryEstimate,
|
||||||
onToggleCollapse = onToggleCollapse,
|
onToggleCollapse = onToggleCollapse,
|
||||||
onAncModeChange = onAncModeChange,
|
onAncModeChange = onAncModeChange,
|
||||||
onUpgrade = onUpgrade,
|
onUpgrade = onUpgrade,
|
||||||
@@ -402,11 +468,11 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
|||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = listOf(
|
devices = listOf(
|
||||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
MockPodDataProvider.dualPodFullyLoaded(),
|
||||||
MockPodDataProvider.singlePodMonitored(),
|
MockPodDataProvider.singlePodMonitored(),
|
||||||
MockPodDataProvider.unknownMonitored(),
|
MockPodDataProvider.unknownMonitored(),
|
||||||
),
|
),
|
||||||
isDebugMode = false,
|
isDebug = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(
|
profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
@@ -414,6 +480,17 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
|||||||
),
|
),
|
||||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||||
showUnmatchedDevices = false,
|
showUnmatchedDevices = false,
|
||||||
|
showReactionsHint = true,
|
||||||
|
userExpandedIds = setOf("preview-single"),
|
||||||
|
batteryEstimates = mapOf(
|
||||||
|
"preview-dual-full" to BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
|
||||||
|
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
"preview-single" to BatteryEstimate(
|
||||||
|
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onRequestPermission = {},
|
onRequestPermission = {},
|
||||||
onBluetoothSettings = {},
|
onBluetoothSettings = {},
|
||||||
@@ -432,7 +509,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
|
|||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = emptyList(),
|
devices = emptyList(),
|
||||||
isDebugMode = false,
|
isDebug = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||||
@@ -455,7 +532,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
|
|||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = emptyList(),
|
devices = emptyList(),
|
||||||
isDebugMode = false,
|
isDebug = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = emptyList(),
|
profiles = emptyList(),
|
||||||
upgradeInfo = MockPodDataProvider.gplayInfo(),
|
upgradeInfo = MockPodDataProvider.gplayInfo(),
|
||||||
@@ -478,7 +555,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
|
|||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = emptyList(),
|
devices = emptyList(),
|
||||||
isDebugMode = false,
|
isDebug = false,
|
||||||
isBluetoothEnabled = false,
|
isBluetoothEnabled = false,
|
||||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||||
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
|
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
import eu.darken.capod.common.datastore.value
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
import eu.darken.capod.common.debug.Bugs
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
@@ -21,7 +21,13 @@ import eu.darken.capod.main.core.GeneralSettings
|
|||||||
import eu.darken.capod.main.core.MonitorMode
|
import eu.darken.capod.main.core.MonitorMode
|
||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.MonitorKillGuidance
|
||||||
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||||
|
import eu.darken.capod.monitor.core.tierRank
|
||||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
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.AapConnectionManager
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
@@ -32,12 +38,16 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.combine as combineFlows
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -48,12 +58,14 @@ class OverviewViewModel @Inject constructor(
|
|||||||
private val deviceMonitor: DeviceMonitor,
|
private val deviceMonitor: DeviceMonitor,
|
||||||
private val permissionTool: PermissionTool,
|
private val permissionTool: PermissionTool,
|
||||||
private val generalSettings: GeneralSettings,
|
private val generalSettings: GeneralSettings,
|
||||||
debugSettings: DebugSettings,
|
|
||||||
private val upgradeRepo: UpgradeRepo,
|
private val upgradeRepo: UpgradeRepo,
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
private val aapManager: AapConnectionManager,
|
private val aapManager: AapConnectionManager,
|
||||||
|
private val monitorModeResolver: MonitorModeResolver,
|
||||||
|
private val batteryEstimator: BatteryEstimator,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
|
private val killGuidance: MonitorKillGuidance,
|
||||||
) : ViewModel4(dispatcherProvider) {
|
) : ViewModel4(dispatcherProvider) {
|
||||||
|
|
||||||
val requestPermissionEvent = SingleEventFlow<Permission>()
|
val requestPermissionEvent = SingleEventFlow<Permission>()
|
||||||
@@ -75,29 +87,104 @@ class OverviewViewModel @Inject constructor(
|
|||||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||||
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
|
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
|
||||||
val workerAutolaunch = permissionTool.missingScanPermissions
|
private data class OverviewUiSettings(
|
||||||
.onEach { permissions ->
|
val reactionsHintDismissed: Boolean,
|
||||||
if (permissions.isNotEmpty()) {
|
val hideUnmatchedDevices: Boolean,
|
||||||
log(TAG) { "Missing scan permissions: $permissions" }
|
val showTroubleshootSuggestion: Boolean,
|
||||||
|
val showOsKillHint: Boolean,
|
||||||
|
val batteryEstimates: Map<String, BatteryEstimate>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the OS killed the monitor (see MonitorKillDetector) and the user hasn't dismissed
|
||||||
|
* the hint since — dismissal only hides kills up to that point, a newer kill re-shows it.
|
||||||
|
*/
|
||||||
|
private val osKillHint = combineFlows(
|
||||||
|
generalSettings.lastOsKillAt.flow,
|
||||||
|
generalSettings.osKillHintDismissedAt.flow,
|
||||||
|
) { lastKillAt, dismissedAt ->
|
||||||
|
lastKillAt > 0 && lastKillAt > dismissedAt
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
|
||||||
|
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
|
||||||
|
* phones, see #603). Surfaced as a hint pointing at the Troubleshooter, which can probe and
|
||||||
|
* persist a working compatibility combo. Debounced so it doesn't flash during the brief window
|
||||||
|
* between an audio connection and the first BLE broadcast. Suppressed whenever any pod is live,
|
||||||
|
* so it never claims "no data" while data is visibly arriving (incl. the #603 duplicate state).
|
||||||
|
*/
|
||||||
|
private val troubleshootSuggestion = combineFlows(
|
||||||
|
profilesRepo.profiles,
|
||||||
|
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
|
||||||
|
deviceMonitor.devices,
|
||||||
|
permissionTool.missingScanPermissions,
|
||||||
|
) { profiles, connectedDevices, devices, missingScanPermissions ->
|
||||||
|
val connectedAddresses = connectedDevices.mapTo(mutableSetOf()) { it.address }
|
||||||
|
val anyProfileConnected = profiles.any { it.address != null && it.address in connectedAddresses }
|
||||||
|
val anyLivePod = devices.any { it.isLive }
|
||||||
|
missingScanPermissions.isEmpty() && anyProfileConnected && !anyLivePod
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flatMapLatest { conditionMet ->
|
||||||
|
if (conditionMet) flow {
|
||||||
|
delay(TROUBLESHOOT_SUGGESTION_DELAY_MS)
|
||||||
|
emit(true)
|
||||||
|
} else flowOf(false)
|
||||||
|
}
|
||||||
|
.onStart { emit(false) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
private val overviewUiSettings = combineFlows(
|
||||||
|
generalSettings.reactionsHintDismissed.flow,
|
||||||
|
generalSettings.hideUnmatchedDevices.flow,
|
||||||
|
troubleshootSuggestion,
|
||||||
|
osKillHint,
|
||||||
|
batteryEstimator.estimates,
|
||||||
|
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, showOsKillHint, batteryEstimates ->
|
||||||
|
OverviewUiSettings(
|
||||||
|
reactionsHintDismissed = reactionsHintDismissed,
|
||||||
|
hideUnmatchedDevices = hideUnmatched,
|
||||||
|
showTroubleshootSuggestion = showTroubleshootSuggestion,
|
||||||
|
showOsKillHint = showOsKillHint,
|
||||||
|
batteryEstimates = batteryEstimates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// When the persistent "hide unmatched" setting is enabled, reset the in-session expand
|
||||||
|
// toggle so the section reappears collapsed if the user later disables the setting.
|
||||||
|
launch {
|
||||||
|
generalSettings.hideUnmatchedDevices.flow
|
||||||
|
.filter { it }
|
||||||
|
.collect { showUnmatchedDevices.value = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val workerAutolaunch = combine(
|
||||||
|
permissionTool.missingScanPermissions,
|
||||||
|
monitorModeResolver.effectiveMode,
|
||||||
|
// BluetoothManager2.connectedDevices is gated by a slow HEADSET-profile lookup. Without
|
||||||
|
// an onStart seed the combine wouldn't fire until that profile is bound, so ALWAYS mode
|
||||||
|
// would silently delay autolaunch until the first connected-device emission.
|
||||||
|
bluetoothManager.connectedDevices
|
||||||
|
.onStart { emit(emptyList()) }
|
||||||
|
.map { it.isNotEmpty() }
|
||||||
|
.distinctUntilChanged(),
|
||||||
|
) { missing, mode, anyConnected -> Triple(missing, mode, anyConnected) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { (missing, mode, anyConnected) ->
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
log(TAG) { "Missing scan permissions: $missing" }
|
||||||
return@onEach
|
return@onEach
|
||||||
}
|
}
|
||||||
|
val shouldStart = when (mode) {
|
||||||
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
|
|
||||||
MonitorMode.MANUAL -> false
|
MonitorMode.MANUAL -> false
|
||||||
MonitorMode.AUTOMATIC -> {
|
MonitorMode.AUTOMATIC -> anyConnected
|
||||||
try {
|
|
||||||
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
|
|
||||||
devices?.isNotEmpty() == true
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MonitorMode.ALWAYS -> true
|
MonitorMode.ALWAYS -> true
|
||||||
}
|
}
|
||||||
if (shouldStart) {
|
if (shouldStart) {
|
||||||
log(TAG) { "Starting monitor" }
|
log(TAG) { "Starting monitor (mode=$mode)" }
|
||||||
monitorControl.startMonitor()
|
monitorControl.startMonitor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,13 +211,15 @@ class OverviewViewModel @Inject constructor(
|
|||||||
updateTicker,
|
updateTicker,
|
||||||
permissionTool.missingPermissions,
|
permissionTool.missingPermissions,
|
||||||
pods,
|
pods,
|
||||||
debugSettings.isDebugModeEnabled.flow,
|
Bugs.isDebug,
|
||||||
bluetoothManager.isBluetoothEnabled,
|
bluetoothManager.isBluetoothEnabled,
|
||||||
profilesRepo.profiles,
|
profilesRepo.profiles,
|
||||||
upgradeRepo.upgradeInfo,
|
upgradeRepo.upgradeInfo,
|
||||||
showUnmatchedDevices,
|
showUnmatchedDevices,
|
||||||
userExpansionOverrides,
|
userExpansionOverrides,
|
||||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds ->
|
overviewUiSettings,
|
||||||
|
profilesRepo.hadLegacyReactionData,
|
||||||
|
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, uiSettings, hadLegacyReactionData ->
|
||||||
// Prune stale overrides (profiles that no longer exist)
|
// Prune stale overrides (profiles that no longer exist)
|
||||||
val currentProfileIds = profiles.map { it.id }.toSet()
|
val currentProfileIds = profiles.map { it.id }.toSet()
|
||||||
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
||||||
@@ -139,12 +228,18 @@ class OverviewViewModel @Inject constructor(
|
|||||||
now = timeSource.now(),
|
now = timeSource.now(),
|
||||||
permissions = permissions,
|
permissions = permissions,
|
||||||
devices = devices,
|
devices = devices,
|
||||||
isDebugMode = isDebugMode,
|
isDebug = isDebug,
|
||||||
isBluetoothEnabled = isBluetoothEnabled,
|
isBluetoothEnabled = isBluetoothEnabled,
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
upgradeInfo = upgradeInfo,
|
upgradeInfo = upgradeInfo,
|
||||||
showUnmatchedDevices = showUnmatched,
|
showUnmatchedDevices = showUnmatched,
|
||||||
userExpandedIds = prunedExpandedIds,
|
userExpandedIds = prunedExpandedIds,
|
||||||
|
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
|
||||||
|
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
|
||||||
|
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
|
||||||
|
showOsKillHint = uiSettings.showOsKillHint,
|
||||||
|
showOsKillAutostartAction = killGuidance.hasAutostartSettings,
|
||||||
|
batteryEstimates = uiSettings.batteryEstimates,
|
||||||
)
|
)
|
||||||
}.asLiveState()
|
}.asLiveState()
|
||||||
|
|
||||||
@@ -154,15 +249,27 @@ class OverviewViewModel @Inject constructor(
|
|||||||
val now: Instant,
|
val now: Instant,
|
||||||
val permissions: Set<Permission>,
|
val permissions: Set<Permission>,
|
||||||
val devices: List<PodDevice>,
|
val devices: List<PodDevice>,
|
||||||
val isDebugMode: Boolean,
|
val isDebug: Boolean,
|
||||||
val isBluetoothEnabled: Boolean,
|
val isBluetoothEnabled: Boolean,
|
||||||
val profiles: List<DeviceProfile>,
|
val profiles: List<DeviceProfile>,
|
||||||
val upgradeInfo: UpgradeRepo.Info,
|
val upgradeInfo: UpgradeRepo.Info,
|
||||||
val showUnmatchedDevices: Boolean,
|
val showUnmatchedDevices: Boolean,
|
||||||
val userExpandedIds: Set<String> = emptySet(),
|
val userExpandedIds: Set<String> = emptySet(),
|
||||||
|
val showReactionsHint: Boolean = false,
|
||||||
|
val hideUnmatchedDevices: Boolean = false,
|
||||||
|
val showTroubleshootSuggestion: Boolean = false,
|
||||||
|
val showOsKillHint: Boolean = false,
|
||||||
|
val showOsKillAutostartAction: Boolean = false,
|
||||||
|
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time-remaining estimate to show for [device], or null when the device has the estimate
|
||||||
|
* disabled, isn't live (no estimate for cached/offline cards), or no rate is available yet.
|
||||||
|
*/
|
||||||
|
fun estimateFor(device: PodDevice): BatteryEstimate? = batteryEstimates.estimateFor(device)
|
||||||
|
|
||||||
/** Profile list order used as tiebreaker within each connection tier. */
|
/** Profile list order used as tiebreaker within each connection tier. */
|
||||||
private val profileOrder: Map<String, Int> by lazy {
|
private val profileOrder: Map<String, Int> by lazy {
|
||||||
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
|
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
|
||||||
@@ -170,7 +277,7 @@ class OverviewViewModel @Inject constructor(
|
|||||||
|
|
||||||
val profiledDevices: List<PodDevice> by lazy {
|
val profiledDevices: List<PodDevice> by lazy {
|
||||||
devices.filter { it.profileId != null }.sortedWith(
|
devices.filter { it.profileId != null }.sortedWith(
|
||||||
compareBy<PodDevice> { deviceTierRank(it) }
|
compareBy<PodDevice> { it.tierRank() }
|
||||||
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -180,6 +287,11 @@ class OverviewViewModel @Inject constructor(
|
|||||||
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
|
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
|
||||||
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
|
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
|
||||||
|
|
||||||
|
/** Unmatched devices actually shown on the dashboard (empty when the hide setting is on). */
|
||||||
|
val visibleUnmatchedDevices: List<PodDevice>
|
||||||
|
get() = if (hideUnmatchedDevices) emptyList() else unmatchedDevices
|
||||||
|
val shouldShowUnmatchedSection: Boolean get() = visibleUnmatchedDevices.isNotEmpty()
|
||||||
|
|
||||||
val bluetoothIconState: BluetoothIconState
|
val bluetoothIconState: BluetoothIconState
|
||||||
get() = when {
|
get() = when {
|
||||||
isScanBlocked -> BluetoothIconState.HIDDEN
|
isScanBlocked -> BluetoothIconState.HIDDEN
|
||||||
@@ -218,6 +330,11 @@ class OverviewViewModel @Inject constructor(
|
|||||||
navTo(Nav.Settings.Index)
|
navTo(Nav.Settings.Index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun goToTroubleShooter() {
|
||||||
|
log(TAG, INFO) { "goToTroubleShooter()" }
|
||||||
|
navTo(Nav.Main.TroubleShooter)
|
||||||
|
}
|
||||||
|
|
||||||
fun goToDeviceManager() {
|
fun goToDeviceManager() {
|
||||||
log(TAG, INFO) { "goToDeviceManager()" }
|
log(TAG, INFO) { "goToDeviceManager()" }
|
||||||
navTo(Nav.Main.DeviceManager)
|
navTo(Nav.Main.DeviceManager)
|
||||||
@@ -252,6 +369,32 @@ class OverviewViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dismissReactionsHint() {
|
||||||
|
log(TAG, INFO) { "dismissReactionsHint()" }
|
||||||
|
launch {
|
||||||
|
generalSettings.reactionsHintDismissed.value(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOsKillShowInstructions() {
|
||||||
|
log(TAG, INFO) { "onOsKillShowInstructions()" }
|
||||||
|
killGuidance.openKillInstructions()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOsKillAutostartSettings() {
|
||||||
|
log(TAG, INFO) { "onOsKillAutostartSettings()" }
|
||||||
|
killGuidance.openAutostartSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissOsKillHint() {
|
||||||
|
log(TAG, INFO) { "dismissOsKillHint()" }
|
||||||
|
launch {
|
||||||
|
// Anchor the dismissal to the kill being dismissed, not wall-clock "now" — clock
|
||||||
|
// jumps must neither keep the card visible nor suppress future kills.
|
||||||
|
generalSettings.osKillHintDismissedAt.value(generalSettings.lastOsKillAt.value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun requestPermission(permission: Permission) {
|
fun requestPermission(permission: Permission) {
|
||||||
log(TAG, INFO) { "requestPermission($permission)" }
|
log(TAG, INFO) { "requestPermission($permission)" }
|
||||||
requestPermissionEvent.tryEmit(permission)
|
requestPermissionEvent.tryEmit(permission)
|
||||||
@@ -271,13 +414,14 @@ class OverviewViewModel @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val FREE_DEVICE_LIMIT = 1
|
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 {
|
* How long the "connected but no live data" condition must hold before the troubleshooter
|
||||||
device.isSystemConnected -> 0
|
* hint appears, so it doesn't flash during the gap between an audio connection and the first
|
||||||
device.isLive -> 1
|
* BLE broadcast.
|
||||||
else -> 2
|
*/
|
||||||
}
|
private const val TROUBLESHOOT_SUGGESTION_DELAY_MS = 15_000L
|
||||||
|
|
||||||
|
private val TAG = logTag("Overview", "VM")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,13 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||||
import androidx.compose.material.icons.twotone.GridView
|
import androidx.compose.material.icons.twotone.GridView
|
||||||
import androidx.compose.material.icons.twotone.Tune
|
import androidx.compose.material.icons.twotone.Tune
|
||||||
import androidx.compose.material.icons.twotone.Warning
|
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedIconButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -52,6 +51,7 @@ import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule
|
|||||||
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
|
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
|
||||||
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
|
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
|
||||||
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
|
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
|
||||||
|
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
|
||||||
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
|
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
|
||||||
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
|
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
|
||||||
import eu.darken.capod.main.ui.overview.cards.components.StatusChipRow
|
import eu.darken.capod.main.ui.overview.cards.components.StatusChipRow
|
||||||
@@ -60,14 +60,15 @@ import eu.darken.capod.common.compose.Preview2
|
|||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
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
|
import java.time.Instant
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -77,6 +78,7 @@ fun DualPodsCard(
|
|||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
isCollapsed: Boolean = false,
|
isCollapsed: Boolean = false,
|
||||||
|
batteryEstimate: BatteryEstimate? = null,
|
||||||
onToggleCollapse: (() -> Unit)? = null,
|
onToggleCollapse: (() -> Unit)? = null,
|
||||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||||
onUpgrade: (() -> Unit)? = null,
|
onUpgrade: (() -> Unit)? = null,
|
||||||
@@ -167,7 +169,7 @@ fun DualPodsCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (device.profileId != null && onDeviceSettings != null) {
|
if (device.profileId != null && onDeviceSettings != null) {
|
||||||
IconButton(onClick = onDeviceSettings) {
|
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.TwoTone.Tune,
|
imageVector = Icons.TwoTone.Tune,
|
||||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||||
@@ -175,15 +177,15 @@ fun DualPodsCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (device.profileId != null && device.address == null && onEditProfile != null) {
|
}
|
||||||
IconButton(onClick = onEditProfile) {
|
|
||||||
Icon(
|
if (!isCollapsed
|
||||||
imageVector = Icons.TwoTone.Warning,
|
&& device.profileId != null
|
||||||
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
|
&& !device.hasSelectedPairedDevice
|
||||||
tint = MaterialTheme.colorScheme.error,
|
&& onEditProfile != null
|
||||||
)
|
) {
|
||||||
}
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
MissingPairedDeviceBanner(onClick = onEditProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
@@ -193,6 +195,7 @@ fun DualPodsCard(
|
|||||||
device = device,
|
device = device,
|
||||||
showDebug = showDebug,
|
showDebug = showDebug,
|
||||||
now = now,
|
now = now,
|
||||||
|
batteryEstimate = batteryEstimate,
|
||||||
onAncModeChange = onAncModeChange,
|
onAncModeChange = onAncModeChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -205,8 +208,10 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
|
batteryEstimate: BatteryEstimate?,
|
||||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Circular battery gauges side by side
|
// Circular battery gauges side by side
|
||||||
@@ -226,24 +231,30 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
) {
|
) {
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
batteryPercent = device.batteryLeft,
|
||||||
isCharging = device.isLeftPodCharging ?: false,
|
chargingState = device.leftPodChargingState
|
||||||
|
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||||
isInEar = device.isLeftInEar ?: false,
|
isInEar = device.isLeftInEar ?: false,
|
||||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||||
showMicrophone = device.hasDualMicrophone,
|
showMicrophone = device.hasDualMicrophone,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||||
|
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||||
)
|
)
|
||||||
|
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
batteryPercent = device.batteryRight,
|
||||||
isCharging = device.isRightPodCharging ?: false,
|
chargingState = device.rightPodChargingState
|
||||||
|
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||||
isInEar = device.isRightInEar ?: false,
|
isInEar = device.isRightInEar ?: false,
|
||||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||||
showMicrophone = device.hasDualMicrophone,
|
showMicrophone = device.hasDualMicrophone,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||||
|
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +303,14 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
private fun PodGauge(
|
private fun PodGauge(
|
||||||
iconRes: Int,
|
iconRes: Int,
|
||||||
batteryPercent: Float,
|
batteryPercent: Float,
|
||||||
isCharging: Boolean,
|
chargingState: AapPodState.ChargingState?,
|
||||||
isInEar: Boolean,
|
isInEar: Boolean,
|
||||||
showEarDetection: Boolean,
|
showEarDetection: Boolean,
|
||||||
isMicrophone: Boolean,
|
isMicrophone: Boolean,
|
||||||
showMicrophone: Boolean,
|
showMicrophone: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
timeRemaining: String? = null,
|
||||||
|
untilCharged: String? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
||||||
@@ -355,29 +368,42 @@ private fun PodGauge(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// Battery percentage
|
// Battery percentage, with the time-remaining estimate inline next to it
|
||||||
Text(
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
Text(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
text = formatBatteryPercent(context, batteryPercent),
|
||||||
color = if (batteryPercent >= 0f) {
|
style = MaterialTheme.typography.titleMedium,
|
||||||
MaterialTheme.colorScheme.onSurface
|
color = if (batteryPercent >= 0f) {
|
||||||
} else {
|
MaterialTheme.colorScheme.onSurface
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
} else {
|
||||||
},
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
},
|
||||||
|
modifier = Modifier.alignByBaseline(),
|
||||||
|
)
|
||||||
|
if (timeRemaining != null) {
|
||||||
|
Text(
|
||||||
|
text = "· $timeRemaining",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.alignByBaseline(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
// Status chips
|
// Status chips
|
||||||
StatusChipRow(
|
StatusChipRow(
|
||||||
isCharging = isCharging,
|
chargingState = chargingState,
|
||||||
isInEar = isInEar,
|
isInEar = isInEar,
|
||||||
showEarDetection = showEarDetection,
|
showEarDetection = showEarDetection,
|
||||||
isMicrophone = isMicrophone,
|
isMicrophone = isMicrophone,
|
||||||
showMicrophone = showMicrophone,
|
showMicrophone = showMicrophone,
|
||||||
chargingLabel = stringResource(R.string.pods_charging_label),
|
chargingLabel = stringResource(R.string.pods_charging_label),
|
||||||
|
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
|
||||||
inEarLabel = stringResource(R.string.pods_inear_label),
|
inEarLabel = stringResource(R.string.pods_inear_label),
|
||||||
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
||||||
|
chargingDetail = untilCharged,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,7 +434,7 @@ private fun CaseRow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCase.toBatteryFloat(),
|
percent = device.batteryCase,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(8.dp),
|
.height(8.dp),
|
||||||
@@ -420,11 +446,17 @@ private fun CaseRow(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
if (device.isCaseCharging == true) {
|
when (val caseState = device.caseChargingState
|
||||||
StatusChip(
|
?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
|
||||||
|
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||||
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
|
label = stringResource(R.string.pods_charging_optimized_label),
|
||||||
|
)
|
||||||
|
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = stringResource(R.string.pods_charging_label),
|
label = stringResource(R.string.pods_charging_label),
|
||||||
)
|
)
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
val lidState = device.caseLidState
|
val lidState = device.caseLidState
|
||||||
@@ -446,10 +478,49 @@ private fun CaseRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
||||||
DualPodsCard(
|
DualPodsCard(
|
||||||
device = MockPodDataProvider.dualPodMonitoredWithAap(),
|
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||||
showDebug = false,
|
showDebug = false,
|
||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
isPro = false,
|
isPro = false,
|
||||||
|
batteryEstimate = BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
|
||||||
|
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
onDeviceSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun DualPodsCardEstimateLearnedPreview() = PreviewWrapper {
|
||||||
|
// LEARNED: rate seeded from persisted history on reconnect, before this session has gathered
|
||||||
|
// enough live samples.
|
||||||
|
DualPodsCard(
|
||||||
|
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||||
|
showDebug = false,
|
||||||
|
now = SystemTimeSource.now(),
|
||||||
|
isPro = false,
|
||||||
|
batteryEstimate = BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(minutesRemaining = 92, fractionPerHour = 0.27f, source = BatteryEstimate.Source.LEARNED),
|
||||||
|
right = BatteryEstimate.Pod(minutesRemaining = 100, fractionPerHour = 0.25f, source = BatteryEstimate.Source.LEARNED),
|
||||||
|
),
|
||||||
|
onDeviceSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper {
|
||||||
|
// SPEC: rated estimate shown immediately on connect, before any drain has been measured.
|
||||||
|
DualPodsCard(
|
||||||
|
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||||
|
showDebug = false,
|
||||||
|
now = SystemTimeSource.now(),
|
||||||
|
isPro = false,
|
||||||
|
batteryEstimate = BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
|
||||||
|
right = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
|
||||||
|
),
|
||||||
onDeviceSettings = {},
|
onDeviceSettings = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -490,7 +561,7 @@ private fun DualPodsCardCollapsedPreview() = PreviewWrapper {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DualPodsCardMissingAddressPreview() = PreviewWrapper {
|
private fun DualPodsCardMissingAddressPreview() = PreviewWrapper {
|
||||||
DualPodsCard(
|
DualPodsCard(
|
||||||
device = MockPodDataProvider.dualPodMonitoredMixed(),
|
device = MockPodDataProvider.dualPodMissingPairedDevice(),
|
||||||
showDebug = false,
|
showDebug = false,
|
||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
onEditProfile = {},
|
onEditProfile = {},
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package eu.darken.capod.main.ui.overview.cards
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.twotone.BatterySaver
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
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
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.compose.Preview2
|
||||||
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MonitorKilledHintCard(
|
||||||
|
showAutostartAction: Boolean,
|
||||||
|
onShowInstructions: () -> Unit,
|
||||||
|
onAutostartSettings: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.TwoTone.BatterySaver,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_os_kill_hint_label),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_os_kill_hint_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Stacked instead of a single Row — up to three actions don't fit on narrow screens,
|
||||||
|
// especially localized or at increased font scale.
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
) {
|
||||||
|
Button(onClick = onShowInstructions) {
|
||||||
|
Text(text = stringResource(R.string.overview_os_kill_hint_instructions_action))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAutostartAction) {
|
||||||
|
OutlinedButton(onClick = onAutostartSettings) {
|
||||||
|
Text(text = stringResource(R.string.overview_os_kill_hint_autostart_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(text = stringResource(R.string.overview_os_kill_hint_dismiss_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun MonitorKilledHintCardPreview() = PreviewWrapper {
|
||||||
|
MonitorKilledHintCard(
|
||||||
|
showAutostartAction = true,
|
||||||
|
onShowInstructions = {},
|
||||||
|
onAutostartSettings = {},
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package eu.darken.capod.main.ui.overview.cards
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.twotone.Tune
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.compose.Preview2
|
||||||
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReactionsMovedHintCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(3.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.TwoTone.Tune,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_reactions_hint_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_reactions_hint_body),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun ReactionsMovedHintCardPreview() = PreviewWrapper {
|
||||||
|
ReactionsMovedHintCard()
|
||||||
|
}
|
||||||
@@ -24,13 +24,12 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||||
import androidx.compose.material.icons.twotone.Hearing
|
import androidx.compose.material.icons.twotone.Hearing
|
||||||
import androidx.compose.material.icons.twotone.Tune
|
import androidx.compose.material.icons.twotone.Tune
|
||||||
import androidx.compose.material.icons.twotone.Warning
|
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedIconButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -50,6 +49,7 @@ import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
|
|||||||
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
|
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
|
||||||
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
|
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
|
||||||
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
|
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
|
||||||
|
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
|
||||||
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
|
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
|
||||||
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
|
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
|
||||||
import eu.darken.capod.common.SystemTimeSource
|
import eu.darken.capod.common.SystemTimeSource
|
||||||
@@ -57,9 +57,14 @@ import eu.darken.capod.common.compose.Preview2
|
|||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@@ -70,6 +75,7 @@ fun SinglePodsCard(
|
|||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
isCollapsed: Boolean = false,
|
isCollapsed: Boolean = false,
|
||||||
|
batteryEstimate: BatteryEstimate? = null,
|
||||||
onToggleCollapse: (() -> Unit)? = null,
|
onToggleCollapse: (() -> Unit)? = null,
|
||||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||||
onUpgrade: (() -> Unit)? = null,
|
onUpgrade: (() -> Unit)? = null,
|
||||||
@@ -149,7 +155,7 @@ fun SinglePodsCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (device.profileId != null && onDeviceSettings != null) {
|
if (device.profileId != null && onDeviceSettings != null) {
|
||||||
IconButton(onClick = onDeviceSettings) {
|
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.TwoTone.Tune,
|
imageVector = Icons.TwoTone.Tune,
|
||||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||||
@@ -157,15 +163,15 @@ fun SinglePodsCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (device.profileId != null && device.address == null && onEditProfile != null) {
|
}
|
||||||
IconButton(onClick = onEditProfile) {
|
|
||||||
Icon(
|
if (!isCollapsed
|
||||||
imageVector = Icons.TwoTone.Warning,
|
&& device.profileId != null
|
||||||
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
|
&& !device.hasSelectedPairedDevice
|
||||||
tint = MaterialTheme.colorScheme.error,
|
&& onEditProfile != null
|
||||||
)
|
) {
|
||||||
}
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
MissingPairedDeviceBanner(onClick = onEditProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
@@ -175,6 +181,7 @@ fun SinglePodsCard(
|
|||||||
device = device,
|
device = device,
|
||||||
showDebug = showDebug,
|
showDebug = showDebug,
|
||||||
now = now,
|
now = now,
|
||||||
|
batteryEstimate = batteryEstimate,
|
||||||
onAncModeChange = onAncModeChange,
|
onAncModeChange = onAncModeChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -187,21 +194,23 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
|
batteryEstimate: BatteryEstimate?,
|
||||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
val percent = device.batteryHeadset
|
||||||
|
val isKnown = isKnownBattery(percent)
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = batteryProgress(percent),
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "gaugeProgress",
|
label = "gaugeProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ringColor = when {
|
val ringColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +245,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Progress ring
|
// Progress ring
|
||||||
if (clamped != null) {
|
if (isKnown) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(88.dp),
|
modifier = Modifier.size(88.dp),
|
||||||
@@ -247,16 +256,28 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Battery text inside ring
|
// Battery percentage + time remaining, stacked inside the ring
|
||||||
Text(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
text = formatBatteryPercent(context, device.batteryHeadset),
|
Text(
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
text = formatBatteryPercent(context, percent),
|
||||||
color = if (device.batteryHeadset != null) {
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
MaterialTheme.colorScheme.onSurface
|
color = if (isKnown) {
|
||||||
} else {
|
MaterialTheme.colorScheme.onSurface
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
} else {
|
||||||
},
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
val headsetEstimate = batteryEstimate?.headset
|
||||||
|
if (headsetEstimate != null) {
|
||||||
|
Text(
|
||||||
|
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -267,11 +288,25 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
if (device.isHeadsetBeingCharged == true) {
|
when (val headsetState = device.headsetChargingState
|
||||||
StatusChip(
|
?: device.isHeadsetBeingCharged?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
|
||||||
|
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = stringResource(R.string.pods_charging_label),
|
label = stringResource(R.string.pods_charging_optimized_label),
|
||||||
)
|
)
|
||||||
|
AapPodState.ChargingState.CHARGING -> {
|
||||||
|
// Time-until-charged lives in the charging chip; the in-ring estimate
|
||||||
|
// always means runtime, so the two can't be confused.
|
||||||
|
val untilCharged = batteryEstimate?.headset?.minutesUntilCharged
|
||||||
|
?.let { formatBatteryDurationShort(context, it) }
|
||||||
|
StatusChip(
|
||||||
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
|
label = untilCharged
|
||||||
|
?.let { "${stringResource(R.string.pods_charging_label)} · $it" }
|
||||||
|
?: stringResource(R.string.pods_charging_label),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
if (device.isBeingWorn == true) {
|
if (device.isBeingWorn == true) {
|
||||||
StatusChip(
|
StatusChip(
|
||||||
@@ -323,6 +358,21 @@ private fun SinglePodsCardFullPreview() = PreviewWrapper {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
|
||||||
|
// Worn (not charging) so the "in ear" chip shows alongside the estimate.
|
||||||
|
SinglePodsCard(
|
||||||
|
device = MockPodDataProvider.singlePodMonitored(),
|
||||||
|
showDebug = false,
|
||||||
|
now = SystemTimeSource.now(),
|
||||||
|
batteryEstimate = BatteryEstimate(
|
||||||
|
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
|
||||||
|
),
|
||||||
|
onDeviceSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
|
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
|
||||||
@@ -359,7 +409,7 @@ private fun SinglePodsCardCollapsedPreview() = PreviewWrapper {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SinglePodsCardMissingAddressPreview() = PreviewWrapper {
|
private fun SinglePodsCardMissingAddressPreview() = PreviewWrapper {
|
||||||
SinglePodsCard(
|
SinglePodsCard(
|
||||||
device = MockPodDataProvider.singlePodMonitored(),
|
device = MockPodDataProvider.singlePodMissingPairedDevice(),
|
||||||
showDebug = false,
|
showDebug = false,
|
||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
onEditProfile = {},
|
onEditProfile = {},
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package eu.darken.capod.main.ui.overview.cards
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.twotone.Troubleshoot
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.compose.Preview2
|
||||||
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TroubleshootSuggestionCard(onTroubleShooter: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.TwoTone.Troubleshoot,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_troubleshoot_suggestion_label),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_troubleshoot_suggestion_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onTroubleShooter,
|
||||||
|
modifier = Modifier.align(Alignment.End),
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.overview_troubleshoot_suggestion_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun TroubleshootSuggestionCardPreview() = PreviewWrapper {
|
||||||
|
TroubleshootSuggestionCard(onTroubleShooter = {})
|
||||||
|
}
|
||||||
+17
-15
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
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.formatBatteryPercent
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CompactBatterySummary(
|
fun CompactBatterySummary(
|
||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hasAnyBattery = device.batteryLeft != null
|
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|
||||||
|| device.batteryRight != null
|
|| isKnownBattery(device.batteryRight)
|
||||||
|| device.batteryHeadset != null
|
|| isKnownBattery(device.batteryHeadset)
|
||||||
|| device.batteryCase != null
|
|| isKnownBattery(device.batteryCase)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
|
|||||||
percent = device.batteryRight,
|
percent = device.batteryRight,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (device.hasCase && device.batteryCase != null) {
|
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
MiniCaseCluster(device = device)
|
MiniCaseCluster(device = device)
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
|
|||||||
iconRes = null,
|
iconRes = null,
|
||||||
percent = device.batteryHeadset,
|
percent = device.batteryHeadset,
|
||||||
)
|
)
|
||||||
if (device.hasCase && device.batteryCase != null) {
|
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
MiniCaseCluster(device = device)
|
MiniCaseCluster(device = device)
|
||||||
}
|
}
|
||||||
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MiniPodRing(
|
private fun MiniPodRing(
|
||||||
iconRes: Int?,
|
iconRes: Int?,
|
||||||
percent: Float?,
|
percent: Float,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clamped = percent?.coerceIn(0f, 1f)
|
val isKnown = isKnownBattery(percent)
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = batteryProgress(percent),
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "miniGaugeProgress",
|
label = "miniGaugeProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ringColor = when {
|
val ringColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ private fun MiniPodRing(
|
|||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
strokeCap = StrokeCap.Round,
|
strokeCap = StrokeCap.Round,
|
||||||
)
|
)
|
||||||
if (clamped != null) {
|
if (isKnown) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(28.dp),
|
modifier = Modifier.size(28.dp),
|
||||||
@@ -177,7 +179,7 @@ private fun MiniPodRing(
|
|||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, percent),
|
text = formatBatteryPercent(context, percent),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = if (percent != null) {
|
color = if (isKnown) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCase ?: -1f,
|
percent = device.batteryCase,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(36.dp)
|
.width(36.dp)
|
||||||
.height(6.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 androidx.compose.ui.unit.dp
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusChip(
|
fun StatusChip(
|
||||||
@@ -60,26 +61,35 @@ fun StatusChip(
|
|||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusChipRow(
|
fun StatusChipRow(
|
||||||
isCharging: Boolean,
|
chargingState: AapPodState.ChargingState?,
|
||||||
isInEar: Boolean,
|
isInEar: Boolean,
|
||||||
showEarDetection: Boolean,
|
showEarDetection: Boolean,
|
||||||
isMicrophone: Boolean,
|
isMicrophone: Boolean,
|
||||||
showMicrophone: Boolean,
|
showMicrophone: Boolean,
|
||||||
chargingLabel: String,
|
chargingLabel: String,
|
||||||
|
chargingOptimizedLabel: String,
|
||||||
inEarLabel: String,
|
inEarLabel: String,
|
||||||
microphoneLabel: String,
|
microphoneLabel: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
chargingDetail: String? = null,
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = modifier.animateContentSize(),
|
modifier = modifier.animateContentSize(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
if (isCharging) {
|
when (chargingState) {
|
||||||
StatusChip(
|
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = chargingLabel,
|
label = chargingOptimizedLabel,
|
||||||
)
|
)
|
||||||
|
// The time-until-charged lives INSIDE the charging chip ("Charging · 25m") — the
|
||||||
|
// estimate line under the percentage always means runtime, so the two can't be confused.
|
||||||
|
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||||
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
|
label = chargingDetail?.let { "$chargingLabel · $it" } ?: chargingLabel,
|
||||||
|
)
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
if (showMicrophone && isMicrophone) {
|
if (showMicrophone && isMicrophone) {
|
||||||
StatusChip(
|
StatusChip(
|
||||||
@@ -106,12 +116,30 @@ private fun StatusChipChargingPreview() = PreviewWrapper {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun StatusChipRowAllPreview() = PreviewWrapper {
|
private fun StatusChipRowAllPreview() = PreviewWrapper {
|
||||||
StatusChipRow(
|
StatusChipRow(
|
||||||
isCharging = true,
|
chargingState = AapPodState.ChargingState.CHARGING,
|
||||||
isInEar = true,
|
isInEar = true,
|
||||||
showEarDetection = true,
|
showEarDetection = true,
|
||||||
isMicrophone = true,
|
isMicrophone = true,
|
||||||
showMicrophone = true,
|
showMicrophone = true,
|
||||||
chargingLabel = "Charging",
|
chargingLabel = "Charging",
|
||||||
|
chargingOptimizedLabel = "Optimized",
|
||||||
|
inEarLabel = "In Ear",
|
||||||
|
microphoneLabel = "Mic",
|
||||||
|
chargingDetail = "25m",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun StatusChipRowOptimizedPreview() = PreviewWrapper {
|
||||||
|
StatusChipRow(
|
||||||
|
chargingState = AapPodState.ChargingState.CHARGING_OPTIMIZED,
|
||||||
|
isInEar = false,
|
||||||
|
showEarDetection = true,
|
||||||
|
isMicrophone = true,
|
||||||
|
showMicrophone = true,
|
||||||
|
chargingLabel = "Charging",
|
||||||
|
chargingOptimizedLabel = "Optimized",
|
||||||
inEarLabel = "In Ear",
|
inEarLabel = "In Ear",
|
||||||
microphoneLabel = "Mic",
|
microphoneLabel = "Mic",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -70,6 +71,18 @@ fun PressControlsScreenHost(
|
|||||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||||
val currentState = state ?: return
|
val currentState = state ?: return
|
||||||
|
|
||||||
|
val isAapConnected = currentState.device?.isAapConnected == true
|
||||||
|
var hasSeenAapConnected by rememberSaveable(profileId) { mutableStateOf(false) }
|
||||||
|
var didAutoNavigate by rememberSaveable(profileId) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(profileId, isAapConnected) {
|
||||||
|
if (isAapConnected) {
|
||||||
|
hasSeenAapConnected = true
|
||||||
|
} else if (hasSeenAapConnected && !didAutoNavigate) {
|
||||||
|
didAutoNavigate = true
|
||||||
|
vm.navUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PressControlsScreen(
|
PressControlsScreen(
|
||||||
state = currentState,
|
state = currentState,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
|
|||||||
@@ -100,6 +100,13 @@ fun AcknowledgementsScreen(
|
|||||||
onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") },
|
onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
SettingsBaseItem(
|
||||||
|
title = "apple-wireshark",
|
||||||
|
subtitle = "Thanks to Pablo Aul for the Wireshark dissector catalog of the AAP/AACP protocol.",
|
||||||
|
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
|
||||||
|
)
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
SettingsBaseItem(
|
SettingsBaseItem(
|
||||||
title = "crowdin.com",
|
title = "crowdin.com",
|
||||||
@@ -110,13 +117,6 @@ fun AcknowledgementsScreen(
|
|||||||
item {
|
item {
|
||||||
SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label))
|
SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label))
|
||||||
}
|
}
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = "Glide",
|
|
||||||
subtitle = "An image loading and caching library for Android focused on smooth scrolling. (Multiple licenses)",
|
|
||||||
onClick = { onOpenUrl("https://github.com/bumptech/glide") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
SettingsBaseItem(
|
SettingsBaseItem(
|
||||||
title = "Material Design Icons",
|
title = "Material Design Icons",
|
||||||
@@ -145,6 +145,13 @@ fun AcknowledgementsScreen(
|
|||||||
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
|
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
SettingsBaseItem(
|
||||||
|
title = "apple-wireshark",
|
||||||
|
subtitle = "Wireshark dissector plugins for Apple protocols. (GPL-3.0)",
|
||||||
|
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
|
||||||
|
)
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
SettingsBaseItem(
|
SettingsBaseItem(
|
||||||
title = "Kotlin",
|
title = "Kotlin",
|
||||||
@@ -166,13 +173,6 @@ fun AcknowledgementsScreen(
|
|||||||
onClick = { onOpenUrl("https://source.android.com/source/licenses.html") },
|
onClick = { onOpenUrl("https://source.android.com/source/licenses.html") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = "Android",
|
|
||||||
subtitle = "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.",
|
|
||||||
onClick = { onOpenUrl("https://developer.android.com/distribute/tools/promote/brand.html") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-131
@@ -1,51 +1,37 @@
|
|||||||
package eu.darken.capod.main.ui.settings.general
|
package eu.darken.capod.main.ui.settings.general
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.selection.selectable
|
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||||
import androidx.compose.material.icons.twotone.AccountTree
|
import androidx.compose.material.icons.twotone.AccountTree
|
||||||
import androidx.compose.material.icons.twotone.BugReport
|
|
||||||
import androidx.compose.material.icons.twotone.Contrast
|
import androidx.compose.material.icons.twotone.Contrast
|
||||||
import androidx.compose.material.icons.twotone.DarkMode
|
import androidx.compose.material.icons.twotone.DarkMode
|
||||||
import androidx.compose.material.icons.twotone.DisabledVisible
|
|
||||||
import androidx.compose.material.icons.twotone.FilterList
|
import androidx.compose.material.icons.twotone.FilterList
|
||||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||||
import androidx.compose.material.icons.twotone.Notifications
|
import androidx.compose.material.icons.twotone.Notifications
|
||||||
import androidx.compose.material.icons.twotone.Palette
|
import androidx.compose.material.icons.twotone.Palette
|
||||||
import androidx.compose.material.icons.twotone.SettingsBluetooth
|
import androidx.compose.material.icons.twotone.VisibilityOff
|
||||||
import androidx.compose.material.icons.automirrored.twotone.ViewList
|
import androidx.compose.material.icons.automirrored.twotone.ViewList
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import eu.darken.capod.common.error.ErrorEventHandler
|
import eu.darken.capod.common.error.ErrorEventHandler
|
||||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||||
@@ -57,7 +43,6 @@ import eu.darken.capod.common.theming.ThemeColor
|
|||||||
import eu.darken.capod.common.theming.ThemeMode
|
import eu.darken.capod.common.theming.ThemeMode
|
||||||
import eu.darken.capod.common.theming.ThemeState
|
import eu.darken.capod.common.theming.ThemeState
|
||||||
import eu.darken.capod.common.theming.ThemeStyle
|
import eu.darken.capod.common.theming.ThemeStyle
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||||
@@ -69,14 +54,12 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
|||||||
GeneralSettingsScreen(
|
GeneralSettingsScreen(
|
||||||
state = it,
|
state = it,
|
||||||
onNavigateUp = { vm.navUp() },
|
onNavigateUp = { vm.navUp() },
|
||||||
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
|
|
||||||
onScannerModeSelected = { mode -> vm.setScannerMode(mode) },
|
|
||||||
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
|
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
|
||||||
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
|
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
|
||||||
onDebugSettings = { vm.goToDebugSettings() },
|
|
||||||
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
|
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
|
||||||
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
|
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
|
||||||
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
|
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
|
||||||
|
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
|
||||||
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
|
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
|
||||||
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
|
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
|
||||||
onThemeColorSelected = { color -> vm.setThemeColor(color) },
|
onThemeColorSelected = { color -> vm.setThemeColor(color) },
|
||||||
@@ -89,22 +72,18 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
|||||||
fun GeneralSettingsScreen(
|
fun GeneralSettingsScreen(
|
||||||
state: GeneralSettingsViewModel.State,
|
state: GeneralSettingsViewModel.State,
|
||||||
onNavigateUp: () -> Unit,
|
onNavigateUp: () -> Unit,
|
||||||
onMonitorModeSelected: (MonitorMode) -> Unit,
|
|
||||||
onScannerModeSelected: (ScannerMode) -> Unit,
|
|
||||||
onShowConnectedNotificationChanged: (Boolean) -> Unit,
|
onShowConnectedNotificationChanged: (Boolean) -> Unit,
|
||||||
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
|
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
|
||||||
onDebugSettings: () -> Unit,
|
|
||||||
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
|
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
|
||||||
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
|
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
|
||||||
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
|
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
|
||||||
|
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
|
||||||
onThemeModeSelected: (ThemeMode) -> Unit = {},
|
onThemeModeSelected: (ThemeMode) -> Unit = {},
|
||||||
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
|
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
|
||||||
onThemeColorSelected: (ThemeColor) -> Unit = {},
|
onThemeColorSelected: (ThemeColor) -> Unit = {},
|
||||||
onUpgrade: () -> Unit = {},
|
onUpgrade: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var showMonitorModeDialog by remember { mutableStateOf(false) }
|
|
||||||
var showScannerModeDialog by remember { mutableStateOf(false) }
|
|
||||||
var showColorDialog by remember { mutableStateOf(false) }
|
var showColorDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
|
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
|
||||||
@@ -127,25 +106,6 @@ fun GeneralSettingsScreen(
|
|||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
item {
|
|
||||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_monitoring_label))
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = stringResource(R.string.settings_monitor_mode_label),
|
|
||||||
subtitle = stringResource(state.monitorMode.labelRes),
|
|
||||||
icon = Icons.TwoTone.DisabledVisible,
|
|
||||||
onClick = { showMonitorModeDialog = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = stringResource(R.string.settings_scanner_mode_label),
|
|
||||||
subtitle = stringResource(state.scannerMode.labelRes),
|
|
||||||
icon = Icons.TwoTone.SettingsBluetooth,
|
|
||||||
onClick = { showScannerModeDialog = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
|
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
|
||||||
}
|
}
|
||||||
@@ -242,10 +202,17 @@ fun GeneralSettingsScreen(
|
|||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
SettingsBaseItem(
|
SettingsBaseItem(
|
||||||
title = stringResource(R.string.settings_debug_label),
|
title = stringResource(R.string.settings_overview_hide_unmatched_label),
|
||||||
subtitle = stringResource(R.string.settings_debug_description),
|
subtitle = stringResource(R.string.settings_overview_hide_unmatched_description),
|
||||||
icon = Icons.TwoTone.BugReport,
|
icon = Icons.TwoTone.VisibilityOff,
|
||||||
onClick = onDebugSettings,
|
onClick = { onHideUnmatchedDevicesChanged(!state.hideUnmatchedDevices) },
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = state.hideUnmatchedDevices,
|
||||||
|
onCheckedChange = onHideUnmatchedDevicesChanged,
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -299,34 +266,6 @@ fun GeneralSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showMonitorModeDialog) {
|
|
||||||
ListPreferenceDialog(
|
|
||||||
title = stringResource(R.string.settings_monitor_mode_label),
|
|
||||||
entries = MonitorMode.entries,
|
|
||||||
selectedEntry = state.monitorMode,
|
|
||||||
onEntrySelected = {
|
|
||||||
onMonitorModeSelected(it)
|
|
||||||
showMonitorModeDialog = false
|
|
||||||
},
|
|
||||||
entryLabel = { stringResource(it.labelRes) },
|
|
||||||
onDismiss = { showMonitorModeDialog = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showScannerModeDialog) {
|
|
||||||
ListPreferenceDialog(
|
|
||||||
title = stringResource(R.string.settings_scanner_mode_label),
|
|
||||||
entries = ScannerMode.entries,
|
|
||||||
selectedEntry = state.scannerMode,
|
|
||||||
onEntrySelected = {
|
|
||||||
onScannerModeSelected(it)
|
|
||||||
showScannerModeDialog = false
|
|
||||||
},
|
|
||||||
entryLabel = { stringResource(it.labelRes) },
|
|
||||||
onDismiss = { showScannerModeDialog = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showColorDialog) {
|
if (showColorDialog) {
|
||||||
ThemeColorSelectorDialog(
|
ThemeColorSelectorDialog(
|
||||||
selectedColor = state.themeState.color,
|
selectedColor = state.themeState.color,
|
||||||
@@ -341,13 +280,12 @@ fun GeneralSettingsScreen(
|
|||||||
|
|
||||||
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
|
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
|
||||||
isPro = isPro,
|
isPro = isPro,
|
||||||
monitorMode = MonitorMode.AUTOMATIC,
|
|
||||||
scannerMode = ScannerMode.BALANCED,
|
|
||||||
showConnectedNotification = true,
|
showConnectedNotification = true,
|
||||||
keepNotificationAfterDisconnect = false,
|
keepNotificationAfterDisconnect = false,
|
||||||
isOffloadedFilteringDisabled = false,
|
isOffloadedFilteringDisabled = false,
|
||||||
isOffloadedBatchingDisabled = false,
|
isOffloadedBatchingDisabled = false,
|
||||||
useIndirectScanResultCallback = false,
|
useIndirectScanResultCallback = false,
|
||||||
|
hideUnmatchedDevices = false,
|
||||||
themeState = ThemeState(),
|
themeState = ThemeState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -357,14 +295,12 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
|
|||||||
GeneralSettingsScreen(
|
GeneralSettingsScreen(
|
||||||
state = previewGeneralState(isPro = true),
|
state = previewGeneralState(isPro = true),
|
||||||
onNavigateUp = {},
|
onNavigateUp = {},
|
||||||
onMonitorModeSelected = {},
|
|
||||||
onScannerModeSelected = {},
|
|
||||||
onShowConnectedNotificationChanged = {},
|
onShowConnectedNotificationChanged = {},
|
||||||
onKeepNotificationAfterDisconnectChanged = {},
|
onKeepNotificationAfterDisconnectChanged = {},
|
||||||
onDebugSettings = {},
|
|
||||||
onOffloadedFilteringDisabledChanged = {},
|
onOffloadedFilteringDisabledChanged = {},
|
||||||
onOffloadedBatchingDisabledChanged = {},
|
onOffloadedBatchingDisabledChanged = {},
|
||||||
onUseIndirectScanResultCallbackChanged = {},
|
onUseIndirectScanResultCallbackChanged = {},
|
||||||
|
onHideUnmatchedDevicesChanged = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,61 +310,11 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
|
|||||||
GeneralSettingsScreen(
|
GeneralSettingsScreen(
|
||||||
state = previewGeneralState(isPro = false),
|
state = previewGeneralState(isPro = false),
|
||||||
onNavigateUp = {},
|
onNavigateUp = {},
|
||||||
onMonitorModeSelected = {},
|
|
||||||
onScannerModeSelected = {},
|
|
||||||
onShowConnectedNotificationChanged = {},
|
onShowConnectedNotificationChanged = {},
|
||||||
onKeepNotificationAfterDisconnectChanged = {},
|
onKeepNotificationAfterDisconnectChanged = {},
|
||||||
onDebugSettings = {},
|
|
||||||
onOffloadedFilteringDisabledChanged = {},
|
onOffloadedFilteringDisabledChanged = {},
|
||||||
onOffloadedBatchingDisabledChanged = {},
|
onOffloadedBatchingDisabledChanged = {},
|
||||||
onUseIndirectScanResultCallbackChanged = {},
|
onUseIndirectScanResultCallbackChanged = {},
|
||||||
)
|
onHideUnmatchedDevicesChanged = {},
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun <T> ListPreferenceDialog(
|
|
||||||
title: String,
|
|
||||||
entries: List<T>,
|
|
||||||
selectedEntry: T,
|
|
||||||
onEntrySelected: (T) -> Unit,
|
|
||||||
entryLabel: @Composable (T) -> String,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(text = title) },
|
|
||||||
text = {
|
|
||||||
Column(Modifier.selectableGroup()) {
|
|
||||||
entries.forEach { entry ->
|
|
||||||
val isSelected = entry == selectedEntry
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.selectable(
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = { onEntrySelected(entry) },
|
|
||||||
role = Role.RadioButton,
|
|
||||||
)
|
|
||||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = null,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = entryLabel(entry),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(text = stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-28
@@ -1,7 +1,6 @@
|
|||||||
package eu.darken.capod.main.ui.settings.general
|
package eu.darken.capod.main.ui.settings.general
|
||||||
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
@@ -14,7 +13,6 @@ import eu.darken.capod.common.theming.ThemeStyle
|
|||||||
import eu.darken.capod.common.uix.ViewModel4
|
import eu.darken.capod.common.uix.ViewModel4
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
|
||||||
import eu.darken.capod.main.core.themeState
|
import eu.darken.capod.main.core.themeState
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -31,13 +29,12 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
data class State(
|
data class State(
|
||||||
val isPro: Boolean,
|
val isPro: Boolean,
|
||||||
val monitorMode: MonitorMode,
|
|
||||||
val scannerMode: ScannerMode,
|
|
||||||
val showConnectedNotification: Boolean,
|
val showConnectedNotification: Boolean,
|
||||||
val keepNotificationAfterDisconnect: Boolean,
|
val keepNotificationAfterDisconnect: Boolean,
|
||||||
val isOffloadedFilteringDisabled: Boolean,
|
val isOffloadedFilteringDisabled: Boolean,
|
||||||
val isOffloadedBatchingDisabled: Boolean,
|
val isOffloadedBatchingDisabled: Boolean,
|
||||||
val useIndirectScanResultCallback: Boolean,
|
val useIndirectScanResultCallback: Boolean,
|
||||||
|
val hideUnmatchedDevices: Boolean,
|
||||||
val themeState: ThemeState,
|
val themeState: ThemeState,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,13 +42,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
val state = combine(
|
val state = combine(
|
||||||
combine(
|
combine(
|
||||||
generalSettings.monitorMode.flow,
|
|
||||||
generalSettings.scannerMode.flow,
|
|
||||||
generalSettings.useExtraMonitorNotification.flow,
|
generalSettings.useExtraMonitorNotification.flow,
|
||||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||||
) { monitorMode, scannerMode, showNotif, keepNotif ->
|
) { showNotif, keepNotif ->
|
||||||
@Suppress("USELESS_CAST")
|
@Suppress("USELESS_CAST")
|
||||||
arrayOf<Any>(monitorMode as Any, scannerMode as Any, showNotif as Any, keepNotif as Any)
|
arrayOf<Any>(showNotif as Any, keepNotif as Any)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||||
@@ -63,30 +58,20 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
},
|
},
|
||||||
generalSettings.themeState,
|
generalSettings.themeState,
|
||||||
isPro,
|
isPro,
|
||||||
) { general, compat, themeState, isPro ->
|
generalSettings.hideUnmatchedDevices.flow,
|
||||||
|
) { general, compat, themeState, isPro, hideUnmatched ->
|
||||||
State(
|
State(
|
||||||
isPro = isPro,
|
isPro = isPro,
|
||||||
monitorMode = general[0] as MonitorMode,
|
showConnectedNotification = general[0] as Boolean,
|
||||||
scannerMode = general[1] as ScannerMode,
|
keepNotificationAfterDisconnect = general[1] as Boolean,
|
||||||
showConnectedNotification = general[2] as Boolean,
|
|
||||||
keepNotificationAfterDisconnect = general[3] as Boolean,
|
|
||||||
isOffloadedFilteringDisabled = compat[0] as Boolean,
|
isOffloadedFilteringDisabled = compat[0] as Boolean,
|
||||||
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
||||||
useIndirectScanResultCallback = compat[2] as Boolean,
|
useIndirectScanResultCallback = compat[2] as Boolean,
|
||||||
|
hideUnmatchedDevices = hideUnmatched,
|
||||||
themeState = themeState,
|
themeState = themeState,
|
||||||
)
|
)
|
||||||
}.asLiveState()
|
}.asLiveState()
|
||||||
|
|
||||||
fun setMonitorMode(mode: MonitorMode) {
|
|
||||||
log(TAG, INFO) { "setMonitorMode($mode)" }
|
|
||||||
generalSettings.monitorMode.valueBlocking = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setScannerMode(mode: ScannerMode) {
|
|
||||||
log(TAG, INFO) { "setScannerMode($mode)" }
|
|
||||||
generalSettings.scannerMode.valueBlocking = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShowConnectedNotification(enabled: Boolean) {
|
fun setShowConnectedNotification(enabled: Boolean) {
|
||||||
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
|
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
|
||||||
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
|
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
|
||||||
@@ -112,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
|
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setHideUnmatchedDevices(enabled: Boolean) {
|
||||||
|
log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" }
|
||||||
|
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
|
||||||
|
}
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) = launch {
|
fun setThemeMode(mode: ThemeMode) = launch {
|
||||||
log(TAG, INFO) { "setThemeMode($mode)" }
|
log(TAG, INFO) { "setThemeMode($mode)" }
|
||||||
if (isPro.first()) {
|
if (isPro.first()) {
|
||||||
@@ -144,11 +134,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
|||||||
navTo(Nav.Main.Upgrade)
|
navTo(Nav.Main.Upgrade)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToDebugSettings() {
|
|
||||||
log(TAG, INFO) { "goToDebugSettings()" }
|
|
||||||
navTo(Nav.Settings.Debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Settings", "General", "VM")
|
private val TAG = logTag("Settings", "General", "VM")
|
||||||
}
|
}
|
||||||
|
|||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package eu.darken.capod.main.ui.settings.general.debug
|
|
||||||
|
|
||||||
import androidx.navigation3.runtime.EntryProviderScope
|
|
||||||
import androidx.navigation3.runtime.NavKey
|
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import dagger.multibindings.IntoSet
|
|
||||||
import eu.darken.capod.common.navigation.Nav
|
|
||||||
import eu.darken.capod.common.navigation.NavigationEntry
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class DebugSettingsNavigation @Inject constructor() : NavigationEntry {
|
|
||||||
override fun EntryProviderScope<NavKey>.setup() {
|
|
||||||
entry<Nav.Settings.Debug> { DebugSettingsScreenHost() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
abstract class Mod {
|
|
||||||
@Binds
|
|
||||||
@IntoSet
|
|
||||||
abstract fun bind(entry: DebugSettingsNavigation): NavigationEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-136
@@ -1,136 +0,0 @@
|
|||||||
package eu.darken.capod.main.ui.settings.general.debug
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
|
||||||
import androidx.compose.material.icons.twotone.BugReport
|
|
||||||
import androidx.compose.material.icons.twotone.DataArray
|
|
||||||
import androidx.compose.material.icons.twotone.DevicesOther
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import eu.darken.capod.common.error.ErrorEventHandler
|
|
||||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
|
||||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DebugSettingsScreenHost(vm: DebugSettingsViewModel = hiltViewModel()) {
|
|
||||||
ErrorEventHandler(vm)
|
|
||||||
NavigationEventHandler(vm)
|
|
||||||
|
|
||||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
|
||||||
state?.let {
|
|
||||||
DebugSettingsScreen(
|
|
||||||
state = it,
|
|
||||||
onNavigateUp = { vm.navUp() },
|
|
||||||
onDebugModeChanged = { enabled -> vm.setDebugModeEnabled(enabled) },
|
|
||||||
onShowFakeDataChanged = { enabled -> vm.setShowFakeData(enabled) },
|
|
||||||
onShowUnfilteredChanged = { enabled -> vm.setShowUnfiltered(enabled) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DebugSettingsScreen(
|
|
||||||
state: DebugSettingsViewModel.State,
|
|
||||||
onNavigateUp: () -> Unit,
|
|
||||||
onDebugModeChanged: (Boolean) -> Unit,
|
|
||||||
onShowFakeDataChanged: (Boolean) -> Unit,
|
|
||||||
onShowUnfilteredChanged: (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(text = stringResource(R.string.settings_debug_label)) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onNavigateUp) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = stringResource(R.string.settings_debug_mode_label),
|
|
||||||
subtitle = stringResource(R.string.settings_debug_mode_description),
|
|
||||||
icon = Icons.TwoTone.BugReport,
|
|
||||||
onClick = { onDebugModeChanged(!state.isDebugModeEnabled) },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = state.isDebugModeEnabled,
|
|
||||||
onCheckedChange = onDebugModeChanged,
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = stringResource(R.string.settings_fake_data_label),
|
|
||||||
subtitle = stringResource(R.string.settings_fake_data_description),
|
|
||||||
icon = Icons.TwoTone.DataArray,
|
|
||||||
onClick = { onShowFakeDataChanged(!state.showFakeData) },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = state.showFakeData,
|
|
||||||
onCheckedChange = onShowFakeDataChanged,
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
SettingsBaseItem(
|
|
||||||
title = stringResource(R.string.settings_blescanner_unfiltered_label),
|
|
||||||
subtitle = stringResource(R.string.settings_blescanner_unfiltered_description),
|
|
||||||
icon = Icons.TwoTone.DevicesOther,
|
|
||||||
onClick = { onShowUnfilteredChanged(!state.showUnfiltered) },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = state.showUnfiltered,
|
|
||||||
onCheckedChange = onShowUnfilteredChanged,
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview2
|
|
||||||
@Composable
|
|
||||||
private fun DebugSettingsScreenPreview() = PreviewWrapper {
|
|
||||||
DebugSettingsScreen(
|
|
||||||
state = DebugSettingsViewModel.State(
|
|
||||||
isDebugModeEnabled = true,
|
|
||||||
showFakeData = false,
|
|
||||||
showUnfiltered = false,
|
|
||||||
),
|
|
||||||
onNavigateUp = {},
|
|
||||||
onDebugModeChanged = {},
|
|
||||||
onShowFakeDataChanged = {},
|
|
||||||
onShowUnfilteredChanged = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
package eu.darken.capod.main.ui.settings.general.debug
|
|
||||||
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
|
|
||||||
import eu.darken.capod.common.uix.ViewModel4
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import javax.inject.Inject
|
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class DebugSettingsViewModel @Inject constructor(
|
|
||||||
dispatcherProvider: DispatcherProvider,
|
|
||||||
private val debugSettings: DebugSettings,
|
|
||||||
) : ViewModel4(dispatcherProvider) {
|
|
||||||
|
|
||||||
data class State(
|
|
||||||
val isDebugModeEnabled: Boolean,
|
|
||||||
val showFakeData: Boolean,
|
|
||||||
val showUnfiltered: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
val state = combine(
|
|
||||||
debugSettings.isDebugModeEnabled.flow,
|
|
||||||
debugSettings.showFakeData.flow,
|
|
||||||
debugSettings.showUnfiltered.flow,
|
|
||||||
) { debugMode, fakeData, unfiltered ->
|
|
||||||
State(
|
|
||||||
isDebugModeEnabled = debugMode,
|
|
||||||
showFakeData = fakeData,
|
|
||||||
showUnfiltered = unfiltered,
|
|
||||||
)
|
|
||||||
}.asLiveState()
|
|
||||||
|
|
||||||
fun setDebugModeEnabled(enabled: Boolean) {
|
|
||||||
log(TAG, INFO) { "setDebugModeEnabled($enabled)" }
|
|
||||||
debugSettings.isDebugModeEnabled.valueBlocking = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShowFakeData(enabled: Boolean) {
|
|
||||||
log(TAG, INFO) { "setShowFakeData($enabled)" }
|
|
||||||
debugSettings.showFakeData.valueBlocking = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShowUnfiltered(enabled: Boolean) {
|
|
||||||
log(TAG, INFO) { "setShowUnfiltered($enabled)" }
|
|
||||||
debugSettings.showUnfiltered.valueBlocking = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("Settings", "Debug", "VM")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.annotation.Keep
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.LocalSize
|
import androidx.glance.LocalSize
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
|
|||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.common.upgrade.isPro
|
import eu.darken.capod.common.upgrade.isPro
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
class AncGlanceWidget : GlanceAppWidget() {
|
class AncGlanceWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
@@ -43,17 +45,15 @@ class AncGlanceWidget : GlanceAppWidget() {
|
|||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val ep: AncWidgetEntryPoint
|
val ep: AncWidgetEntryPoint
|
||||||
val appWidgetId: Int
|
val appWidgetId: Int
|
||||||
val initialIsPro: Boolean
|
|
||||||
val initialProfileId: String?
|
|
||||||
val cachedDevice: PodDevice?
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
|
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
|
||||||
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||||
initialIsPro = ep.upgradeRepo().isPro()
|
ep.widgetSettings().migrateLegacyConfigIfNeeded(
|
||||||
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
appWidgetId,
|
||||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||||
provideContent {
|
provideContent {
|
||||||
@@ -73,26 +73,40 @@ class AncGlanceWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provideContent {
|
provideContent {
|
||||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
|
||||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
|
||||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
|
||||||
val widthDp = LocalSize.current.width
|
val widthDp = LocalSize.current.width
|
||||||
val heightDp = LocalSize.current.height
|
val heightDp = LocalSize.current.height
|
||||||
|
|
||||||
val state = try {
|
// Glance keeps the content session alive and does not restart provideGlance()
|
||||||
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
// for every update(). Observe a widget-key-deduped device flow so visible state
|
||||||
val theme = WidgetTheme.fromBundle(
|
// changes update active sessions without recomposing on every BLE advertisement.
|
||||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
|
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
|
||||||
)
|
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
|
||||||
|
.getOrNull()
|
||||||
|
|
||||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
val state = if (config != null) {
|
||||||
|
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
|
||||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
|
||||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
.getOrDefault(false)
|
||||||
|
val initialDevice = remember(config.profileId) {
|
||||||
val profileLabel = profileId?.let { pid ->
|
config.profileId?.let { pid ->
|
||||||
profiles.firstOrNull { it.id == pid }?.label
|
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
|
||||||
|
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
val device by config.profileId
|
||||||
|
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||||
|
?.collectAsState(initial = initialDevice)
|
||||||
|
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||||
|
val profileLabel = config.profileId?.let { pid ->
|
||||||
|
runCatching {
|
||||||
|
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||||
|
}
|
||||||
|
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
|
||||||
|
|
||||||
val widthCells = getCellsForSize(widthDp.value.toInt())
|
val widthCells = getCellsForSize(widthDp.value.toInt())
|
||||||
val heightCells = getCellsForSize(heightDp.value.toInt())
|
val heightCells = getCellsForSize(heightDp.value.toInt())
|
||||||
@@ -108,14 +122,13 @@ class AncGlanceWidget : GlanceAppWidget() {
|
|||||||
AncWidgetRenderStateMapper.map(
|
AncWidgetRenderStateMapper.map(
|
||||||
context = context,
|
context = context,
|
||||||
device = device,
|
device = device,
|
||||||
theme = theme,
|
theme = config.theme,
|
||||||
isPro = isPro,
|
isPro = isPro,
|
||||||
hasConfiguredProfile = profileId != null,
|
hasConfiguredProfile = config.profileId != null,
|
||||||
profileLabel = profileLabel,
|
profileLabel = profileLabel,
|
||||||
layout = layout,
|
layout = layout,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
|
|
||||||
AncWidgetRenderState.Message(
|
AncWidgetRenderState.Message(
|
||||||
theme = WidgetTheme.DEFAULT,
|
theme = WidgetTheme.DEFAULT,
|
||||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||||
@@ -158,7 +171,7 @@ class AncGlanceWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
private fun getCellsForSize(size: Int): Int {
|
private fun getCellsForSize(size: Int): Int {
|
||||||
var n = 2
|
var n = 2
|
||||||
while (70 * n - 30 < size) {
|
while (70 * n - 30 <= size) {
|
||||||
++n
|
++n
|
||||||
}
|
}
|
||||||
return n - 1
|
return n - 1
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.annotation.Keep
|
|||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.action.ActionParameters
|
import androidx.glance.action.ActionParameters
|
||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.updateAll
|
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
@@ -30,6 +29,7 @@ class AncModeActionCallback : ActionCallback {
|
|||||||
fun aapConnectionManager(): AapConnectionManager
|
fun aapConnectionManager(): AapConnectionManager
|
||||||
fun widgetSettings(): WidgetSettings
|
fun widgetSettings(): WidgetSettings
|
||||||
fun deviceMonitor(): DeviceMonitor
|
fun deviceMonitor(): DeviceMonitor
|
||||||
|
fun widgetManager(): WidgetManager
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
@@ -53,7 +53,7 @@ class AncModeActionCallback : ActionCallback {
|
|||||||
|
|
||||||
val ep = EntryPointAccessors.fromApplication(context, AncModeCallbackEntryPoint::class.java)
|
val ep = EntryPointAccessors.fromApplication(context, AncModeCallbackEntryPoint::class.java)
|
||||||
|
|
||||||
val profileId = ep.widgetSettings().getWidgetProfile(widgetId)
|
val profileId = ep.widgetSettings().getWidgetConfig(widgetId).profileId
|
||||||
if (profileId == null) {
|
if (profileId == null) {
|
||||||
log(TAG, ERROR) { "onAction: no profile for widgetId=$widgetId" }
|
log(TAG, ERROR) { "onAction: no profile for widgetId=$widgetId" }
|
||||||
return
|
return
|
||||||
@@ -62,14 +62,14 @@ class AncModeActionCallback : ActionCallback {
|
|||||||
val device = ep.deviceMonitor().getDeviceForProfile(profileId)
|
val device = ep.deviceMonitor().getDeviceForProfile(profileId)
|
||||||
if (device == null) {
|
if (device == null) {
|
||||||
log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
|
log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
|
||||||
AncGlanceWidget().updateAll(context)
|
ep.widgetManager().refreshWidgets()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val address = device.address
|
val address = device.address
|
||||||
if (address == null) {
|
if (address == null) {
|
||||||
log(TAG, ERROR) { "onAction: device has no address" }
|
log(TAG, ERROR) { "onAction: device has no address" }
|
||||||
AncGlanceWidget().updateAll(context)
|
ep.widgetManager().refreshWidgets()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class AncModeActionCallback : ActionCallback {
|
|||||||
log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
|
log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
AncGlanceWidget().updateAll(context)
|
ep.widgetManager().refreshWidgets()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.darken.capod.R
|
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.main.ui.components.shortLabel
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.visibleAncModes
|
import eu.darken.capod.monitor.core.visibleAncModes
|
||||||
@@ -57,13 +58,21 @@ object AncWidgetRenderStateMapper {
|
|||||||
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
||||||
)
|
)
|
||||||
|
|
||||||
!device.isAapConnected -> AncWidgetRenderState.Message(
|
!device.isAapConnected -> {
|
||||||
theme = theme,
|
val secondaryRes = if (device.ble != null) {
|
||||||
resolvedBgColor = bgColor,
|
R.string.anc_widget_aap_not_connected_nearby_description
|
||||||
resolvedTextColor = textColor,
|
} else {
|
||||||
resolvedIconColor = iconColor,
|
R.string.anc_widget_aap_not_connected_not_nearby_description
|
||||||
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
|
}
|
||||||
)
|
AncWidgetRenderState.Message(
|
||||||
|
theme = theme,
|
||||||
|
resolvedBgColor = bgColor,
|
||||||
|
resolvedTextColor = textColor,
|
||||||
|
resolvedIconColor = iconColor,
|
||||||
|
primaryText = profileLabel ?: device.getLabel(context),
|
||||||
|
secondaryText = context.getString(secondaryRes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
!device.isAapReady -> AncWidgetRenderState.Message(
|
!device.isAapReady -> AncWidgetRenderState.Message(
|
||||||
theme = theme,
|
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.annotation.Keep
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.LocalSize
|
import androidx.glance.LocalSize
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
@@ -23,8 +24,14 @@ import eu.darken.capod.common.debug.logging.logTag
|
|||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.common.upgrade.isPro
|
import eu.darken.capod.common.upgrade.isPro
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.takeFor
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
@@ -38,31 +45,32 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
fun upgradeRepo(): UpgradeRepo
|
fun upgradeRepo(): UpgradeRepo
|
||||||
fun widgetSettings(): WidgetSettings
|
fun widgetSettings(): WidgetSettings
|
||||||
fun deviceProfilesRepo(): DeviceProfilesRepo
|
fun deviceProfilesRepo(): DeviceProfilesRepo
|
||||||
|
fun batteryEstimator(): BatteryEstimator
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val ep: WidgetEntryPoint
|
val ep: WidgetEntryPoint
|
||||||
val appWidgetId: Int
|
val appWidgetId: Int
|
||||||
val initialIsPro: Boolean
|
|
||||||
val initialProfileId: String?
|
|
||||||
val cachedDevice: PodDevice?
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
|
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
|
||||||
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||||
initialIsPro = ep.upgradeRepo().isPro()
|
ep.widgetSettings().migrateLegacyConfigIfNeeded(
|
||||||
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
appWidgetId,
|
||||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||||
provideContent {
|
provideContent {
|
||||||
|
val layout = BatteryLayout.forCells(getCellsForSize(LocalSize.current.width.value.toInt()))
|
||||||
GlanceWidgetContent(
|
GlanceWidgetContent(
|
||||||
state = WidgetRenderState.Message(
|
state = WidgetRenderState.Message(
|
||||||
theme = WidgetTheme.DEFAULT,
|
theme = WidgetTheme.DEFAULT,
|
||||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||||
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||||
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||||
|
layout = layout,
|
||||||
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
|
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
|
||||||
),
|
),
|
||||||
context = context,
|
context = context,
|
||||||
@@ -72,45 +80,65 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provideContent {
|
provideContent {
|
||||||
// Composable reads — must be outside try-catch
|
|
||||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
|
||||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
|
||||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
|
||||||
val widthDp = LocalSize.current.width
|
val widthDp = LocalSize.current.width
|
||||||
|
val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
|
||||||
|
|
||||||
val state = try {
|
// Glance keeps the content session alive and does not restart provideGlance()
|
||||||
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
// for every update(). Observe a widget-key-deduped device flow so visible state
|
||||||
val theme = WidgetTheme.fromBundle(
|
// changes update active sessions without recomposing on every BLE advertisement.
|
||||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
|
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
|
||||||
)
|
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
|
||||||
|
.getOrNull()
|
||||||
|
|
||||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
val state = if (config != null) {
|
||||||
|
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
|
||||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
|
||||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
.getOrDefault(false)
|
||||||
|
val initialDevice = remember(config.profileId) {
|
||||||
val profileLabel = profileId?.let { pid ->
|
config.profileId?.let { pid ->
|
||||||
profiles.firstOrNull { it.id == pid }?.label
|
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
|
||||||
|
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val device by config.profileId
|
||||||
|
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||||
|
?.collectAsState(initial = initialDevice)
|
||||||
|
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||||
|
// Empty while the monitor service isn't running — estimates simply stay hidden.
|
||||||
|
val rawEstimate by config.profileId
|
||||||
|
?.let { pid ->
|
||||||
|
remember(pid) { ep.batteryEstimator().estimates.map { it[pid] }.distinctUntilChanged() }
|
||||||
|
}
|
||||||
|
?.collectAsState(initial = null)
|
||||||
|
?: remember { androidx.compose.runtime.mutableStateOf<BatteryEstimate?>(null) }
|
||||||
|
val profileLabel = config.profileId?.let { pid ->
|
||||||
|
runCatching {
|
||||||
|
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||||
|
}
|
||||||
|
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
val isWide = getCellsForSize(widthDp.value.toInt()) >= 5
|
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
|
||||||
|
|
||||||
WidgetRenderStateMapper.map(
|
WidgetRenderStateMapper.map(
|
||||||
context = context,
|
context = context,
|
||||||
device = device,
|
device = device,
|
||||||
theme = theme,
|
theme = config.theme,
|
||||||
isPro = isPro,
|
isPro = isPro,
|
||||||
hasConfiguredProfile = profileId != null,
|
hasConfiguredProfile = config.profileId != null,
|
||||||
profileLabel = profileLabel,
|
profileLabel = profileLabel,
|
||||||
isWide = isWide,
|
layout = layout,
|
||||||
|
estimate = device?.let { d -> rawEstimate?.takeFor(d) },
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
|
|
||||||
WidgetRenderState.Message(
|
WidgetRenderState.Message(
|
||||||
theme = WidgetTheme.DEFAULT,
|
theme = WidgetTheme.DEFAULT,
|
||||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||||
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||||
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||||
|
layout = layout,
|
||||||
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
|
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -124,7 +152,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||||
)
|
).withLocalizedPreviewEstimates(context)
|
||||||
|
|
||||||
provideContent {
|
provideContent {
|
||||||
GlanceWidgetContent(state = previewState, context = context)
|
GlanceWidgetContent(state = previewState, context = context)
|
||||||
@@ -137,7 +165,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
*/
|
*/
|
||||||
private fun getCellsForSize(size: Int): Int {
|
private fun getCellsForSize(size: Int): Int {
|
||||||
var n = 2
|
var n = 2
|
||||||
while (70 * n - 30 < size) {
|
while (70 * n - 30 <= size) {
|
||||||
++n
|
++n
|
||||||
}
|
}
|
||||||
return n - 1
|
return n - 1
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -87,86 +88,51 @@ private fun DualPodPreview(
|
|||||||
val iconColor = Color(state.resolvedIconColor)
|
val iconColor = Color(state.resolvedIconColor)
|
||||||
val iconTint = ColorFilter.tint(iconColor)
|
val iconTint = ColorFilter.tint(iconColor)
|
||||||
|
|
||||||
if (state.isWide) {
|
when (state.layout) {
|
||||||
// Wide layout: left | case | right in a horizontal row
|
BatteryLayout.WIDE -> {
|
||||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Left pod
|
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = Modifier.padding(end = 12.dp))
|
||||||
PodItemRow(
|
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||||
icon = state.leftIcon,
|
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
|
||||||
percent = state.leftPercent,
|
}
|
||||||
charging = state.leftCharging,
|
DeviceLabel(
|
||||||
inEar = state.leftInEar,
|
label = state.deviceLabel,
|
||||||
|
visible = state.theme.showDeviceLabel,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
iconTint = iconTint,
|
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
||||||
iconSize = 40,
|
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
|
||||||
)
|
|
||||||
// Case
|
|
||||||
PodItemRow(
|
|
||||||
icon = state.caseIcon,
|
|
||||||
percent = state.casePercent,
|
|
||||||
charging = state.caseCharging,
|
|
||||||
inEar = false,
|
|
||||||
textColor = textColor,
|
|
||||||
iconTint = iconTint,
|
|
||||||
iconSize = 40,
|
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
|
||||||
)
|
|
||||||
// Right pod
|
|
||||||
PodItemRow(
|
|
||||||
icon = state.rightIcon,
|
|
||||||
percent = state.rightPercent,
|
|
||||||
charging = state.rightCharging,
|
|
||||||
inEar = state.rightInEar,
|
|
||||||
textColor = textColor,
|
|
||||||
iconTint = iconTint,
|
|
||||||
iconSize = 40,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DeviceLabel(
|
|
||||||
label = state.deviceLabel,
|
|
||||||
visible = state.theme.showDeviceLabel,
|
|
||||||
textColor = textColor,
|
|
||||||
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Compact layout: vertical stack
|
BatteryLayout.NARROW -> {
|
||||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||||
PodItemRow(
|
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, estimate = state.leftEstimate)
|
||||||
icon = state.leftIcon,
|
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, estimate = state.rightEstimate)
|
||||||
percent = state.leftPercent,
|
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
|
||||||
charging = state.leftCharging,
|
DeviceLabel(
|
||||||
inEar = state.leftInEar,
|
label = state.deviceLabel,
|
||||||
textColor = textColor,
|
visible = state.theme.showDeviceLabel,
|
||||||
iconTint = iconTint,
|
textColor = textColor,
|
||||||
)
|
)
|
||||||
PodItemRow(
|
}
|
||||||
icon = state.rightIcon,
|
}
|
||||||
percent = state.rightPercent,
|
|
||||||
charging = state.rightCharging,
|
BatteryLayout.TINY_COLUMN -> {
|
||||||
inEar = state.rightInEar,
|
WidgetContainer(
|
||||||
textColor = textColor,
|
bgColor = bgColor,
|
||||||
iconTint = iconTint,
|
modifier = modifier,
|
||||||
)
|
horizontalPadding = 4.dp,
|
||||||
PodItemRow(
|
verticalPadding = 0.dp,
|
||||||
icon = state.caseIcon,
|
) {
|
||||||
percent = state.casePercent,
|
TinyPodItem(state.leftIcon, state.leftPercent, textColor, iconTint)
|
||||||
charging = state.caseCharging,
|
TinyPodItem(state.rightIcon, state.rightPercent, textColor, iconTint)
|
||||||
inEar = false,
|
TinyPodItem(state.caseIcon, state.casePercent, textColor, iconTint)
|
||||||
textColor = textColor,
|
}
|
||||||
iconTint = iconTint,
|
|
||||||
)
|
|
||||||
DeviceLabel(
|
|
||||||
label = state.deviceLabel,
|
|
||||||
visible = state.theme.showDeviceLabel,
|
|
||||||
textColor = textColor,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,45 +146,61 @@ private fun SinglePodPreview(
|
|||||||
val textColor = Color(state.resolvedTextColor)
|
val textColor = Color(state.resolvedTextColor)
|
||||||
val iconTint = ColorFilter.tint(Color(state.resolvedIconColor))
|
val iconTint = ColorFilter.tint(Color(state.resolvedIconColor))
|
||||||
|
|
||||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
when (state.layout) {
|
||||||
Row(
|
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
|
||||||
horizontalArrangement = Arrangement.Center,
|
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Row(
|
||||||
) {
|
horizontalArrangement = Arrangement.Center,
|
||||||
Image(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
painter = painterResource(state.batteryIcon),
|
) {
|
||||||
contentDescription = null,
|
Image(
|
||||||
modifier = Modifier.size(20.dp),
|
painter = painterResource(state.batteryIcon),
|
||||||
colorFilter = iconTint,
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(20.dp),
|
||||||
Text(
|
colorFilter = iconTint,
|
||||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
)
|
||||||
fontSize = 12.sp,
|
Text(
|
||||||
color = textColor,
|
text = formatPercent(state.percent) + estimateSuffix(state.estimate),
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
fontSize = 12.sp,
|
||||||
)
|
color = textColor,
|
||||||
if (state.charging) {
|
maxLines = 1,
|
||||||
Image(
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
)
|
||||||
contentDescription = null,
|
if (state.charging) {
|
||||||
modifier = Modifier.size(20.dp),
|
Image(
|
||||||
colorFilter = iconTint,
|
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||||
)
|
contentDescription = null,
|
||||||
}
|
modifier = Modifier.size(20.dp),
|
||||||
if (state.worn) {
|
colorFilter = iconTint,
|
||||||
Image(
|
)
|
||||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
}
|
||||||
contentDescription = null,
|
if (state.worn) {
|
||||||
modifier = Modifier.size(20.dp),
|
Image(
|
||||||
colorFilter = iconTint,
|
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,
|
BatteryLayout.TINY_COLUMN -> {
|
||||||
visible = state.theme.showDeviceLabel,
|
WidgetContainer(
|
||||||
textColor = textColor,
|
bgColor = bgColor,
|
||||||
)
|
modifier = modifier,
|
||||||
|
horizontalPadding = 4.dp,
|
||||||
|
verticalPadding = 0.dp,
|
||||||
|
) {
|
||||||
|
TinyPodItem(state.batteryIcon, state.percent, textColor, iconTint)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,17 +211,25 @@ private fun MessagePreview(
|
|||||||
) {
|
) {
|
||||||
val bgColor = Color(state.resolvedBgColor)
|
val bgColor = Color(state.resolvedBgColor)
|
||||||
val textColor = Color(state.resolvedTextColor)
|
val textColor = Color(state.resolvedTextColor)
|
||||||
|
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||||
|
|
||||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
WidgetContainer(
|
||||||
|
bgColor = bgColor,
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalPadding = if (isCompact) 4.dp else 16.dp,
|
||||||
|
verticalPadding = if (isCompact) 0.dp else 4.dp,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.primaryText,
|
text = state.primaryText,
|
||||||
fontSize = 12.sp,
|
fontSize = if (isCompact) 10.sp else 12.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
if (state.secondaryText != null) {
|
if (!isCompact && state.secondaryText != null) {
|
||||||
Text(
|
Text(
|
||||||
text = state.secondaryText,
|
text = state.secondaryText,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
@@ -260,11 +250,17 @@ private fun LoadingPreview(
|
|||||||
) {
|
) {
|
||||||
val bgColor = Color(state.resolvedBgColor)
|
val bgColor = Color(state.resolvedBgColor)
|
||||||
val textColor = Color(state.resolvedTextColor)
|
val textColor = Color(state.resolvedTextColor)
|
||||||
|
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||||
|
|
||||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
WidgetContainer(
|
||||||
|
bgColor = bgColor,
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalPadding = if (isCompact) 4.dp else 16.dp,
|
||||||
|
verticalPadding = if (isCompact) 0.dp else 4.dp,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "…",
|
text = "…",
|
||||||
fontSize = 12.sp,
|
fontSize = if (isCompact) 10.sp else 12.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -277,13 +273,15 @@ private fun LoadingPreview(
|
|||||||
private fun WidgetContainer(
|
private fun WidgetContainer(
|
||||||
bgColor: Color,
|
bgColor: Color,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
horizontalPadding: Dp = 16.dp,
|
||||||
|
verticalPadding: Dp = 4.dp,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(bgColor)
|
.background(bgColor)
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
@@ -300,41 +298,84 @@ private fun PodItemRow(
|
|||||||
textColor: Color,
|
textColor: Color,
|
||||||
iconTint: ColorFilter,
|
iconTint: ColorFilter,
|
||||||
iconSize: Int = 20,
|
iconSize: Int = 20,
|
||||||
|
estimate: String? = null,
|
||||||
|
estimateBelow: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(icon),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(iconSize.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatPercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
if (charging) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (inEar) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (estimateBelow && estimate != null) {
|
||||||
|
Text(
|
||||||
|
text = estimate,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TinyPodItem(
|
||||||
|
icon: Int,
|
||||||
|
percent: Float,
|
||||||
|
textColor: Color,
|
||||||
|
iconTint: ColorFilter,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(icon),
|
painter = painterResource(icon),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(iconSize.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(percent.toBatteryOrNull()),
|
text = formatPercent(percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(horizontal = 4.dp),
|
maxLines = 1,
|
||||||
|
modifier = Modifier.padding(start = 2.dp),
|
||||||
)
|
)
|
||||||
if (charging) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
colorFilter = iconTint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (inEar) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
colorFilter = iconTint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,18 +398,37 @@ private fun DeviceLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatPercent(percent: Float?): String {
|
private fun formatPercent(percent: Float): String =
|
||||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
|
|
||||||
|
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDualTiny11() = PreviewWrapper {
|
||||||
|
ComposeWidgetPreview(
|
||||||
|
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviewDualCompact() = PreviewWrapper {
|
private fun PreviewDualTinyTall() = PreviewWrapper {
|
||||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod())
|
ComposeWidgetPreview(
|
||||||
|
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
|
||||||
|
modifier = Modifier.size(width = 40.dp, height = 110.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDualNarrow() = PreviewWrapper {
|
||||||
|
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.NARROW))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviewDualWide() = PreviewWrapper {
|
private fun PreviewDualWide() = PreviewWrapper {
|
||||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(isWide = true))
|
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.WIDE))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package eu.darken.capod.main.ui.widget
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
@@ -26,7 +27,7 @@ import androidx.glance.text.TextAlign
|
|||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.main.ui.MainActivity
|
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
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -53,31 +54,47 @@ private fun GlanceDualPod(
|
|||||||
state: WidgetRenderState.DualPod,
|
state: WidgetRenderState.DualPod,
|
||||||
clickModifier: GlanceModifier,
|
clickModifier: GlanceModifier,
|
||||||
) {
|
) {
|
||||||
val textStyle = TextStyle(
|
|
||||||
color = fixedColor(state.resolvedTextColor),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
||||||
|
|
||||||
if (state.isWide) {
|
when (state.layout) {
|
||||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
BatteryLayout.WIDE -> {
|
||||||
Row(
|
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||||
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
|
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
|
||||||
) {
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
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)
|
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, 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, estimate = state.rightEstimate, estimateBelow = true)
|
||||||
|
}
|
||||||
|
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||||
}
|
}
|
||||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
BatteryLayout.NARROW -> {
|
||||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
|
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
|
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, estimate = state.leftEstimate)
|
||||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, estimate = state.rightEstimate)
|
||||||
|
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,61 @@ private fun GlanceSinglePod(
|
|||||||
state: WidgetRenderState.SinglePod,
|
state: WidgetRenderState.SinglePod,
|
||||||
clickModifier: GlanceModifier,
|
clickModifier: GlanceModifier,
|
||||||
) {
|
) {
|
||||||
val textStyle = TextStyle(
|
|
||||||
color = fixedColor(state.resolvedTextColor),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
||||||
|
|
||||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
when (state.layout) {
|
||||||
Row(
|
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
|
||||||
modifier = GlanceModifier.fillMaxWidth(),
|
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Row(
|
||||||
) {
|
modifier = GlanceModifier.fillMaxWidth(),
|
||||||
Image(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
provider = ImageProvider(state.batteryIcon),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
contentDescription = null,
|
) {
|
||||||
modifier = GlanceModifier.size(20.dp),
|
Image(
|
||||||
colorFilter = iconTint,
|
provider = ImageProvider(state.batteryIcon),
|
||||||
)
|
contentDescription = null,
|
||||||
Text(
|
modifier = GlanceModifier.size(20.dp),
|
||||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
colorFilter = iconTint,
|
||||||
style = textStyle,
|
)
|
||||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
Text(
|
||||||
)
|
text = formatGlancePercent(state.percent) + estimateSuffix(state.estimate),
|
||||||
if (state.charging) {
|
style = textStyle,
|
||||||
Image(
|
maxLines = 1,
|
||||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||||
contentDescription = null,
|
)
|
||||||
modifier = GlanceModifier.size(20.dp),
|
if (state.charging) {
|
||||||
colorFilter = iconTint,
|
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),
|
BatteryLayout.TINY_COLUMN -> {
|
||||||
contentDescription = null,
|
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||||
modifier = GlanceModifier.size(20.dp),
|
GlanceWidgetRoot(
|
||||||
colorFilter = iconTint,
|
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 +167,29 @@ private fun GlanceMessage(
|
|||||||
state: WidgetRenderState.Message,
|
state: WidgetRenderState.Message,
|
||||||
clickModifier: GlanceModifier,
|
clickModifier: GlanceModifier,
|
||||||
) {
|
) {
|
||||||
val textStyle = TextStyle(
|
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||||
color = fixedColor(state.resolvedTextColor),
|
val fontSize = if (isCompact) 10.sp else 12.sp
|
||||||
fontSize = 12.sp,
|
val hPad = if (isCompact) 4.dp else 16.dp
|
||||||
fontWeight = FontWeight.Bold,
|
val vPad = if (isCompact) 0.dp else 4.dp
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
|
|
||||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
GlanceWidgetRoot(
|
||||||
|
bgColor = state.resolvedBgColor,
|
||||||
|
clickModifier = clickModifier,
|
||||||
|
horizontalPadding = hPad,
|
||||||
|
verticalPadding = vPad,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.primaryText,
|
text = state.primaryText,
|
||||||
style = textStyle,
|
style = TextStyle(
|
||||||
|
color = fixedColor(state.resolvedTextColor),
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
modifier = GlanceModifier.fillMaxWidth(),
|
modifier = GlanceModifier.fillMaxWidth(),
|
||||||
|
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
|
||||||
)
|
)
|
||||||
if (state.secondaryText != null) {
|
if (!isCompact && state.secondaryText != null) {
|
||||||
Text(
|
Text(
|
||||||
text = state.secondaryText,
|
text = state.secondaryText,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -169,12 +209,18 @@ private fun GlanceLoading(
|
|||||||
state: WidgetRenderState.Loading,
|
state: WidgetRenderState.Loading,
|
||||||
clickModifier: GlanceModifier,
|
clickModifier: GlanceModifier,
|
||||||
) {
|
) {
|
||||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||||
|
GlanceWidgetRoot(
|
||||||
|
bgColor = state.resolvedBgColor,
|
||||||
|
clickModifier = clickModifier,
|
||||||
|
horizontalPadding = if (isCompact) 4.dp else 16.dp,
|
||||||
|
verticalPadding = if (isCompact) 0.dp else 4.dp,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "…",
|
text = "…",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = fixedColor(state.resolvedTextColor),
|
color = fixedColor(state.resolvedTextColor),
|
||||||
fontSize = 12.sp,
|
fontSize = if (isCompact) 10.sp else 12.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
),
|
),
|
||||||
@@ -187,13 +233,15 @@ private fun GlanceLoading(
|
|||||||
private fun GlanceWidgetRoot(
|
private fun GlanceWidgetRoot(
|
||||||
bgColor: Int,
|
bgColor: Int,
|
||||||
clickModifier: GlanceModifier,
|
clickModifier: GlanceModifier,
|
||||||
|
horizontalPadding: Dp = 16.dp,
|
||||||
|
verticalPadding: Dp = 4.dp,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = clickModifier
|
modifier = clickModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(fixedColor(bgColor))
|
.background(fixedColor(bgColor))
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -210,6 +258,60 @@ private fun GlancePodItem(
|
|||||||
textStyle: TextStyle,
|
textStyle: TextStyle,
|
||||||
iconTint: ColorFilter,
|
iconTint: ColorFilter,
|
||||||
iconSize: Int = 20,
|
iconSize: Int = 20,
|
||||||
|
estimate: String? = null,
|
||||||
|
estimateBelow: Boolean = false,
|
||||||
|
modifier: GlanceModifier = GlanceModifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(icon),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = GlanceModifier.size(iconSize.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatGlancePercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
|
||||||
|
style = textStyle,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
if (charging) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = GlanceModifier.size(20.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (inEar) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = GlanceModifier.size(20.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (estimateBelow && estimate != null) {
|
||||||
|
Text(
|
||||||
|
text = estimate,
|
||||||
|
style = textStyle,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GlanceTinyPodItem(
|
||||||
|
icon: Int,
|
||||||
|
percent: Float,
|
||||||
|
textStyle: TextStyle,
|
||||||
|
iconTint: ColorFilter,
|
||||||
modifier: GlanceModifier = GlanceModifier,
|
modifier: GlanceModifier = GlanceModifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -219,30 +321,15 @@ private fun GlancePodItem(
|
|||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(icon),
|
provider = ImageProvider(icon),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = GlanceModifier.size(iconSize.dp),
|
modifier = GlanceModifier.size(14.dp),
|
||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
text = formatGlancePercent(percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
maxLines = 1,
|
||||||
|
modifier = GlanceModifier.padding(start = 4.dp),
|
||||||
)
|
)
|
||||||
if (charging) {
|
|
||||||
Image(
|
|
||||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = GlanceModifier.size(20.dp),
|
|
||||||
colorFilter = iconTint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (inEar) {
|
|
||||||
Image(
|
|
||||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = GlanceModifier.size(20.dp),
|
|
||||||
colorFilter = iconTint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +354,7 @@ private fun GlanceDeviceLabel(
|
|||||||
|
|
||||||
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||||
|
|
||||||
private fun formatGlancePercent(percent: Float?): String {
|
private fun formatGlancePercent(percent: Float): String =
|
||||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
}
|
|
||||||
|
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -507,7 +507,7 @@ private fun WidgetConfigPreview(
|
|||||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
|
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
|
||||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
|
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
|
||||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
|
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
|
||||||
).copy(deviceLabel = deviceLabel)
|
).withLocalizedPreviewEstimates(context).copy(deviceLabel = deviceLabel)
|
||||||
}
|
}
|
||||||
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
|
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,19 +47,21 @@ class WidgetConfigurationViewModel @Inject constructor(
|
|||||||
|
|
||||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
log(TAG) { "Invalid widget ID" }
|
log(TAG) { "Invalid widget ID" }
|
||||||
|
} else {
|
||||||
|
widgetSettings.migrateLegacyConfigIfNeeded(widgetId, appWidgetManager.getAppWidgetOptions(widgetId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId))
|
private val initialConfig: WidgetConfig = run {
|
||||||
|
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetConfig()
|
||||||
private val initialTheme: WidgetTheme = run {
|
widgetSettings.getWidgetConfig(widgetId)
|
||||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetTheme.DEFAULT
|
|
||||||
WidgetTheme.fromBundle(appWidgetManager.getAppWidgetOptions(widgetId))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialTheme) == null)
|
private val selectedProfile = MutableStateFlow(initialConfig.profileId)
|
||||||
|
|
||||||
private val currentTheme = MutableStateFlow(initialTheme)
|
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialConfig.theme) == null)
|
||||||
|
|
||||||
|
private val currentTheme = MutableStateFlow(initialConfig.theme)
|
||||||
|
|
||||||
private val visibleProfiles = deviceProfilesRepo.profiles.map { profiles ->
|
private val visibleProfiles = deviceProfilesRepo.profiles.map { profiles ->
|
||||||
if (isAncWidget) profiles.filter { it.model.features.hasAncControl } else profiles
|
if (isAncWidget) profiles.filter { it.model.features.hasAncControl } else profiles
|
||||||
@@ -152,18 +154,16 @@ class WidgetConfigurationViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun confirmSelection() {
|
fun confirmSelection() {
|
||||||
val selectedProfile = selectedProfile.value
|
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
if (selectedProfile != null) {
|
log(TAG, INFO) { "confirmSelection: invalid widget ID, skipping save" }
|
||||||
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
|
return
|
||||||
widgetSettings.saveWidgetProfile(widgetId, selectedProfile)
|
|
||||||
}
|
}
|
||||||
|
val config = WidgetConfig(
|
||||||
// Save theme to AppWidgetOptions bundle
|
profileId = selectedProfile.value,
|
||||||
val theme = currentTheme.value
|
theme = currentTheme.value,
|
||||||
log(TAG, INFO) { "confirmSelection: saving theme=$theme" }
|
)
|
||||||
val options = appWidgetManager.getAppWidgetOptions(widgetId)
|
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, config=$config)" }
|
||||||
theme.toBundle(options)
|
widgetSettings.saveWidgetConfig(widgetId, config)
|
||||||
appWidgetManager.updateAppWidgetOptions(widgetId, options)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package eu.darken.capod.main.ui.widget
|
||||||
|
|
||||||
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
internal fun DeviceMonitor.widgetDeviceFlow(profileId: String): Flow<PodDevice?> = devices
|
||||||
|
.map { devices -> devices.firstOrNull { it.profileId == profileId } }
|
||||||
|
.distinctUntilChangedBy { it?.toWidgetKey() }
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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 batteryEstimateEnabled: 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,
|
||||||
|
batteryEstimateEnabled = batteryEstimateEnabled,
|
||||||
|
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
|
package eu.darken.capod.main.ui.widget
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -16,9 +23,63 @@ class WidgetManager @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun refreshWidgets() {
|
suspend fun refreshWidgets() {
|
||||||
log(TAG, VERBOSE) { "refreshWidgets()" }
|
val manager = GlanceAppWidgetManager(context)
|
||||||
BatteryGlanceWidget().updateAll(context)
|
refreshWidget(
|
||||||
AncGlanceWidget().updateAll(context)
|
name = "battery",
|
||||||
|
widget = BatteryGlanceWidget(),
|
||||||
|
providerClass = BatteryGlanceWidget::class.java,
|
||||||
|
receiverClass = WidgetProvider::class.java,
|
||||||
|
manager = manager,
|
||||||
|
)
|
||||||
|
refreshWidget(
|
||||||
|
name = "anc",
|
||||||
|
widget = AncGlanceWidget(),
|
||||||
|
providerClass = AncGlanceWidget::class.java,
|
||||||
|
receiverClass = AncWidgetProvider::class.java,
|
||||||
|
manager = manager,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T : GlanceAppWidget> refreshWidget(
|
||||||
|
name: String,
|
||||||
|
widget: T,
|
||||||
|
providerClass: Class<T>,
|
||||||
|
receiverClass: Class<*>,
|
||||||
|
manager: GlanceAppWidgetManager,
|
||||||
|
) {
|
||||||
|
val ids = manager.getGlanceIds(providerClass).ifEmpty {
|
||||||
|
val appWidgetIds = AppWidgetManager.getInstance(context)
|
||||||
|
.getAppWidgetIds(ComponentName(context, receiverClass))
|
||||||
|
.toList()
|
||||||
|
if (appWidgetIds.isNotEmpty()) {
|
||||||
|
log(TAG, WARN) { "refresh($name): Glance IDs empty, falling back to platform ids=$appWidgetIds" }
|
||||||
|
}
|
||||||
|
appWidgetIds.mapNotNull { appWidgetId ->
|
||||||
|
runCatching { manager.getGlanceIdBy(appWidgetId) }
|
||||||
|
.onFailure { error ->
|
||||||
|
log(TAG, ERROR) { "refresh($name): failed to resolve widgetId=$appWidgetId: ${error.asLog()}" }
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(TAG, VERBOSE) { "refresh($name): ids=${ids.toAppWidgetIds(manager)}" }
|
||||||
|
|
||||||
|
ids.forEach { id ->
|
||||||
|
runCatching { widget.update(context, id) }
|
||||||
|
.onFailure { error ->
|
||||||
|
log(TAG, ERROR) { "refresh($name): update failed for id=${id.toAppWidgetId(manager)}: ${error.asLog()}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<GlanceId>.toAppWidgetIds(manager: GlanceAppWidgetManager): List<Int> =
|
||||||
|
map { it.toAppWidgetId(manager) }
|
||||||
|
|
||||||
|
private fun GlanceId.toAppWidgetId(manager: GlanceAppWidgetManager): Int = runCatching {
|
||||||
|
manager.getAppWidgetId(this)
|
||||||
|
}.getOrElse {
|
||||||
|
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user