mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
100
Commits
v4.0.0-rc0
...
v5.0.1-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7883dcbafb | ||
|
|
300e6b8c49 | ||
|
|
33b6fbd0e0 | ||
|
|
f2bf39bda1 | ||
|
|
87d366bcb7 | ||
|
|
df5732569f | ||
|
|
56e01cfde9 | ||
|
|
ed37e142ad | ||
|
|
cb915d6263 | ||
|
|
41245d928c | ||
|
|
ccd95a0981 | ||
|
|
2d38db5cba | ||
|
|
175f8e36b5 | ||
|
|
a102b03261 | ||
|
|
57fc4d8e7b | ||
|
|
f67336d4fe | ||
|
|
a511c57b7d | ||
|
|
66e3ab1670 | ||
|
|
7e73ac80e9 | ||
|
|
1ae9ae587e | ||
|
|
4631aa8bb2 | ||
|
|
33c8fe76a6 | ||
|
|
e356344ba6 | ||
|
|
e014ad8045 | ||
|
|
9d446938bd | ||
|
|
fea1092a95 | ||
|
|
adfedc1cce | ||
|
|
c609b03148 | ||
|
|
927a198d8e | ||
|
|
1f5f406b33 | ||
|
|
4a475419e8 | ||
|
|
7c37854076 | ||
|
|
30d7efeec2 | ||
|
|
942e03af36 | ||
|
|
24a84a49f1 | ||
|
|
e3693628c2 | ||
|
|
94156b6200 | ||
|
|
5ae15d828d | ||
|
|
360067b07e | ||
|
|
e91243e577 | ||
|
|
19d61ca5cb | ||
|
|
7b403defab | ||
|
|
ceb4108306 | ||
|
|
fe4ac306cf | ||
|
|
618229b96b | ||
|
|
1931e60b07 | ||
|
|
7bdc9ca212 | ||
|
|
04ea3d0cd8 | ||
|
|
fef6c97d2d | ||
|
|
dc2e318374 | ||
|
|
71638c13ab | ||
|
|
bd5c6bf858 | ||
|
|
70e5b99e1d | ||
|
|
f65c6e4a29 | ||
|
|
c860ecc430 | ||
|
|
a8593eecd3 | ||
|
|
94e28fb0b5 | ||
|
|
4808e04146 | ||
|
|
aebf43c355 | ||
|
|
c1cd99f701 | ||
|
|
3fa01bb510 | ||
|
|
e84f8762b4 | ||
|
|
ac21261d4d | ||
|
|
c19ee88ae6 | ||
|
|
f751cdb37e | ||
|
|
28cd3b1340 | ||
|
|
3e2852e622 | ||
|
|
b919d8d19b | ||
|
|
9d6ec2b6bb | ||
|
|
9a85b23d91 | ||
|
|
217d9be177 | ||
|
|
53447f3fb9 | ||
|
|
db759bac7c | ||
|
|
6837db5b84 | ||
|
|
83b24cced2 | ||
|
|
d913e72c5c | ||
|
|
5c16c08dee | ||
|
|
20e22c0910 | ||
|
|
fa3e56b0f3 | ||
|
|
f25718ab2c | ||
|
|
6e9b275936 | ||
|
|
c6c7cd7858 | ||
|
|
4d7027a9b8 | ||
|
|
992cb5e695 | ||
|
|
039d20d7aa | ||
|
|
52433460be | ||
|
|
95bb225e64 | ||
|
|
ac477113cf | ||
|
|
5e21941765 | ||
|
|
e5032ee35a | ||
|
|
1ceac349ab | ||
|
|
2db01b410b | ||
|
|
193a1030fb | ||
|
|
12e35631d6 | ||
|
|
67474a30da | ||
|
|
44cd0eec2a | ||
|
|
a79f6a0535 | ||
|
|
8675d73a98 | ||
|
|
3423786f06 | ||
|
|
00083707c6 |
@@ -0,0 +1 @@
|
||||
worktrees/
|
||||
@@ -80,5 +80,5 @@ Code shared between phone and Wear OS apps is placed in `app-common`. When modif
|
||||
|
||||
- **Hilt**: Dependency injection framework
|
||||
- **AndroidX Navigation**: Fragment navigation with SafeArgs
|
||||
- **Moshi**: JSON serialization for configuration and debugging
|
||||
- **kotlinx.serialization**: JSON serialization for configuration and caching
|
||||
- **Material Design**: UI components following Material Design guidelines
|
||||
|
||||
@@ -40,6 +40,18 @@ PR titles appear in auto-generated changelogs and are read by users. Use **ELI5,
|
||||
|
||||
## PR Description Format
|
||||
|
||||
PRs are reviewed in **GitHub's web UI**, which already shows the file tree, the diff, and the tests. Don't duplicate any
|
||||
of it. The description should answer questions the diff can't — not restate it.
|
||||
|
||||
Only these sections, in this order:
|
||||
|
||||
1. `## What changed`
|
||||
2. `## Technical Context`
|
||||
3. `## Review checklist` *(optional)*
|
||||
|
||||
No `Scope`, `Files changed`, `Tests`, or `Review guidance` sub-sections — GitHub shows the files and tests, and review
|
||||
notes belong in the checklist. Fold anything critical into a Technical Context bullet.
|
||||
|
||||
### What changed
|
||||
|
||||
User-friendly explanation of what this PR does. Describe the problem that was fixed or the feature that was added from the user's perspective. No internal class or method names.
|
||||
@@ -53,9 +65,18 @@ Explain what's hard to extract from the diff alone. Focus on:
|
||||
- **Why** this approach was chosen (and alternatives considered/rejected)
|
||||
- **Root cause** for bug fixes (the diff shows the fix, not what caused it)
|
||||
- **Non-obvious side effects** or behavioral changes not apparent from reading the code
|
||||
- **Review guidance** — what's tricky or deserves close attention
|
||||
|
||||
Keep it scannable with bullet points. Don't restate what's visible in the diff (file names, class renames, line-level changes).
|
||||
Format rules:
|
||||
|
||||
- **One bullet per point.** No prose paragraphs, no nested sub-headers like `**Bug 1** / **Bug 2**` — if a PR fixes
|
||||
multiple bugs, one bullet per bug is enough.
|
||||
- **Don't restate the diff.** File paths, class renames, test names, and line-level changes are all visible in the web
|
||||
UI.
|
||||
|
||||
### Review checklist (optional)
|
||||
|
||||
For PRs with multiple non-trivial review points, add a `## Review checklist` section with `- [ ]` tasks the reviewer can
|
||||
tick off as they verify. Skip it for small PRs — a single tricky thing can stay as a Technical Context bullet.
|
||||
|
||||
### Example
|
||||
|
||||
|
||||
+12
-11
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__ide__getDiagnostics",
|
||||
"WebSearch"
|
||||
],
|
||||
"deny": []
|
||||
"allow": [
|
||||
"mcp__ide__getDiagnostics",
|
||||
"WebSearch"
|
||||
],
|
||||
"deny": []
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"android-translation@claude-code-cafe": true,
|
||||
"debugbadger@claude-code-cafe": true,
|
||||
"jvm-tools@claude-code-cafe": true,
|
||||
"frontend-design@claude-plugins-official": true,
|
||||
"kotlin-lsp@claude-plugins-official": true
|
||||
"android-translation@claude-code-cafe": true,
|
||||
"debugbadger@claude-code-cafe": true,
|
||||
"jvm-tools@claude-code-cafe": true,
|
||||
"frontend-design@claude-plugins-official": true,
|
||||
"kotlin-lsp@claude-plugins-official": true,
|
||||
"support-investigator@claude-code-cafe": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
@@ -14,7 +14,7 @@ runs:
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Cache Gradle Wrapper
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -24,7 +24,7 @@ runs:
|
||||
${{ runner.os }}-gradle-wrapper-
|
||||
|
||||
- name: Cache Gradle Dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -33,7 +33,7 @@ runs:
|
||||
${{ runner.os }}-gradle-caches-
|
||||
|
||||
- name: Cache Android Global Build-Cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.android/build-cache
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-vital:
|
||||
name: Lint vitals
|
||||
@@ -18,7 +21,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
@@ -36,7 +41,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
@@ -53,9 +60,22 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
- name: Test modules
|
||||
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
|
||||
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
|
||||
|
||||
check-fastlane-metadata:
|
||||
name: Fastlane metadata
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate metadata lengths
|
||||
run: bash fastlane/check_metadata_length.sh
|
||||
@@ -8,10 +8,15 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validation:
|
||||
name: "Validation"
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
|
||||
|
||||
@@ -4,6 +4,15 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Build only, skip release/upload'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-github:
|
||||
@@ -24,23 +33,16 @@ jobs:
|
||||
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
- name: Get the version
|
||||
id: tagger
|
||||
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
|
||||
with:
|
||||
skip-unshallow: 'true'
|
||||
abbrev: false
|
||||
commit-ish: HEAD
|
||||
|
||||
- name: Assemble beta APK
|
||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||
if: contains(github.ref_name, '-beta')
|
||||
run: ./gradlew assembleFossBeta
|
||||
env:
|
||||
VERSION: ${{ github.ref }}
|
||||
@@ -49,7 +51,7 @@ jobs:
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Assemble production APK
|
||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||
if: "!contains(github.ref_name, '-beta')"
|
||||
run: ./gradlew assembleFossRelease
|
||||
env:
|
||||
VERSION: ${{ github.ref }}
|
||||
@@ -58,24 +60,24 @@ jobs:
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Create pre-release
|
||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ steps.tagger.outputs.tag }}
|
||||
name: ${{ steps.tagger.outputs.tag }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create release
|
||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
||||
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ steps.tagger.outputs.tag }}
|
||||
name: ${{ steps.tagger.outputs.tag }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
|
||||
env:
|
||||
@@ -107,29 +109,22 @@ jobs:
|
||||
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
- name: Get the version
|
||||
id: tagger
|
||||
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
|
||||
with:
|
||||
skip-unshallow: 'true'
|
||||
abbrev: false
|
||||
commit-ish: HEAD
|
||||
|
||||
- name: Set up ruby env
|
||||
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
|
||||
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 #v1.292.0
|
||||
with:
|
||||
ruby-version: 3.3.6
|
||||
bundler-cache: true
|
||||
|
||||
- name: Assemble beta and upload to Google Play
|
||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
run: bundle exec fastlane beta
|
||||
env:
|
||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||
@@ -137,7 +132,7 @@ jobs:
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Assemble production and upload to Google Play
|
||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
||||
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||
run: bundle exec fastlane production
|
||||
env:
|
||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||
|
||||
+2
-1
@@ -16,4 +16,5 @@
|
||||
/fastlane/README.md
|
||||
.kotlin
|
||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||
app/src/screenshotTest*/reference/
|
||||
app/src/screenshotTest*/reference/
|
||||
.codex
|
||||
@@ -1,4 +1,4 @@
|
||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
|
||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400" alt="CAPod banner">
|
||||
|
||||
# Companion App for AirPods (CAPod)
|
||||
|
||||
@@ -87,6 +87,7 @@ Currently supported models:
|
||||
check it out.
|
||||
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
|
||||
his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff.
|
||||
* [Crowdin](https://crowdin.com/) for supporting open-source projects.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -177,8 +177,6 @@ dependencies {
|
||||
|
||||
addDagger()
|
||||
|
||||
addMoshi()
|
||||
|
||||
addOkio()
|
||||
|
||||
addBaseAndroid()
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-keepclassmembernames @com.squareup.moshi.JsonClass class * extends java.lang.Enum {
|
||||
<fields>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="eu.darken.capod.debug.l2cap.L2capPocActivity"
|
||||
android:exported="true"
|
||||
android:label="L2CAP PoC">
|
||||
<intent-filter>
|
||||
<action android:name="eu.darken.capod.L2CAP_POC" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,256 @@
|
||||
package eu.darken.capod.debug.l2cap
|
||||
|
||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class L2capPocActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "L2capPoc"
|
||||
private const val PSM = 0x1001
|
||||
|
||||
private val HANDSHAKE = byteArrayOf(
|
||||
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
private val ANC_OFF = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x01, 0x00, 0x00, 0x00
|
||||
)
|
||||
private val ANC_ON = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x02, 0x00, 0x00, 0x00
|
||||
)
|
||||
private val TRANSPARENCY = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x03, 0x00, 0x00, 0x00
|
||||
)
|
||||
}
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) Log.d(TAG, "BLUETOOTH_CONNECT granted")
|
||||
else Log.w(TAG, "BLUETOOTH_CONNECT denied")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
PocScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@SuppressLint("MissingPermission")
|
||||
@Composable
|
||||
private fun PocScreen() {
|
||||
val btManager = remember { getSystemService(BluetoothManager::class.java) }
|
||||
val adapter: BluetoothAdapter? = remember { btManager?.adapter }
|
||||
val logEntries = remember { mutableStateListOf<String>() }
|
||||
val logListState = rememberLazyListState()
|
||||
var selectedDevice by remember { mutableStateOf<BluetoothDevice?>(null) }
|
||||
var socket by remember { mutableStateOf<BluetoothSocket?>(null) }
|
||||
var outputStream by remember { mutableStateOf<OutputStream?>(null) }
|
||||
var connected by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var readerJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val timeFmt = remember { SimpleDateFormat("HH:mm:ss.SSS", Locale.US) }
|
||||
|
||||
fun log(msg: String) {
|
||||
val line = "${timeFmt.format(Date())} $msg"
|
||||
Log.d(TAG, msg)
|
||||
logEntries.add(line)
|
||||
}
|
||||
|
||||
fun ByteArray.hex(): String = joinToString(" ") { "%02X".format(it) }
|
||||
|
||||
LaunchedEffect(logEntries.size) {
|
||||
if (logEntries.isNotEmpty()) logListState.animateScrollToItem(logEntries.size - 1)
|
||||
}
|
||||
|
||||
fun startReader(input: InputStream) {
|
||||
readerJob = scope.launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(1024)
|
||||
try {
|
||||
while (isActive) {
|
||||
val len = input.read(buf)
|
||||
if (len == -1) {
|
||||
launch(Dispatchers.Main) { log("<<< Stream closed by remote") }
|
||||
break
|
||||
}
|
||||
val data = buf.copyOf(len)
|
||||
launch(Dispatchers.Main) { log("<<< [${data.size}] ${data.hex()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
launch(Dispatchers.Main) { log("<<< Read error: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text("L2CAP PoC — AAP Connection", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
if (adapter == null) {
|
||||
Text("No Bluetooth adapter found", color = MaterialTheme.colorScheme.error)
|
||||
return@Column
|
||||
}
|
||||
|
||||
// Bonded devices
|
||||
if (!connected) {
|
||||
Text("Bonded Devices:", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
||||
val bonded = remember(adapter) {
|
||||
try { adapter.bondedDevices?.toList() ?: emptyList() }
|
||||
catch (_: SecurityException) { emptyList() }
|
||||
}
|
||||
bonded.forEach { device ->
|
||||
val name = try { device.name ?: "Unknown" } catch (_: SecurityException) { "Unknown" }
|
||||
val isSelected = selectedDevice == device
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { selectedDevice = device },
|
||||
colors = if (isSelected) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else CardDefaults.cardColors()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(8.dp)) {
|
||||
Text("$name ${device.address}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection controls
|
||||
FlowRow(modifier = Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
val dev = selectedDevice ?: return@Button
|
||||
log("=== Connecting to ${dev.address} ===")
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val sock = L2capSocketFactory().createSocket(dev, PSM)
|
||||
withContext(Dispatchers.Main) { log("Socket created, connecting...") }
|
||||
sock.connect()
|
||||
withContext(Dispatchers.Main) {
|
||||
log("Connected!")
|
||||
socket = sock
|
||||
outputStream = sock.outputStream
|
||||
connected = true
|
||||
}
|
||||
startReader(sock.inputStream)
|
||||
|
||||
withContext(Dispatchers.Main) { log(">>> Handshake [${HANDSHAKE.size}] ${HANDSHAKE.hex()}") }
|
||||
sock.outputStream.write(HANDSHAKE)
|
||||
sock.outputStream.flush()
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) { log("Failed: ${e::class.simpleName}: ${e.message}") }
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = selectedDevice != null && !connected
|
||||
) { Text("Connect") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
log("=== Disconnecting ===")
|
||||
readerJob?.cancel()
|
||||
readerJob = null
|
||||
try { socket?.close() } catch (_: Exception) {}
|
||||
socket = null
|
||||
outputStream = null
|
||||
connected = false
|
||||
},
|
||||
enabled = connected
|
||||
) { Text("Disconnect") }
|
||||
}
|
||||
|
||||
// AAP command buttons
|
||||
if (connected) {
|
||||
FlowRow(modifier = Modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
fun sendCommand(name: String, cmd: ByteArray) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) { log(">>> $name [${cmd.size}] ${cmd.hex()}") }
|
||||
outputStream?.write(cmd)
|
||||
outputStream?.flush()
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) { log(">>> Send error: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(onClick = { sendCommand("ANC Off", ANC_OFF) }) { Text("ANC Off") }
|
||||
Button(onClick = { sendCommand("ANC On", ANC_ON) }) { Text("ANC On") }
|
||||
Button(onClick = { sendCommand("Transparency", TRANSPARENCY) }) { Text("Transparency") }
|
||||
Button(onClick = { sendCommand("Handshake", HANDSHAKE) }) { Text("Handshake") }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
LazyColumn(state = logListState, modifier = Modifier.weight(1f)) {
|
||||
items(logEntries) { entry ->
|
||||
Text(text = entry, fontFamily = FontFamily.Monospace, fontSize = 11.sp, lineHeight = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
||||
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
||||
import eu.darken.capod.main.ui.widget.WidgetTheme
|
||||
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.common.theming.CapodTheme
|
||||
@@ -29,7 +22,12 @@ import eu.darken.capod.main.ui.overview.OverviewScreen
|
||||
import eu.darken.capod.main.ui.overview.OverviewViewModel
|
||||
import eu.darken.capod.main.ui.settings.SettingsScreen
|
||||
import eu.darken.capod.main.ui.settings.SettingsViewModel
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
||||
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
||||
import eu.darken.capod.main.ui.widget.WidgetTheme
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.profiles.ui.DeviceManagerScreen
|
||||
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
||||
import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationScreen
|
||||
@@ -37,6 +35,7 @@ import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationViewModel
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.ui.ReactionSettingsScreen
|
||||
import eu.darken.capod.reaction.ui.ReactionSettingsViewModel
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
||||
|
||||
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||
|
||||
@@ -47,15 +46,15 @@ internal fun DashboardContent() = PreviewWrapper {
|
||||
now = MOCK_NOW,
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.airPodsProMixed(),
|
||||
MockPodDataProvider.airPodsMax(),
|
||||
MockPodDataProvider.unknownDevice(),
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
),
|
||||
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
|
||||
showUnmatchedDevices = false,
|
||||
@@ -73,9 +72,9 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
||||
DeviceManagerScreen(
|
||||
state = DeviceManagerViewModel.State(
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("Work AirPods", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("Gym Beats", PodDevice.Model.POWERBEATS_PRO),
|
||||
MockPodDataProvider.profile("Work AirPods", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("Gym Beats", PodModel.POWERBEATS_PRO),
|
||||
),
|
||||
),
|
||||
onBack = {},
|
||||
@@ -92,11 +91,11 @@ internal fun AddProfileContent() = PreviewWrapper {
|
||||
name = "",
|
||||
nameError = null,
|
||||
selectedModel = null,
|
||||
availableModels = PodDevice.Model.entries.filter { it != PodDevice.Model.UNKNOWN },
|
||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||
identityKey = null,
|
||||
encryptionKey = null,
|
||||
selectedDevice = null,
|
||||
bondedDevices = emptyList(),
|
||||
bondedDeviceItems = emptyList(),
|
||||
minimumSignalQuality = 0.15f,
|
||||
canSave = false,
|
||||
),
|
||||
@@ -158,8 +157,8 @@ internal fun ReactionSettingsContent() = PreviewWrapper {
|
||||
@Composable
|
||||
internal fun WidgetConfigurationContent() = PreviewWrapper {
|
||||
val profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
)
|
||||
WidgetConfigurationScreen(
|
||||
state = WidgetConfigurationViewModel.State(
|
||||
@@ -201,7 +200,7 @@ internal fun CasePopUpContent() {
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PopUpCard(
|
||||
device = MockPodDataProvider.airPodsProMixed(),
|
||||
device = MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
onClose = {},
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
package eu.darken.capod.common.upgrade.core
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
@Serializable
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FossUpgrade(
|
||||
@Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
|
||||
val reason: Reason
|
||||
@SerialName("upgradedAt") @Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
|
||||
@SerialName("reason") val reason: Reason
|
||||
) {
|
||||
@Serializable
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class Reason {
|
||||
@SerialName("foss.upgrade.reason.donated") @Json(name = "foss.upgrade.reason.donated") DONATED,
|
||||
@SerialName("foss.upgrade.reason.alreadydonated") @Json(name = "foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
|
||||
@SerialName("foss.upgrade.reason.nomoney") @Json(name = "foss.upgrade.reason.nomoney") NO_MONEY;
|
||||
@SerialName("foss.upgrade.reason.donated") DONATED,
|
||||
@SerialName("foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
|
||||
@SerialName("foss.upgrade.reason.nomoney") NO_MONEY;
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,11 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -24,6 +21,8 @@ import androidx.compose.material.icons.twotone.BluetoothConnected
|
||||
import androidx.compose.material.icons.twotone.Favorite
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.twotone.PlayCircle
|
||||
import androidx.compose.material.icons.twotone.Headphones
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material.icons.twotone.Widgets
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -36,6 +35,7 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -111,24 +112,35 @@ fun UpgradeScreen(
|
||||
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
|
||||
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
|
||||
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
|
||||
Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
|
||||
Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
|
||||
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateUp) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
@@ -208,6 +220,14 @@ fun UpgradeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_benefit_disclaimer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
@@ -238,18 +258,6 @@ fun UpgradeScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onNavigateUp,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<resources>
|
||||
<string name="foss_upgrade_donate_label">Faire un don</string>
|
||||
<string name="foss_upgrade_alreadydonated_label">J’ai déjà fait un don</string>
|
||||
<string name="foss_upgrade_no_money_label">J’ai dépensé tout mon argent sur les AirPods</string>
|
||||
<string name="upgrade_foss_preamble">CAPod FOSS est gratuit et open source. Si vous le trouvez utile, pensez à sponsoriser le développement pour aider à maintenir le projet en vie.</string>
|
||||
<string name="foss_upgrade_no_money_label">J’ai dépensé tout mon argent sur des AirPods</string>
|
||||
<string name="upgrade_foss_preamble">CAPod FOSS est gratuite et à code source ouvert. Si vous la trouvez utile, pensez à soutenir le développement pour contribuer à la pérennité du projet.</string>
|
||||
<string name="upgrade_foss_sponsor_action">Soutenir le développement</string>
|
||||
<string name="upgrade_foss_sponsor_subtitle">Pas de publicités. Pas de suivi. Pas de dépendance à Google Play.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">Déjà de retour ? Votre soutien maintient CAPod en vie.</string>
|
||||
<string name="upgrade_foss_sponsor_subtitle">Pas de publicités. Pas de suivi à la trace. Pas de dépendance à Google Play.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">Déjà de retour ? Votre soutien maintient CAPod en vie.</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<string name="foss_upgrade_donate_label">Пожертвовать</string>
|
||||
<string name="foss_upgrade_alreadydonated_label">Я уже пожертвовал</string>
|
||||
<string name="foss_upgrade_no_money_label">Я потратил все свои деньги на AirPods</string>
|
||||
<string name="upgrade_foss_preamble">CAPod FOSS бесплатный и с открытым исходным кодом. Если вам полезно приложение, рассмотрите возможность спонсировать разработку, чтобы помочь проекту продолжать развиваться.</string>
|
||||
<string name="upgrade_foss_preamble">CAPod FOSS бесплатный и с открытым исходным кодом. Если Вам полезно приложение, рассмотрите возможность спонсировать разработку, чтобы помочь проекту продолжать развиваться.</string>
|
||||
<string name="upgrade_foss_sponsor_action">Поддержать разработку</string>
|
||||
<string name="upgrade_foss_sponsor_subtitle">Без рекламы. Без слежки. Без привязки к Google Play.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">Вернулись уже? Ваша поддержка помогает CAPod жить.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">Вернулись? Ваша поддержка помогает CAPod жить.</string>
|
||||
</resources>
|
||||
|
||||
+14
-23
@@ -9,6 +9,9 @@ import com.android.billingclient.api.ProductDetails
|
||||
import com.android.billingclient.api.Purchase
|
||||
import com.android.billingclient.api.QueryProductDetailsParams
|
||||
import com.android.billingclient.api.QueryPurchasesParams
|
||||
import com.android.billingclient.api.acknowledgePurchase
|
||||
import com.android.billingclient.api.queryProductDetails
|
||||
import com.android.billingclient.api.queryPurchasesAsync
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -21,8 +24,6 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
data class BillingClientConnection(
|
||||
private val client: BillingClient,
|
||||
@@ -67,22 +68,16 @@ data class BillingClientConnection(
|
||||
.setProductType(productType)
|
||||
.build()
|
||||
|
||||
val (result: BillingResult, purchases) = suspendCoroutine<Pair<BillingResult, Collection<Purchase>?>> { continuation ->
|
||||
client.queryPurchasesAsync(params) { result, purchases ->
|
||||
continuation.resume(result to purchases)
|
||||
}
|
||||
}
|
||||
val queryResult = client.queryPurchasesAsync(params)
|
||||
|
||||
log(TAG) { "queryPurchases($productType): code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases" }
|
||||
log(TAG) { "queryPurchases($productType): code=${queryResult.billingResult.responseCode}, message=${queryResult.billingResult.debugMessage}, purchases=${queryResult.purchasesList}" }
|
||||
|
||||
if (!result.isSuccess) {
|
||||
if (!queryResult.billingResult.isSuccess) {
|
||||
log(TAG, WARN) { "queryPurchases($productType) failed" }
|
||||
throw BillingResultException(result)
|
||||
} else {
|
||||
requireNotNull(purchases)
|
||||
throw BillingResultException(queryResult.billingResult)
|
||||
}
|
||||
|
||||
return purchases
|
||||
return queryResult.purchasesList
|
||||
}
|
||||
|
||||
suspend fun acknowledgePurchase(purchase: Purchase) {
|
||||
@@ -90,9 +85,7 @@ data class BillingClientConnection(
|
||||
setPurchaseToken(purchase.purchaseToken)
|
||||
}.build()
|
||||
|
||||
val result = suspendCoroutine<BillingResult> { continuation ->
|
||||
client.acknowledgePurchase(ack) { continuation.resume(it) }
|
||||
}
|
||||
val result = client.acknowledgePurchase(ack)
|
||||
|
||||
log(TAG, INFO) { "acknowledgePurchase($purchase): code=${result.responseCode} (${result.debugMessage})" }
|
||||
|
||||
@@ -118,17 +111,15 @@ data class BillingClientConnection(
|
||||
|
||||
val params = QueryProductDetailsParams.newBuilder().setProductList(products).build()
|
||||
|
||||
val (result, details) = suspendCoroutine<Pair<BillingResult, List<ProductDetails>>> { continuation ->
|
||||
client.queryProductDetailsAsync(params) { billingResult, queryResult ->
|
||||
continuation.resume(billingResult to queryResult.productDetailsList.orEmpty())
|
||||
}
|
||||
}
|
||||
val queryResult = client.queryProductDetails(params)
|
||||
|
||||
val details = queryResult.productDetailsList.orEmpty()
|
||||
|
||||
log(TAG) {
|
||||
"querySkus(type=$type, skus=${typeSkus.map { it.id }}): code=${result.responseCode}, debug=${result.debugMessage}, details=$details"
|
||||
"querySkus(type=$type, skus=${typeSkus.map { it.id }}): code=${queryResult.billingResult.responseCode}, debug=${queryResult.billingResult.debugMessage}, details=$details"
|
||||
}
|
||||
|
||||
if (!result.isSuccess) throw BillingResultException(result)
|
||||
if (!queryResult.billingResult.isSuccess) throw BillingResultException(queryResult.billingResult)
|
||||
|
||||
for (detail in details) {
|
||||
val matchingSku = typeSkus.firstOrNull { it.id == detail.productId }
|
||||
|
||||
@@ -24,9 +24,11 @@ class BillingDataRepo @Inject constructor(
|
||||
) {
|
||||
|
||||
private val connectionProvider = billingClientConnectionProvider.connection
|
||||
.catch {
|
||||
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
|
||||
throw it
|
||||
.retryWhen { cause, attempt ->
|
||||
if (cause is CancellationException) return@retryWhen false
|
||||
log(TAG, ERROR) { "Unable to provide client connection (attempt=$attempt):\n${cause.asLog()}" }
|
||||
delay(60_000) // 60s between retries (upstream already did 5 quick retries)
|
||||
true
|
||||
}
|
||||
.replayingShare(scope)
|
||||
|
||||
|
||||
@@ -7,14 +7,11 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -27,6 +24,8 @@ import androidx.compose.material.icons.twotone.Favorite
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.twotone.PlayCircle
|
||||
import androidx.compose.material.icons.twotone.Stars
|
||||
import androidx.compose.material.icons.twotone.Headphones
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material.icons.twotone.Widgets
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -37,8 +36,10 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -125,17 +126,33 @@ fun UpgradeScreen(
|
||||
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
|
||||
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
|
||||
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
|
||||
Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
|
||||
Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
|
||||
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars)) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateUp) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
@@ -215,6 +232,14 @@ fun UpgradeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_benefit_disclaimer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (state == null) {
|
||||
@@ -231,17 +256,6 @@ fun UpgradeScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onNavigateUp,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.darken.capod">
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE"
|
||||
tools:ignore="ForegroundServicesPolicy" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
|
||||
tools:ignore="BackgroundLocationPolicy" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
@@ -32,7 +36,8 @@
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
|
||||
@@ -12,9 +12,10 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
|
||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -27,8 +28,7 @@ import javax.inject.Inject
|
||||
open class App : Application() {
|
||||
|
||||
@Inject lateinit var autoReporting: AutomaticBugReporter
|
||||
@Inject lateinit var monitorControl: MonitorControl
|
||||
@Inject lateinit var podMonitor: PodMonitor
|
||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||
@Inject lateinit var widgetManager: WidgetManager
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
@Inject @AppScope lateinit var appScope: CoroutineScope
|
||||
@@ -41,11 +41,9 @@ open class App : Application() {
|
||||
|
||||
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
|
||||
|
||||
monitorControl.startMonitor(forceStart = true)
|
||||
|
||||
appScope.launch { widgetManager.refreshWidgets() }
|
||||
|
||||
podMonitor.devicesWithProfiles()
|
||||
deviceMonitor.devicesWithProfiles()
|
||||
.distinctUntilChanged()
|
||||
.throttleLatest(1000)
|
||||
.onEach {
|
||||
|
||||
@@ -45,7 +45,7 @@ class MediaControl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendKey(keyCode: Int) {
|
||||
internal suspend fun sendKey(keyCode: Int) {
|
||||
log(TAG) { "Sending up+down KeyEvent: $keyCode" }
|
||||
val eventTime = SystemClock.uptimeMillis()
|
||||
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||
@@ -53,6 +53,24 @@ class MediaControl @Inject constructor(
|
||||
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime + 200, eventTime + 200, KeyEvent.ACTION_UP, keyCode, 0))
|
||||
}
|
||||
|
||||
fun adjustVolumeUp() {
|
||||
log(TAG, INFO) { "adjustVolumeUp()" }
|
||||
audioManager.adjustSuggestedStreamVolume(
|
||||
AudioManager.ADJUST_RAISE,
|
||||
AudioManager.USE_DEFAULT_STREAM_TYPE,
|
||||
AudioManager.FLAG_SHOW_UI,
|
||||
)
|
||||
}
|
||||
|
||||
fun adjustVolumeDown() {
|
||||
log(TAG, INFO) { "adjustVolumeDown()" }
|
||||
audioManager.adjustSuggestedStreamVolume(
|
||||
AudioManager.ADJUST_LOWER,
|
||||
AudioManager.USE_DEFAULT_STREAM_TYPE,
|
||||
AudioManager.FLAG_SHOW_UI,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("MediaControl")
|
||||
}
|
||||
|
||||
@@ -3,19 +3,21 @@ package eu.darken.capod.common.bluetooth
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.os.Parcelable
|
||||
import androidx.core.util.forEach
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||
import eu.darken.capod.common.serialization.MapIntByteArrayBase64Serializer
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
@Parcelize
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Serializable
|
||||
data class BleScanResult(
|
||||
@Json(name = "receivedAt") val receivedAt: Instant,
|
||||
@Json(name = "address") val address: String,
|
||||
@Json(name = "rssi") val rssi: Int,
|
||||
@Json(name = "generatedAtNanos") val generatedAtNanos: Long,
|
||||
@Json(name = "manufacturerSpecificData") val manufacturerSpecificData: Map<Int, ByteArray>
|
||||
@SerialName("receivedAt") @Serializable(with = InstantEpochMillisSerializer::class) val receivedAt: Instant,
|
||||
@SerialName("address") val address: String,
|
||||
@SerialName("rssi") val rssi: Int,
|
||||
@SerialName("generatedAtNanos") val generatedAtNanos: Long,
|
||||
@SerialName("manufacturerSpecificData") @Serializable(with = MapIntByteArrayBase64Serializer::class) val manufacturerSpecificData: Map<Int, ByteArray>
|
||||
) : Parcelable {
|
||||
|
||||
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
|
||||
|
||||
@@ -22,7 +22,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -304,6 +304,40 @@ class BluetoothManager2 @Inject constructor(
|
||||
emit(wrappedDevices)
|
||||
}
|
||||
|
||||
private var _isNudgeAvailable: Boolean = true
|
||||
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
||||
|
||||
/**
|
||||
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
|
||||
* settings display without touching the AirPods firmware itself. Uses reflection on the hidden
|
||||
* `setAlias(String)` method because the public API 30+ variant requires `BLUETOOTH_PRIVILEGED`,
|
||||
* which third-party apps cannot hold.
|
||||
*
|
||||
* Returns `true` on success, `false` if the call threw, was rejected, or returned `false`.
|
||||
*
|
||||
* Known failure mode on Android 12+: `SecurityException: does not have a CDM association with
|
||||
* the Bluetooth Device`. The hidden method was moved behind a Companion Device Manager (CDM)
|
||||
* permission check at the service layer — only apps that have explicitly requested and been
|
||||
* granted a CDM association for this specific device can rename it. CAPod does not currently
|
||||
* pursue a CDM association (that's a user-visible pairing flow), so setAlias is effectively
|
||||
* unavailable on modern Android and callers should be prepared to surface a user-facing
|
||||
* fallback when it returns false. See DeviceSettingsViewModel.setDeviceName.
|
||||
*/
|
||||
@android.annotation.SuppressLint("MissingPermission")
|
||||
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
||||
return try {
|
||||
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
||||
.apply { isAccessible = true }
|
||||
val result = method.invoke(device.internal, alias) as? Boolean ?: false
|
||||
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
val cause = (e as? java.lang.reflect.InvocationTargetException)?.cause ?: e
|
||||
log(TAG, WARN) { "setDeviceAlias(${device.address}, $alias) failed: $cause" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
@@ -312,11 +346,16 @@ class BluetoothManager2 @Inject constructor(
|
||||
"connect", BluetoothDevice::class.java
|
||||
).apply { isAccessible = true }
|
||||
|
||||
connectMethod.invoke(bluetoothProfile.proxy, device.internal)
|
||||
|
||||
log(TAG) { "Nudged connection to $device" }
|
||||
true
|
||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
|
||||
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
val isSecurityException = e is SecurityException ||
|
||||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
||||
if (isSecurityException) {
|
||||
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
|
||||
_isNudgeAvailable = false
|
||||
}
|
||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class ScannerMode(
|
||||
val identifier: String,
|
||||
@StringRes val labelRes: Int
|
||||
) {
|
||||
@SerialName("scanner.mode.lowpower") @Json(name = "scanner.mode.lowpower") LOW_POWER(
|
||||
@SerialName("scanner.mode.lowpower") LOW_POWER(
|
||||
"scanner.mode.lowpower",
|
||||
R.string.settings_scanner_mode_lowpower_label
|
||||
),
|
||||
@SerialName("scanner.mode.balanced") @Json(name = "scanner.mode.balanced") BALANCED(
|
||||
@SerialName("scanner.mode.balanced") BALANCED(
|
||||
"scanner.mode.balanced",
|
||||
R.string.settings_scanner_mode_balanced_label
|
||||
),
|
||||
@SerialName("scanner.mode.lowlatency") @Json(name = "scanner.mode.lowlatency") LOW_LATENCY(
|
||||
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
|
||||
"scanner.mode.lowlatency",
|
||||
R.string.settings_scanner_mode_lowlatency_label
|
||||
),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package eu.darken.capod.common.bluetooth.l2cap
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import java.lang.invoke.MethodType
|
||||
|
||||
/**
|
||||
* Mirror classes matching ART's internal field layout for offset calculation.
|
||||
* [sun.misc.Unsafe.objectFieldOffset] on these classes gives the same offsets as the real
|
||||
* hidden fields, because the field types and order are identical.
|
||||
*
|
||||
* Based on LSPosed/AndroidHiddenApiBypass (Apache 2.0)
|
||||
* https://github.com/LSPosed/AndroidHiddenApiBypass
|
||||
*/
|
||||
@Keep
|
||||
object ArtMirror {
|
||||
|
||||
@Keep
|
||||
open class AccessibleObject {
|
||||
private val override: Boolean = false
|
||||
}
|
||||
|
||||
@Keep
|
||||
class Executable : AccessibleObject() {
|
||||
private val declaringClass: Any? = null
|
||||
private val declaringClassOfOverriddenMethod: Any? = null
|
||||
private val parameters: Array<Any>? = null
|
||||
@JvmField val artMethod: Long = 0
|
||||
private val accessFlags: Int = 0
|
||||
}
|
||||
|
||||
@Keep
|
||||
class MethodHandle {
|
||||
private val type: MethodType? = null
|
||||
private val nominalType: MethodType? = null
|
||||
private val cachedSpreadInvoker: MethodHandle? = null
|
||||
private val handleKind: Int = 0
|
||||
@JvmField val artFieldOrMethod: Long = 0
|
||||
}
|
||||
|
||||
@Keep
|
||||
class Class {
|
||||
private val classLoader: ClassLoader? = null
|
||||
private val componentType: kotlin.Any? = null
|
||||
private val dexCache: kotlin.Any? = null
|
||||
private val extData: kotlin.Any? = null
|
||||
private val ifTable: Array<kotlin.Any>? = null
|
||||
private val name: String? = null
|
||||
private val superClass: kotlin.Any? = null
|
||||
private val vtable: kotlin.Any? = null
|
||||
@JvmField val iFields: Long = 0
|
||||
@JvmField val methods: Long = 0
|
||||
@JvmField val sFields: Long = 0
|
||||
private val accessFlags: Int = 0
|
||||
private val classFlags: Int = 0
|
||||
private val classSize: Int = 0
|
||||
private val clinitThreadId: Int = 0
|
||||
private val dexClassDefIndex: Int = 0
|
||||
@Volatile private var dexTypeIndex: Int = 0
|
||||
private val numReferenceInstanceFields: Int = 0
|
||||
private val numReferenceStaticFields: Int = 0
|
||||
private val objectSize: Int = 0
|
||||
private val objectSizeAllocFastPath: Int = 0
|
||||
private val primitiveType: Int = 0
|
||||
private val referenceInstanceOffsets: Int = 0
|
||||
private val status: Int = 0
|
||||
private val copiedMethodsOffset: Short = 0
|
||||
private val virtualMethodsOffset: Short = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package eu.darken.capod.common.bluetooth.l2cap
|
||||
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import java.lang.invoke.MethodHandles
|
||||
import java.lang.invoke.MethodType
|
||||
import java.lang.reflect.Method
|
||||
|
||||
/**
|
||||
* Bypasses Android's hidden API restrictions by calling [dalvik.system.VMRuntime.setHiddenApiExemptions].
|
||||
*
|
||||
* Technique: Uses [sun.misc.Unsafe] to iterate ART's internal method array and swap a stub method's
|
||||
* artMethod pointer to execute hidden methods. Mirror classes in [ArtMirror] provide field offsets
|
||||
* without triggering hidden API checks.
|
||||
*
|
||||
* Based on LSPosed/AndroidHiddenApiBypass (Apache 2.0)
|
||||
* https://github.com/LSPosed/AndroidHiddenApiBypass
|
||||
*
|
||||
* Copyright 2021 LSPosed
|
||||
* Licensed under the Apache License, Version 2.0
|
||||
*/
|
||||
object HiddenApiBypass {
|
||||
|
||||
private val TAG = logTag("HiddenApiBypass")
|
||||
|
||||
private val exemptedPrefixes = mutableSetOf<String>()
|
||||
|
||||
@androidx.annotation.Keep
|
||||
private object InvokeStub {
|
||||
@JvmStatic
|
||||
fun invoke(vararg args: Any?): Any? {
|
||||
throw IllegalStateException("Stub — artMethod not swapped")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setExemptions(vararg prefixes: String) {
|
||||
// Merge with existing prefixes (VMRuntime.setHiddenApiExemptions replaces, not merges)
|
||||
val newPrefixes = prefixes.filter { it !in exemptedPrefixes }
|
||||
if (newPrefixes.isEmpty()) return
|
||||
|
||||
exemptedPrefixes.addAll(newPrefixes)
|
||||
val allPrefixes = exemptedPrefixes.toTypedArray()
|
||||
|
||||
val unsafe = Class.forName("sun.misc.Unsafe").getDeclaredMethod("getUnsafe").invoke(null)
|
||||
val unsafeClass = unsafe::class.java
|
||||
val objectFieldOffset = unsafeClass.getMethod("objectFieldOffset", java.lang.reflect.Field::class.java)
|
||||
val getLong = unsafeClass.getMethod("getLong", Any::class.java, Long::class.javaPrimitiveType)
|
||||
val putLong = unsafeClass.getMethod("putLong", Any::class.java, Long::class.javaPrimitiveType, Long::class.javaPrimitiveType)
|
||||
val getInt = unsafeClass.getMethod("getInt", Long::class.javaPrimitiveType)
|
||||
|
||||
// --- Offset calculation from mirror classes ---
|
||||
val artMethodOff = objectFieldOffset.invoke(unsafe,
|
||||
ArtMirror.Executable::class.java.getDeclaredField("artMethod")) as Long
|
||||
val artFieldOrMethodOff = objectFieldOffset.invoke(unsafe,
|
||||
ArtMirror.MethodHandle::class.java.getDeclaredField("artFieldOrMethod")) as Long
|
||||
val methodsOff = objectFieldOffset.invoke(unsafe,
|
||||
ArtMirror.Class::class.java.getDeclaredField("methods")) as Long
|
||||
|
||||
// --- Sanity check: verify artMethod offset matches the real accessible field ---
|
||||
val realArtMethodOff = objectFieldOffset.invoke(unsafe,
|
||||
java.lang.reflect.Executable::class.java.getDeclaredField("artMethod")) as Long
|
||||
check(artMethodOff == realArtMethodOff) {
|
||||
"ArtMirror.Executable.artMethod offset ($artMethodOff) != real offset ($realArtMethodOff). " +
|
||||
"ART field layout has changed — mirror classes need updating."
|
||||
}
|
||||
|
||||
// --- Calculate artMethodSize and bias from NeverCall ---
|
||||
val mA = NeverCall::class.java.getDeclaredMethod("a").apply { isAccessible = true }
|
||||
val mB = NeverCall::class.java.getDeclaredMethod("b").apply { isAccessible = true }
|
||||
val mhA = MethodHandles.lookup().unreflect(mA)
|
||||
val mhB = MethodHandles.lookup().unreflect(mB)
|
||||
val aAddr = getLong.invoke(unsafe, mhA, artFieldOrMethodOff) as Long
|
||||
val bAddr = getLong.invoke(unsafe, mhB, artFieldOrMethodOff) as Long
|
||||
val artMethodSize = bAddr - aAddr
|
||||
|
||||
check(artMethodSize in 16..256) {
|
||||
"artMethodSize=$artMethodSize is outside expected range [16, 256]. ART internals may have changed."
|
||||
}
|
||||
|
||||
val ncMethods = getLong.invoke(unsafe, NeverCall::class.java, methodsOff) as Long
|
||||
val artMethodBias = aAddr - ncMethods - artMethodSize
|
||||
|
||||
// --- Iterate VMRuntime methods ---
|
||||
val vmRuntimeClass = Class.forName("dalvik.system.VMRuntime")
|
||||
val vmMethods = getLong.invoke(unsafe, vmRuntimeClass, methodsOff) as Long
|
||||
val numMethods = getInt.invoke(unsafe, vmMethods) as Int
|
||||
|
||||
check(numMethods in 1..10000) {
|
||||
"VMRuntime numMethods=$numMethods is outside expected range. Pointer may be invalid."
|
||||
}
|
||||
|
||||
val stubMethod: Method = InvokeStub::class.java.getDeclaredMethod("invoke", Array<Any?>::class.java)
|
||||
stubMethod.isAccessible = true
|
||||
val originalArtMethod = getLong.invoke(unsafe, stubMethod, artMethodOff) as Long
|
||||
|
||||
var runtime: Any? = null
|
||||
var exemptionsSet = false
|
||||
|
||||
try {
|
||||
for (i in 0 until numMethods) {
|
||||
val methodPtr = vmMethods + i * artMethodSize + artMethodBias
|
||||
putLong.invoke(unsafe, stubMethod, artMethodOff, methodPtr)
|
||||
|
||||
val name = stubMethod.name
|
||||
val params = stubMethod.parameterTypes
|
||||
|
||||
if (name == "getRuntime" && params.isEmpty() && runtime == null) {
|
||||
runtime = stubMethod.invoke(null)
|
||||
}
|
||||
|
||||
if (name == "setHiddenApiExemptions" && params.size == 1 && params[0] == Array<String>::class.java) {
|
||||
if (runtime == null) {
|
||||
throw IllegalStateException("Found setHiddenApiExemptions before getRuntime")
|
||||
}
|
||||
stubMethod.invoke(runtime, allPrefixes as Any)
|
||||
exemptionsSet = true
|
||||
log(TAG, VERBOSE) { "setHiddenApiExemptions OK: ${allPrefixes.contentToString()}" }
|
||||
}
|
||||
|
||||
if (runtime != null && exemptionsSet) break
|
||||
}
|
||||
} finally {
|
||||
// Always restore the stub's original artMethod pointer
|
||||
putLong.invoke(unsafe, stubMethod, artMethodOff, originalArtMethod)
|
||||
}
|
||||
|
||||
if (runtime == null) throw RuntimeException("VMRuntime.getRuntime() not found in method array")
|
||||
if (!exemptionsSet) throw RuntimeException("VMRuntime.setHiddenApiExemptions() not found in method array")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package eu.darken.capod.common.bluetooth.l2cap
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Creates BR/EDR L2CAP sockets for connecting to devices like AirPods.
|
||||
*
|
||||
* Tries the public [android.bluetooth.BluetoothSocketSettings] API first (API 37+).
|
||||
* Falls back to the hidden `createInsecureL2capSocket` method via [HiddenApiBypass].
|
||||
*/
|
||||
@Singleton
|
||||
@SuppressLint("MissingPermission")
|
||||
class L2capSocketFactory @Inject constructor() {
|
||||
|
||||
private val TAG = logTag("L2capSocketFactory")
|
||||
private val TYPE_L2CAP = 3
|
||||
|
||||
/**
|
||||
* Creates an insecure BR/EDR L2CAP socket for the given [device] and [psm].
|
||||
*
|
||||
* The socket is created but not connected — call [BluetoothSocket.connect] separately.
|
||||
*
|
||||
* @throws IllegalArgumentException if [psm] is invalid
|
||||
* @throws SecurityException if BLUETOOTH_CONNECT permission is missing
|
||||
* @throws NoSuchMethodException if the hidden API is unavailable on this Android version
|
||||
* @throws IOException if socket creation fails at the transport level
|
||||
*/
|
||||
fun createSocket(device: BluetoothDevice, psm: Int): BluetoothSocket {
|
||||
require(psm > 0) { "Invalid PSM: $psm" }
|
||||
|
||||
// Strategy 1: Public API via BluetoothSocketSettings (API 37+)
|
||||
tryPublicApi(device, psm)?.let { socket ->
|
||||
log(TAG, VERBOSE) { "Socket created via public BluetoothSocketSettings API" }
|
||||
return socket
|
||||
}
|
||||
|
||||
// Strategy 2: Hidden API via reflection + bypass
|
||||
log(TAG, VERBOSE) { "Public API unavailable, using hidden API bypass" }
|
||||
return createViaHiddenApi(device, psm)
|
||||
}
|
||||
|
||||
private fun tryPublicApi(device: BluetoothDevice, psm: Int): BluetoothSocket? {
|
||||
return try {
|
||||
val settingsClass = Class.forName("android.bluetooth.BluetoothSocketSettings")
|
||||
val builderClass = Class.forName("android.bluetooth.BluetoothSocketSettings\$Builder")
|
||||
|
||||
val builder = builderClass.getDeclaredConstructor().newInstance()
|
||||
builderClass.getMethod("setSocketType", Int::class.javaPrimitiveType).invoke(builder, TYPE_L2CAP)
|
||||
builderClass.getMethod("setL2capPsm", Int::class.javaPrimitiveType).invoke(builder, psm)
|
||||
builderClass.getMethod("setAuthenticationRequired", Boolean::class.javaPrimitiveType).invoke(builder, false)
|
||||
builderClass.getMethod("setEncryptionRequired", Boolean::class.javaPrimitiveType).invoke(builder, false)
|
||||
val settings = builderClass.getMethod("build").invoke(builder)
|
||||
|
||||
val createMethod = BluetoothDevice::class.java.getMethod("createUsingSocketSettings", settingsClass)
|
||||
createMethod.invoke(device, settings) as BluetoothSocket
|
||||
} catch (e: ClassNotFoundException) {
|
||||
log(TAG, VERBOSE) { "BluetoothSocketSettings not available (pre-API 37)" }
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
val cause = if (e is java.lang.reflect.InvocationTargetException) e.cause ?: e else e
|
||||
when (cause) {
|
||||
is IllegalArgumentException -> {
|
||||
log(TAG, VERBOSE) { "BluetoothSocketSettings does not support TYPE_L2CAP: ${cause.message}" }
|
||||
null
|
||||
}
|
||||
is SecurityException -> throw cause
|
||||
else -> {
|
||||
log(TAG, VERBOSE) { "BluetoothSocketSettings failed: ${cause::class.simpleName}: ${cause.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViaHiddenApi(device: BluetoothDevice, psm: Int): BluetoothSocket {
|
||||
HiddenApiBypass.setExemptions("Landroid/bluetooth/")
|
||||
|
||||
return try {
|
||||
val method = BluetoothDevice::class.java.getDeclaredMethod(
|
||||
"createInsecureL2capSocket",
|
||||
Int::class.javaPrimitiveType
|
||||
)
|
||||
method.invoke(device, psm) as BluetoothSocket
|
||||
} catch (e: java.lang.reflect.InvocationTargetException) {
|
||||
throw e.cause ?: IOException("createInsecureL2capSocket failed", e)
|
||||
} catch (e: NoSuchMethodException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
throw e
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
throw IOException("Failed to create L2CAP socket via hidden API", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package eu.darken.capod.common.bluetooth.l2cap;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
|
||||
/**
|
||||
* Helper class for ART method size calculation.
|
||||
* Methods a() and b() must be adjacent in ART's internal method array.
|
||||
* Plain Java (not Kotlin) to avoid synthetic bridge methods from companion objects.
|
||||
*/
|
||||
@Keep
|
||||
class NeverCall {
|
||||
private static void a() { throw new RuntimeException(); }
|
||||
private static void b() { throw new RuntimeException(); }
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package eu.darken.capod.common.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
class ReorderableState<T>(
|
||||
val lazyListState: LazyListState,
|
||||
private val hapticFeedback: HapticFeedback,
|
||||
private val key: (T) -> Any,
|
||||
) {
|
||||
private val _items = mutableStateListOf<T>()
|
||||
val items: List<T> get() = _items
|
||||
|
||||
private var originalItems: List<T> = emptyList()
|
||||
|
||||
var draggedIndex: Int? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var dragOffsetY: Float by mutableFloatStateOf(0f)
|
||||
private set
|
||||
|
||||
val isDragging: Boolean get() = draggedIndex != null
|
||||
|
||||
fun syncItems(newItems: List<T>) {
|
||||
if (isDragging) return
|
||||
_items.clear()
|
||||
_items.addAll(newItems)
|
||||
}
|
||||
|
||||
fun onDragStart(item: T) {
|
||||
val itemKey = key(item)
|
||||
val index = _items.indexOfFirst { key(it) == itemKey }
|
||||
if (index == -1) return
|
||||
originalItems = _items.toList()
|
||||
draggedIndex = index
|
||||
dragOffsetY = 0f
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
fun onDrag(delta: Float) {
|
||||
val currentIndex = draggedIndex ?: return
|
||||
dragOffsetY += delta
|
||||
|
||||
val layoutInfo = lazyListState.layoutInfo
|
||||
val draggedItemInfo = layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { it.index == currentIndex } ?: return
|
||||
val draggedCenter = draggedItemInfo.offset + draggedItemInfo.size / 2 + dragOffsetY.toInt()
|
||||
|
||||
val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { itemInfo ->
|
||||
itemInfo.index != currentIndex &&
|
||||
itemInfo.index < _items.size &&
|
||||
draggedCenter in itemInfo.offset..(itemInfo.offset + itemInfo.size)
|
||||
} ?: return
|
||||
|
||||
val targetIndex = targetInfo.index
|
||||
dragOffsetY += (draggedItemInfo.offset - targetInfo.offset).toFloat()
|
||||
_items.swap(currentIndex, targetIndex)
|
||||
draggedIndex = targetIndex
|
||||
}
|
||||
|
||||
fun onDragEnd(onReorder: (List<T>) -> Unit) {
|
||||
val reordered = _items.toList()
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
originalItems = emptyList()
|
||||
onReorder(reordered)
|
||||
}
|
||||
|
||||
fun onDragCancel() {
|
||||
if (originalItems.isNotEmpty()) {
|
||||
_items.clear()
|
||||
_items.addAll(originalItems)
|
||||
}
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
originalItems = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> MutableList<T>.swap(from: Int, to: Int) {
|
||||
val temp = this[from]
|
||||
this[from] = this[to]
|
||||
this[to] = temp
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> rememberReorderableState(
|
||||
lazyListState: LazyListState,
|
||||
items: List<T>,
|
||||
key: (T) -> Any,
|
||||
): ReorderableState<T> {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
return remember(lazyListState) {
|
||||
ReorderableState(lazyListState, hapticFeedback, key).also {
|
||||
it.syncItems(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> ReorderableAutoScroll(state: ReorderableState<T>) {
|
||||
val scrollThresholdPx = with(LocalDensity.current) { 80.dp.toPx() }
|
||||
LaunchedEffect(state.isDragging) {
|
||||
if (!state.isDragging) return@LaunchedEffect
|
||||
while (isActive) {
|
||||
val layoutInfo = state.lazyListState.layoutInfo
|
||||
val viewportStart = layoutInfo.viewportStartOffset
|
||||
val viewportEnd = layoutInfo.viewportEndOffset
|
||||
val draggedIdx = state.draggedIndex ?: break
|
||||
val draggedItem = layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggedIdx }
|
||||
if (draggedItem != null) {
|
||||
val itemCenter = draggedItem.offset + draggedItem.size / 2 + state.dragOffsetY
|
||||
val scrollSpeed = 8f
|
||||
val delta = when {
|
||||
itemCenter < viewportStart + scrollThresholdPx -> -scrollSpeed
|
||||
itemCenter > viewportEnd - scrollThresholdPx -> scrollSpeed
|
||||
else -> 0f
|
||||
}
|
||||
if (delta != 0f) state.lazyListState.scrollBy(delta)
|
||||
}
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> reorderableItemModifier(
|
||||
state: ReorderableState<T>,
|
||||
index: Int,
|
||||
item: T,
|
||||
enabled: Boolean = true,
|
||||
onReorder: (List<T>) -> Unit = {},
|
||||
): Modifier {
|
||||
val isDragging = state.draggedIndex == index
|
||||
val dragOffsetY = if (isDragging) state.dragOffsetY else 0f
|
||||
|
||||
val currentItem by rememberUpdatedState(item)
|
||||
val currentOnReorder by rememberUpdatedState(onReorder)
|
||||
|
||||
val surfaceColor = MaterialTheme.colorScheme.surface
|
||||
|
||||
return Modifier
|
||||
.zIndex(if (isDragging) 1f else 0f)
|
||||
.background(surfaceColor)
|
||||
.then(
|
||||
if (enabled) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { state.onDragStart(currentItem) },
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
state.onDrag(dragAmount.y)
|
||||
},
|
||||
onDragEnd = { state.onDragEnd(currentOnReorder) },
|
||||
onDragCancel = { state.onDragCancel() },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.graphicsLayer {
|
||||
if (isDragging) {
|
||||
translationY = dragOffsetY
|
||||
shadowElevation = 8.dp.toPx()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,23 @@ import android.content.Context
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.unknown.UnknownDevice
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import java.time.Instant
|
||||
@@ -35,8 +42,8 @@ object MockPodDataProvider {
|
||||
|
||||
// --- Dual pod scenarios ---
|
||||
|
||||
fun airPodsProFullCharge(): DualPodDevice = MockDualPodDevice(
|
||||
_model = PodDevice.Model.AIRPODS_PRO2,
|
||||
fun airPodsProFullCharge(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_PRO2,
|
||||
_label = "Work AirPods",
|
||||
batteryLeftPodPercent = 1.0f,
|
||||
batteryRightPodPercent = 1.0f,
|
||||
@@ -44,10 +51,11 @@ object MockPodDataProvider {
|
||||
leftPodIcon = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIcon = R.drawable.device_airpods_pro2_right,
|
||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
_address = "AA:BB:CC:DD:EE:FF",
|
||||
)
|
||||
|
||||
fun airPodsProMixed(): DualPodDevice = MockDualPodDevice(
|
||||
_model = PodDevice.Model.AIRPODS_PRO2,
|
||||
fun airPodsProMixed(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_PRO2,
|
||||
_label = "My AirPods Pro",
|
||||
batteryLeftPodPercent = 0.80f,
|
||||
batteryRightPodPercent = 0.45f,
|
||||
@@ -58,8 +66,8 @@ object MockPodDataProvider {
|
||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
)
|
||||
|
||||
fun airPodsProLowBattery(): DualPodDevice = MockDualPodDevice(
|
||||
_model = PodDevice.Model.AIRPODS_PRO2,
|
||||
fun airPodsProLowBattery(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_PRO2,
|
||||
_label = "My AirPods Pro",
|
||||
batteryLeftPodPercent = 0.10f,
|
||||
batteryRightPodPercent = 0.05f,
|
||||
@@ -71,8 +79,8 @@ object MockPodDataProvider {
|
||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
)
|
||||
|
||||
fun airPodsProInCase(): DualPodDevice = MockDualPodDevice(
|
||||
_model = PodDevice.Model.AIRPODS_PRO2,
|
||||
fun airPodsProInCase(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_PRO2,
|
||||
_label = "My AirPods Pro",
|
||||
batteryLeftPodPercent = null,
|
||||
batteryRightPodPercent = null,
|
||||
@@ -82,8 +90,8 @@ object MockPodDataProvider {
|
||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
)
|
||||
|
||||
fun airPodsGen1Wearing(): DualPodDevice = MockDualPodDevice(
|
||||
_model = PodDevice.Model.AIRPODS_GEN1,
|
||||
fun airPodsGen1Wearing(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_GEN1,
|
||||
_label = "Old AirPods",
|
||||
batteryLeftPodPercent = 0.70f,
|
||||
batteryRightPodPercent = 0.65f,
|
||||
@@ -96,8 +104,8 @@ object MockPodDataProvider {
|
||||
_caseIcon = R.drawable.device_airpods_gen1_case,
|
||||
)
|
||||
|
||||
fun powerBeatsPro(): DualPodDevice = MockDualPodDeviceNoCase(
|
||||
_model = PodDevice.Model.POWERBEATS_PRO,
|
||||
fun powerBeatsPro(): DualBlePodSnapshot = MockDualBlePodSnapshotNoCase(
|
||||
_model = PodModel.POWERBEATS_PRO,
|
||||
_label = "PowerBeats Pro",
|
||||
batteryLeftPodPercent = 0.55f,
|
||||
batteryRightPodPercent = 0.60f,
|
||||
@@ -107,39 +115,159 @@ object MockPodDataProvider {
|
||||
|
||||
// --- Single pod scenarios ---
|
||||
|
||||
fun airPodsMax(): SinglePodDevice = MockSinglePodDevice(
|
||||
_model = PodDevice.Model.AIRPODS_MAX,
|
||||
fun airPodsMax(): SingleBlePodSnapshot = MockSingleBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_MAX,
|
||||
_label = "AirPods Max",
|
||||
batteryHeadsetPercent = 0.85f,
|
||||
_isBeingWorn = true,
|
||||
)
|
||||
|
||||
fun airPodsMaxCharging(): SinglePodDevice = MockSinglePodDevice(
|
||||
_model = PodDevice.Model.AIRPODS_MAX,
|
||||
fun airPodsMaxCharging(): SingleBlePodSnapshot = MockSingleAppleBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_MAX,
|
||||
_label = "AirPods Max",
|
||||
batteryHeadsetPercent = 0.40f,
|
||||
_isHeadsetBeingCharged = true,
|
||||
_isIRKMatch = true,
|
||||
_hasPrivatePayload = true,
|
||||
_address = "11:22:33:44:55:66",
|
||||
)
|
||||
|
||||
fun beatsSolo3(): SinglePodDevice = MockSinglePodDevice(
|
||||
_model = PodDevice.Model.BEATS_SOLO_3,
|
||||
fun beatsSolo3(): SingleBlePodSnapshot = MockSingleBlePodSnapshot(
|
||||
_model = PodModel.BEATS_SOLO_3,
|
||||
_label = "Beats Solo 3",
|
||||
batteryHeadsetPercent = 0.70f,
|
||||
)
|
||||
|
||||
// --- Unknown device ---
|
||||
|
||||
fun unknownDevice(): PodDevice = UnknownDevice(
|
||||
fun unknownDevice(): BlePodSnapshot = UnknownSnapshotBle(
|
||||
scanResult = dummyScanResult(rssi = -70),
|
||||
)
|
||||
|
||||
// --- Profile helpers ---
|
||||
|
||||
fun profile(label: String, model: PodDevice.Model): AppleDeviceProfile = AppleDeviceProfile(
|
||||
fun profile(label: String, model: PodModel): AppleDeviceProfile = AppleDeviceProfile(
|
||||
label = label,
|
||||
model = model,
|
||||
)
|
||||
|
||||
// --- Dual pod with Apple keys ---
|
||||
|
||||
fun airPodsProWithKeys(): DualBlePodSnapshot = MockDualAppleBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_PRO2,
|
||||
_label = "My AirPods Pro",
|
||||
batteryLeftPodPercent = 0.80f,
|
||||
batteryRightPodPercent = 0.45f,
|
||||
_batteryCasePercent = 0.60f,
|
||||
_isIRKMatch = true,
|
||||
_hasPrivatePayload = true,
|
||||
leftPodIcon = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIcon = R.drawable.device_airpods_pro2_right,
|
||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
_address = "AA:BB:CC:DD:EE:FF",
|
||||
)
|
||||
|
||||
// --- PodDevice wrappers ---
|
||||
|
||||
fun dualPodMonitored(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual",
|
||||
ble = airPodsProFullCharge(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-mixed",
|
||||
ble = airPodsProMixed(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-keys",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-aap",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||
profileId = "preview-single",
|
||||
ble = airPodsMax(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-single-aap",
|
||||
ble = airPodsMaxCharging(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ON,
|
||||
supported = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun unknownMonitored(): PodDevice = PodDevice(
|
||||
profileId = null,
|
||||
ble = unknownDevice(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
/** Cached-only dual pod — device fully offline, showing last known state. */
|
||||
fun dualPodCachedOnly(): PodDevice = PodDevice(
|
||||
profileId = "preview-cached",
|
||||
ble = null,
|
||||
aap = null,
|
||||
cached = CachedDeviceState(
|
||||
profileId = "preview-cached",
|
||||
model = PodModel.AIRPODS_PRO2,
|
||||
address = "AA:BB:CC:DD:EE:FF",
|
||||
left = CachedDeviceState.CachedBatterySlot(0.65f, MOCK_NOW.minusSeconds(3600)),
|
||||
right = CachedDeviceState.CachedBatterySlot(0.50f, MOCK_NOW.minusSeconds(3600)),
|
||||
case = CachedDeviceState.CachedBatterySlot(0.80f, MOCK_NOW.minusSeconds(3600)),
|
||||
isLeftCharging = false,
|
||||
isRightCharging = false,
|
||||
isCaseCharging = false,
|
||||
lastSeenAt = MOCK_NOW.minusSeconds(3600),
|
||||
),
|
||||
)
|
||||
|
||||
/** Cached-only single pod — device fully offline, showing last known state. */
|
||||
fun singlePodCachedOnly(): PodDevice = PodDevice(
|
||||
profileId = "preview-cached-single",
|
||||
ble = null,
|
||||
aap = null,
|
||||
cached = CachedDeviceState(
|
||||
profileId = "preview-cached-single",
|
||||
model = PodModel.AIRPODS_MAX,
|
||||
headset = CachedDeviceState.CachedBatterySlot(0.40f, MOCK_NOW.minusSeconds(7200)),
|
||||
lastSeenAt = MOCK_NOW.minusSeconds(7200),
|
||||
),
|
||||
)
|
||||
|
||||
// --- UpgradeInfo ---
|
||||
|
||||
fun fossInfo(isPro: Boolean = false): UpgradeRepo.Info = MockUpgradeInfo(
|
||||
@@ -160,8 +288,8 @@ private data class MockUpgradeInfo(
|
||||
override val error: Throwable? = null,
|
||||
) : UpgradeRepo.Info
|
||||
|
||||
private class MockDualPodDevice(
|
||||
private val _model: PodDevice.Model,
|
||||
private class MockDualBlePodSnapshot(
|
||||
private val _model: PodModel,
|
||||
private val _label: String,
|
||||
override val batteryLeftPodPercent: Float?,
|
||||
override val batteryRightPodPercent: Float?,
|
||||
@@ -176,10 +304,11 @@ private class MockDualPodDevice(
|
||||
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
|
||||
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
|
||||
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
|
||||
private val _address: String? = null,
|
||||
rssi: Int = -50,
|
||||
) : DualPodDevice, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
|
||||
override val identifier: PodDevice.Id = PodDevice.Id()
|
||||
override val model: PodDevice.Model = _model
|
||||
) : DualBlePodSnapshot, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
|
||||
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
|
||||
override val model: PodModel = _model
|
||||
override val seenLastAt: Instant = MOCK_NOW
|
||||
override val seenFirstAt: Instant = MOCK_NOW
|
||||
override val seenCounter: Int = 5
|
||||
@@ -187,8 +316,8 @@ private class MockDualPodDevice(
|
||||
override val reliability: Float = 1.0f
|
||||
override val signalQuality: Float = 0.75f
|
||||
override val iconRes: Int = _model.iconRes
|
||||
override val meta: PodDevice.Meta = object : PodDevice.Meta {
|
||||
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
|
||||
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
|
||||
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model, address = _address)
|
||||
}
|
||||
|
||||
override fun getLabel(context: Context): String = _model.label
|
||||
@@ -211,17 +340,17 @@ private class MockDualPodDevice(
|
||||
override val isRightPodMicrophone: Boolean = _isRightPodMicrophone
|
||||
}
|
||||
|
||||
private class MockDualPodDeviceNoCase(
|
||||
private val _model: PodDevice.Model,
|
||||
private class MockDualBlePodSnapshotNoCase(
|
||||
private val _model: PodModel,
|
||||
private val _label: String,
|
||||
override val batteryLeftPodPercent: Float?,
|
||||
override val batteryRightPodPercent: Float?,
|
||||
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
|
||||
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
|
||||
rssi: Int = -50,
|
||||
) : DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual {
|
||||
override val identifier: PodDevice.Id = PodDevice.Id()
|
||||
override val model: PodDevice.Model = _model
|
||||
) : DualBlePodSnapshot, HasChargeDetectionDual, HasEarDetectionDual {
|
||||
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
|
||||
override val model: PodModel = _model
|
||||
override val seenLastAt: Instant = MOCK_NOW
|
||||
override val seenFirstAt: Instant = MOCK_NOW
|
||||
override val seenCounter: Int = 5
|
||||
@@ -229,7 +358,7 @@ private class MockDualPodDeviceNoCase(
|
||||
override val reliability: Float = 1.0f
|
||||
override val signalQuality: Float = 0.70f
|
||||
override val iconRes: Int = _model.iconRes
|
||||
override val meta: PodDevice.Meta = object : PodDevice.Meta {
|
||||
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
|
||||
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
|
||||
}
|
||||
|
||||
@@ -244,16 +373,16 @@ private class MockDualPodDeviceNoCase(
|
||||
override val isRightPodInEar: Boolean = false
|
||||
}
|
||||
|
||||
private class MockSinglePodDevice(
|
||||
private val _model: PodDevice.Model,
|
||||
private class MockSingleBlePodSnapshot(
|
||||
private val _model: PodModel,
|
||||
private val _label: String,
|
||||
override val batteryHeadsetPercent: Float?,
|
||||
private val _isHeadsetBeingCharged: Boolean = false,
|
||||
private val _isBeingWorn: Boolean = false,
|
||||
rssi: Int = -50,
|
||||
) : SinglePodDevice, HasChargeDetection, HasEarDetection {
|
||||
override val identifier: PodDevice.Id = PodDevice.Id()
|
||||
override val model: PodDevice.Model = _model
|
||||
) : SingleBlePodSnapshot, HasChargeDetection, HasEarDetection {
|
||||
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
|
||||
override val model: PodModel = _model
|
||||
override val seenLastAt: Instant = MOCK_NOW
|
||||
override val seenFirstAt: Instant = MOCK_NOW
|
||||
override val seenCounter: Int = 5
|
||||
@@ -261,7 +390,7 @@ private class MockSinglePodDevice(
|
||||
override val reliability: Float = 1.0f
|
||||
override val signalQuality: Float = 0.80f
|
||||
override val iconRes: Int = _model.iconRes
|
||||
override val meta: PodDevice.Meta = object : PodDevice.Meta {
|
||||
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
|
||||
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
|
||||
}
|
||||
|
||||
@@ -273,3 +402,102 @@ private class MockSinglePodDevice(
|
||||
// HasEarDetection
|
||||
override val isBeingWorn: Boolean = _isBeingWorn
|
||||
}
|
||||
|
||||
@Suppress("PropertyName")
|
||||
private class MockSingleAppleBlePodSnapshot(
|
||||
private val _model: PodModel,
|
||||
private val _label: String,
|
||||
override val batteryHeadsetPercent: Float?,
|
||||
private val _isHeadsetBeingCharged: Boolean = false,
|
||||
private val _isBeingWorn: Boolean = false,
|
||||
private val _isIRKMatch: Boolean = false,
|
||||
private val _hasPrivatePayload: Boolean = false,
|
||||
private val _address: String? = null,
|
||||
rssi: Int = -50,
|
||||
) : SingleBlePodSnapshot, ApplePods, HasChargeDetection, HasEarDetection {
|
||||
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
|
||||
override val model: PodModel = _model
|
||||
override val seenLastAt: Instant = MOCK_NOW
|
||||
override val seenFirstAt: Instant = MOCK_NOW
|
||||
override val seenCounter: Int = 5
|
||||
override val scanResult: BleScanResult = MockPodDataProvider.dummyScanResult(rssi)
|
||||
override val reliability: Float = 1.0f
|
||||
override val signalQuality: Float = 0.80f
|
||||
override val iconRes: Int = _model.iconRes
|
||||
override val meta: ApplePods.AppleMeta = ApplePods.AppleMeta(
|
||||
isIRKMatch = _isIRKMatch,
|
||||
profile = AppleDeviceProfile(label = _label, model = _model, address = _address),
|
||||
)
|
||||
override val payload: ProximityPayload = ProximityPayload(
|
||||
public = ProximityPayload.Public(UByteArray(9)),
|
||||
private = if (_hasPrivatePayload) ProximityPayload.Private(UByteArray(8)) else null,
|
||||
)
|
||||
|
||||
override fun getLabel(context: Context): String = _model.label
|
||||
|
||||
// HasChargeDetection
|
||||
override val isHeadsetBeingCharged: Boolean = _isHeadsetBeingCharged
|
||||
|
||||
// HasEarDetection
|
||||
override val isBeingWorn: Boolean = _isBeingWorn
|
||||
}
|
||||
|
||||
@Suppress("PropertyName")
|
||||
private class MockDualAppleBlePodSnapshot(
|
||||
private val _model: PodModel,
|
||||
private val _label: String,
|
||||
override val batteryLeftPodPercent: Float?,
|
||||
override val batteryRightPodPercent: Float?,
|
||||
private val _batteryCasePercent: Float?,
|
||||
private val _isCaseCharging: Boolean = false,
|
||||
private val _isLeftPodCharging: Boolean = false,
|
||||
private val _isRightPodCharging: Boolean = false,
|
||||
private val _isLeftPodInEar: Boolean = false,
|
||||
private val _isRightPodInEar: Boolean = false,
|
||||
private val _isLeftPodMicrophone: Boolean = false,
|
||||
private val _isRightPodMicrophone: Boolean = false,
|
||||
private val _isIRKMatch: Boolean = false,
|
||||
private val _hasPrivatePayload: Boolean = false,
|
||||
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
|
||||
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
|
||||
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
|
||||
private val _address: String? = null,
|
||||
rssi: Int = -50,
|
||||
) : DualBlePodSnapshot, ApplePods, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
|
||||
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
|
||||
override val model: PodModel = _model
|
||||
override val seenLastAt: Instant = MOCK_NOW
|
||||
override val seenFirstAt: Instant = MOCK_NOW
|
||||
override val seenCounter: Int = 5
|
||||
override val scanResult: BleScanResult = MockPodDataProvider.dummyScanResult(rssi)
|
||||
override val reliability: Float = 1.0f
|
||||
override val signalQuality: Float = 0.75f
|
||||
override val iconRes: Int = _model.iconRes
|
||||
override val meta: ApplePods.AppleMeta = ApplePods.AppleMeta(
|
||||
isIRKMatch = _isIRKMatch,
|
||||
profile = AppleDeviceProfile(label = _label, model = _model, address = _address),
|
||||
)
|
||||
override val payload: ProximityPayload = ProximityPayload(
|
||||
public = ProximityPayload.Public(UByteArray(9)),
|
||||
private = if (_hasPrivatePayload) ProximityPayload.Private(UByteArray(8)) else null,
|
||||
)
|
||||
|
||||
override fun getLabel(context: Context): String = _model.label
|
||||
|
||||
// HasCase
|
||||
override val batteryCasePercent: Float? = _batteryCasePercent
|
||||
override val isCaseCharging: Boolean = _isCaseCharging
|
||||
override val caseIcon: Int = _caseIcon
|
||||
|
||||
// HasChargeDetectionDual
|
||||
override val isLeftPodCharging: Boolean = _isLeftPodCharging
|
||||
override val isRightPodCharging: Boolean = _isRightPodCharging
|
||||
|
||||
// HasEarDetectionDual
|
||||
override val isLeftPodInEar: Boolean = _isLeftPodInEar
|
||||
override val isRightPodInEar: Boolean = _isRightPodInEar
|
||||
|
||||
// HasDualMicrophone
|
||||
override val isLeftPodMicrophone: Boolean = _isLeftPodMicrophone
|
||||
override val isRightPodMicrophone: Boolean = _isRightPodMicrophone
|
||||
}
|
||||
|
||||
@@ -33,4 +33,5 @@ class AndroidModule {
|
||||
@Singleton
|
||||
fun audioManager(context: Context): AudioManager =
|
||||
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
}
|
||||
|
||||
+12
-10
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -57,21 +59,20 @@ class DebugSessionManager @Inject constructor(
|
||||
activeDir = recorderState.currentLogDir,
|
||||
recordingStartedAt = recorderState.recordingStartedAt,
|
||||
)
|
||||
val overlaid = applyOverlays(raw, zipping, failedZips)
|
||||
applyOverlays(raw, zipping, failedZips)
|
||||
}.replayingShare(appScope)
|
||||
|
||||
val orphans = findOrphans(overlaid, zipping)
|
||||
if (orphans.isNotEmpty()) {
|
||||
orphans.forEach { (id, _) -> pendingAutoZips.add(id) }
|
||||
appScope.launch {
|
||||
orphans.forEach { (id, dir) ->
|
||||
init {
|
||||
sessions.onEach { allSessions ->
|
||||
val orphans = findOrphans(allSessions, zippingIds.value)
|
||||
orphans.forEach { (id, dir) ->
|
||||
if (pendingAutoZips.add(id)) {
|
||||
log(TAG, INFO) { "Orphan session detected, auto-zipping: $id" }
|
||||
zipSessionAsync(id, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overlaid
|
||||
}.replayingShare(appScope)
|
||||
}.launchIn(appScope)
|
||||
}
|
||||
|
||||
private fun applyOverlays(
|
||||
sessions: List<DebugSession>,
|
||||
@@ -226,6 +227,7 @@ class DebugSessionManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
failedZipIds.update { emptySet() }
|
||||
pendingAutoZips.clear()
|
||||
log(TAG) { "All stored logs deleted" }
|
||||
refresh()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package eu.darken.capod.common.debug.recording.core
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.InstallId
|
||||
@@ -48,7 +49,12 @@ class RecorderModule @Inject constructor(
|
||||
|
||||
private val internalState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) {
|
||||
val triggerFileExists = triggerFile.exists()
|
||||
State(shouldRecord = triggerFileExists)
|
||||
val persistedInfo = if (triggerFileExists) readTriggerFile() else null
|
||||
State(
|
||||
shouldRecord = triggerFileExists,
|
||||
persistedLogDir = persistedInfo?.first,
|
||||
recordingStartedAt = persistedInfo?.second ?: 0L,
|
||||
)
|
||||
}
|
||||
val state: Flow<State> = internalState.flow
|
||||
|
||||
@@ -59,24 +65,46 @@ class RecorderModule @Inject constructor(
|
||||
|
||||
internalState.updateBlocking {
|
||||
if (!isRecording && shouldRecord) {
|
||||
val sessionDir = createSessionDir()
|
||||
val isResume = persistedLogDir != null && persistedLogDir.exists()
|
||||
val sessionDir = if (isResume) {
|
||||
log(TAG, INFO) { "Resuming recording into existing session: $persistedLogDir" }
|
||||
persistedLogDir
|
||||
} else {
|
||||
createSessionDir()
|
||||
}
|
||||
val logFile = File(sessionDir, "core.log")
|
||||
val newRecorder = Recorder()
|
||||
newRecorder.start(logFile)
|
||||
triggerFile.createNewFile()
|
||||
|
||||
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
|
||||
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
|
||||
if (!isResume) {
|
||||
val startTime = System.currentTimeMillis()
|
||||
writeTriggerFile(sessionDir, startTime)
|
||||
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
|
||||
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
|
||||
|
||||
this@RecorderModule.currentLogDir = sessionDir
|
||||
this@RecorderModule.currentLogDir = sessionDir
|
||||
|
||||
copy(
|
||||
recorder = newRecorder,
|
||||
currentLogDir = sessionDir,
|
||||
recordingStartedAt = System.currentTimeMillis(),
|
||||
)
|
||||
copy(
|
||||
recorder = newRecorder,
|
||||
currentLogDir = sessionDir,
|
||||
recordingStartedAt = startTime,
|
||||
persistedLogDir = null,
|
||||
)
|
||||
} else {
|
||||
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
|
||||
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
|
||||
|
||||
this@RecorderModule.currentLogDir = sessionDir
|
||||
|
||||
copy(
|
||||
recorder = newRecorder,
|
||||
currentLogDir = sessionDir,
|
||||
recordingStartedAt = if (recordingStartedAt > 0L) recordingStartedAt else System.currentTimeMillis(),
|
||||
persistedLogDir = null,
|
||||
)
|
||||
}
|
||||
} else if (!shouldRecord && isRecording) {
|
||||
recorder!!.stop()
|
||||
requireNotNull(recorder) { "Recorder is null despite isRecording" }.stop()
|
||||
|
||||
if (triggerFile.exists() && !triggerFile.delete()) {
|
||||
log(TAG, ERROR) { "Failed to delete trigger file" }
|
||||
@@ -133,7 +161,8 @@ class RecorderModule @Inject constructor(
|
||||
internalState.updateBlocking {
|
||||
copy(shouldRecord = true)
|
||||
}
|
||||
return internalState.flow.filter { it.isRecording }.first().currentLogDir!!
|
||||
val state = internalState.flow.filter { it.isRecording }.first()
|
||||
return requireNotNull(state.currentLogDir) { "Recording state has no logDir" }
|
||||
}
|
||||
|
||||
suspend fun stopRecorder(): File? {
|
||||
@@ -169,6 +198,7 @@ class RecorderModule @Inject constructor(
|
||||
internal val recorder: Recorder? = null,
|
||||
val currentLogDir: File? = null,
|
||||
val recordingStartedAt: Long = 0L,
|
||||
internal val persistedLogDir: File? = null,
|
||||
) {
|
||||
val isRecording: Boolean
|
||||
get() = recorder != null
|
||||
@@ -177,9 +207,59 @@ class RecorderModule @Inject constructor(
|
||||
get() = recorder?.path
|
||||
}
|
||||
|
||||
internal fun readTriggerFile(): Pair<File, Long>? = try {
|
||||
parseTriggerContent(triggerFile.readText())
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to read trigger file: $e" }
|
||||
null
|
||||
}
|
||||
|
||||
private fun writeTriggerFile(sessionDir: File, startTime: Long) {
|
||||
try {
|
||||
triggerFile.writeText("${sessionDir.absolutePath}\n$startTime")
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to write trigger file: $e" }
|
||||
try {
|
||||
triggerFile.createNewFile()
|
||||
} catch (e2: Exception) {
|
||||
log(TAG, ERROR) { "Failed to create trigger file fallback: $e2" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal val TAG = logTag("Debug", "Log", "Recorder", "Module")
|
||||
private const val FORCE_FILE = "capod_force_debug_run"
|
||||
private const val MIN_RECORDING_MS = 5_000L
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun parseTriggerContent(
|
||||
content: String,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
): Pair<File, Long>? {
|
||||
val trimmed = content.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val lines = trimmed.lines()
|
||||
if (lines.size < 2) {
|
||||
log(TAG, WARN) { "Trigger file has unexpected format: $trimmed" }
|
||||
return null
|
||||
}
|
||||
|
||||
val dir = File(lines[0])
|
||||
val timestamp = lines[1].toLongOrNull()
|
||||
|
||||
if (timestamp == null || timestamp !in 1..(now + 60_000L)) {
|
||||
log(TAG, WARN) { "Trigger file has invalid timestamp: ts=$timestamp" }
|
||||
return null
|
||||
}
|
||||
|
||||
if (!dir.exists()) {
|
||||
log(TAG, WARN) { "Trigger file references non-existent dir: ${lines[0]}" }
|
||||
return null
|
||||
}
|
||||
|
||||
return dir to timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.common.PrivacyPolicy
|
||||
import eu.darken.capod.common.WebpageTool
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.debug.recording.core.DebugSession
|
||||
@@ -132,8 +133,6 @@ class RecorderActivityVM @Inject constructor(
|
||||
}
|
||||
|
||||
fun share() = launch {
|
||||
val currentState = stater.flow.first()
|
||||
val dir = currentState.logDir ?: return@launch
|
||||
val sid = sessionId ?: return@launch
|
||||
|
||||
stater.updateBlocking { copy(isWorking = true) }
|
||||
@@ -141,17 +140,21 @@ class RecorderActivityVM @Inject constructor(
|
||||
try {
|
||||
val uri = sessionManager.getZipUri(sid)
|
||||
|
||||
val displayName = stater.flow.first().logDir?.name ?: sid
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
type = "application/zip"
|
||||
addCategory(Intent.CATEGORY_DEFAULT)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${dir.name}")
|
||||
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - $displayName")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
val chooserIntent = Intent.createChooser(intent, context.getString(R.string.support_debuglog_label))
|
||||
events.tryEmit(Event.ShareIntent(chooserIntent))
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to share session $sid: ${e.message}" }
|
||||
} finally {
|
||||
stater.updateBlocking { copy(isWorking = false) }
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ private fun BottomActionBar(
|
||||
onClick = onKeep,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = stringResource(R.string.general_close_action))
|
||||
Text(text = stringResource(R.string.debug_debuglog_screen_keep_action))
|
||||
}
|
||||
androidx.compose.material3.Button(
|
||||
onClick = onShare,
|
||||
|
||||
@@ -21,6 +21,12 @@ object Nav {
|
||||
|
||||
@Serializable
|
||||
data object Upgrade : Main
|
||||
|
||||
@Serializable
|
||||
data class DeviceSettings(val address: String) : Main
|
||||
|
||||
@Serializable
|
||||
data object StemActionConfig : Main
|
||||
}
|
||||
|
||||
sealed interface Settings : NavigationDestination {
|
||||
|
||||
@@ -16,6 +16,7 @@ enum class Permission(
|
||||
@StringRes val labelRes: Int,
|
||||
@StringRes val descriptionRes: Int,
|
||||
val permissionId: String,
|
||||
val isScanBlocking: Boolean = false,
|
||||
val isGranted: (Context) -> Boolean = {
|
||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
||||
},
|
||||
@@ -26,6 +27,7 @@ enum class Permission(
|
||||
labelRes = R.string.permission_bluetooth_label,
|
||||
descriptionRes = R.string.permission_bluetooth_description,
|
||||
permissionId = "android.permission.BLUETOOTH",
|
||||
isScanBlocking = true,
|
||||
),
|
||||
BLUETOOTH_CONNECT(
|
||||
minApiLevel = Build.VERSION_CODES.S,
|
||||
@@ -38,6 +40,7 @@ enum class Permission(
|
||||
labelRes = R.string.permission_bluetooth_scan_label,
|
||||
descriptionRes = R.string.permission_bluetooth_scan_description,
|
||||
permissionId = "android.permission.BLUETOOTH_SCAN",
|
||||
isScanBlocking = true,
|
||||
),
|
||||
ACCESS_FINE_LOCATION(
|
||||
minApiLevel = Build.VERSION_CODES.BASE,
|
||||
@@ -45,6 +48,7 @@ enum class Permission(
|
||||
labelRes = R.string.permission_access_fine_location_label,
|
||||
descriptionRes = R.string.permission_access_fine_location_description,
|
||||
permissionId = "android.permission.ACCESS_FINE_LOCATION",
|
||||
isScanBlocking = true,
|
||||
),
|
||||
ACCESS_BACKGROUND_LOCATION(
|
||||
minApiLevel = Build.VERSION_CODES.Q,
|
||||
@@ -73,7 +77,7 @@ enum class Permission(
|
||||
},
|
||||
),
|
||||
POST_NOTIFICATIONS(
|
||||
minApiLevel = Build.VERSION_CODES.S,
|
||||
minApiLevel = Build.VERSION_CODES.TIRAMISU,
|
||||
labelRes = R.string.permission_post_notifications_label,
|
||||
descriptionRes = R.string.permission_post_notifications_description,
|
||||
permissionId = "android.permission.POST_NOTIFICATIONS",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package eu.darken.capod.common.serialization
|
||||
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.ToJson
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Suppress("unused")
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class ByteArrayAdapter {
|
||||
@ToJson
|
||||
fun toJson(obj: ByteArray): String = Base64.encode(obj)
|
||||
|
||||
@FromJson
|
||||
fun fromJson(base64: String): ByteArray? = Base64.decode(base64)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package eu.darken.capod.common.serialization
|
||||
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.ToJson
|
||||
import java.time.Instant
|
||||
|
||||
class JavaInstantAdapter {
|
||||
@ToJson
|
||||
fun toJson(obj: Instant): Long = obj.toEpochMilli()
|
||||
|
||||
@FromJson
|
||||
fun fromJson(epochMillis: Long): Instant = Instant.ofEpochMilli(epochMillis)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package eu.darken.capod.common.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
object MapIntByteArrayBase64Serializer : KSerializer<Map<Int, ByteArray>> {
|
||||
|
||||
private val delegate = MapSerializer(Int.serializer(), ByteArrayBase64Serializer)
|
||||
|
||||
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Map<Int, ByteArray>) {
|
||||
delegate.serialize(encoder, value)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): Map<Int, ByteArray> {
|
||||
return delegate.deserialize(decoder)
|
||||
}
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
@file:Suppress("MemberVisibilityCanBePrivate")
|
||||
|
||||
package eu.darken.capod.common.serialization
|
||||
|
||||
import com.squareup.moshi.*
|
||||
import java.lang.reflect.Type
|
||||
import java.util.*
|
||||
import javax.annotation.CheckReturnValue
|
||||
import kotlin.apply
|
||||
import kotlin.collections.isNotEmpty
|
||||
import kotlin.collections.plus
|
||||
import kotlin.collections.toTypedArray
|
||||
import kotlin.io.use
|
||||
import kotlin.jvm.javaClass
|
||||
|
||||
class NameBasedPolyJsonAdapterFactory<T> internal constructor(
|
||||
val baseType: Class<T>,
|
||||
val keyLabels: List<String> = emptyList(),
|
||||
val subtypes: List<Type> = emptyList(),
|
||||
) : JsonAdapter.Factory {
|
||||
|
||||
fun withSubtype(subtype: Class<out T>, label: String): NameBasedPolyJsonAdapterFactory<T> {
|
||||
require(!keyLabels.contains(label)) { "Labels must be unique." }
|
||||
return NameBasedPolyJsonAdapterFactory(
|
||||
baseType = baseType,
|
||||
keyLabels = keyLabels + label,
|
||||
subtypes = subtypes + subtype,
|
||||
)
|
||||
}
|
||||
|
||||
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
|
||||
if (Types.getRawType(type) != baseType || annotations.isNotEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val jsonAdapters = ArrayList<JsonAdapter<Any>>(subtypes.size)
|
||||
var i = 0
|
||||
val size = subtypes.size
|
||||
while (i < size) {
|
||||
jsonAdapters.add(moshi.adapter(subtypes[i]))
|
||||
i++
|
||||
}
|
||||
|
||||
return PolymorphicJsonAdapter(
|
||||
nameLabels = keyLabels,
|
||||
subTypes = subtypes,
|
||||
jsonAdapters = jsonAdapters,
|
||||
).nullSafe()
|
||||
}
|
||||
|
||||
internal class PolymorphicJsonAdapter(
|
||||
val nameLabels: List<String>,
|
||||
val subTypes: List<Type>,
|
||||
val jsonAdapters: List<JsonAdapter<Any>>,
|
||||
) : JsonAdapter<Any>() {
|
||||
|
||||
private val nameOptions: JsonReader.Options = JsonReader.Options.of(*nameLabels.toTypedArray())
|
||||
|
||||
override fun fromJson(reader: JsonReader): Any? {
|
||||
val peeked = reader.peekJson().apply {
|
||||
setFailOnUnknown(false)
|
||||
}
|
||||
val labelIndex = peeked.use(::labelIndex)
|
||||
|
||||
if (labelIndex == -1) {
|
||||
throw JsonDataException("No matching Field names for $nameLabels")
|
||||
}
|
||||
|
||||
return jsonAdapters[labelIndex].fromJson(reader)
|
||||
|
||||
}
|
||||
|
||||
private fun labelIndex(reader: JsonReader): Int {
|
||||
reader.beginObject()
|
||||
while (reader.hasNext()) {
|
||||
val labelIndex = reader.selectName(nameOptions)
|
||||
if (labelIndex == -1) {
|
||||
reader.skipName()
|
||||
reader.skipValue()
|
||||
continue
|
||||
}
|
||||
return labelIndex
|
||||
}
|
||||
|
||||
return -1
|
||||
// throw JsonDataException("Missing label for $labelKey")
|
||||
}
|
||||
|
||||
override fun toJson(writer: JsonWriter, value: Any?) {
|
||||
val type = value!!.javaClass
|
||||
val typeIndex = subTypes.indexOf(type)
|
||||
|
||||
if (typeIndex == -1) {
|
||||
throw JsonDataException("No matching name label for $value. Valid labels are $nameLabels")
|
||||
}
|
||||
|
||||
val adapter = jsonAdapters[typeIndex]
|
||||
|
||||
writer.beginObject()
|
||||
val flattenToken = writer.beginFlatten()
|
||||
adapter.toJson(writer, value)
|
||||
writer.endFlatten(flattenToken)
|
||||
writer.endObject()
|
||||
}
|
||||
|
||||
override fun toString(): String = "KeyBasedPolyJsonAdapterFactory($nameLabels)"
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@CheckReturnValue
|
||||
fun <T> of(baseType: Class<T>): NameBasedPolyJsonAdapterFactory<T> = NameBasedPolyJsonAdapterFactory(
|
||||
baseType
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package eu.darken.capod.common.serialization
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
@@ -14,14 +12,6 @@ import javax.inject.Singleton
|
||||
@Module
|
||||
class SerializationModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun moshi(): Moshi = Moshi.Builder().apply {
|
||||
add(JavaInstantAdapter())
|
||||
add(ByteArrayAdapter())
|
||||
add(DeviceProfile.MOSHI_FACTORY)
|
||||
}.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@SerializationCapod
|
||||
@@ -29,6 +19,7 @@ class SerializationModule {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
classDiscriminator = "type"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,18 +58,26 @@ fun SettingsBaseItem(
|
||||
Color.Unspecified
|
||||
}
|
||||
|
||||
val iconModifier = if (subtitle != null) {
|
||||
Modifier
|
||||
.align(Alignment.Top)
|
||||
.padding(top = 2.dp)
|
||||
.size(iconSize)
|
||||
} else {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
modifier = iconModifier,
|
||||
tint = tint,
|
||||
)
|
||||
} else if (iconPainter != null) {
|
||||
Icon(
|
||||
painter = iconPainter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
modifier = iconModifier,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package eu.darken.capod.common.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
@@ -26,6 +29,7 @@ fun SettingsSliderItem(
|
||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||
steps: Int = 0,
|
||||
enabled: Boolean = true,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
valueLabel: ((Float) -> String)? = null,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
@@ -35,28 +39,33 @@ fun SettingsSliderItem(
|
||||
subtitle = subtitle,
|
||||
onClick = {},
|
||||
enabled = enabled,
|
||||
trailingContent = if (valueLabel != null) {
|
||||
{
|
||||
Text(
|
||||
text = valueLabel(value),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
enabled = enabled,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 56.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
onValueChangeFinished = onValueChangeFinished,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (valueLabel != null) {
|
||||
Text(
|
||||
text = valueLabel(value),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
|
||||
modifier = Modifier.width(42.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
package eu.darken.capod.common.theming
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class ThemeColor(
|
||||
@StringRes val labelRes: Int
|
||||
) {
|
||||
@SerialName("theme.color.blue") @Json(name = "theme.color.blue") BLUE(R.string.ui_theme_color_blue_label),
|
||||
@SerialName("theme.color.green") @Json(name = "theme.color.green") GREEN(R.string.ui_theme_color_green_label),
|
||||
@SerialName("theme.color.amber") @Json(name = "theme.color.amber") AMBER(R.string.ui_theme_color_amber_label),
|
||||
@SerialName("theme.color.blue") BLUE(R.string.ui_theme_color_blue_label),
|
||||
@SerialName("theme.color.green") GREEN(R.string.ui_theme_color_green_label),
|
||||
@SerialName("theme.color.amber") AMBER(R.string.ui_theme_color_amber_label),
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
package eu.darken.capod.common.theming
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class ThemeMode(
|
||||
@StringRes val labelRes: Int
|
||||
) {
|
||||
@SerialName("theme.mode.system") @Json(name = "theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label),
|
||||
@SerialName("theme.mode.dark") @Json(name = "theme.mode.dark") DARK(R.string.ui_theme_mode_dark_label),
|
||||
@SerialName("theme.mode.light") @Json(name = "theme.mode.light") LIGHT(R.string.ui_theme_mode_light_label),
|
||||
@SerialName("theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label),
|
||||
@SerialName("theme.mode.dark") DARK(R.string.ui_theme_mode_dark_label),
|
||||
@SerialName("theme.mode.light") LIGHT(R.string.ui_theme_mode_light_label),
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
package eu.darken.capod.common.theming
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class ThemeStyle(
|
||||
@StringRes val labelRes: Int
|
||||
) {
|
||||
@SerialName("theme.style.default") @Json(name = "theme.style.default") DEFAULT(R.string.ui_theme_style_default_label),
|
||||
@SerialName("theme.style.materialyou") @Json(name = "theme.style.materialyou") MATERIAL_YOU(R.string.ui_theme_style_materialyou_label),
|
||||
@SerialName("theme.style.mediumcontrast") @Json(name = "theme.style.mediumcontrast") MEDIUM_CONTRAST(R.string.ui_theme_style_medium_contrast_label),
|
||||
@SerialName("theme.style.highcontrast") @Json(name = "theme.style.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label),
|
||||
@SerialName("theme.style.default") DEFAULT(R.string.ui_theme_style_default_label),
|
||||
@SerialName("theme.style.materialyou") MATERIAL_YOU(R.string.ui_theme_style_materialyou_label),
|
||||
@SerialName("theme.style.mediumcontrast") MEDIUM_CONTRAST(R.string.ui_theme_style_medium_contrast_label),
|
||||
@SerialName("theme.style.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label),
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import eu.darken.capod.common.theming.ThemeColor
|
||||
import eu.darken.capod.common.theming.ThemeMode
|
||||
import eu.darken.capod.common.theming.ThemeStyle
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
||||
import kotlinx.serialization.builtins.nullable
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
@@ -54,7 +54,8 @@ class GeneralSettings @Inject constructor(
|
||||
writer = { value -> value },
|
||||
)
|
||||
|
||||
val oldMainDeviceModel = dataStore.createValue("core.maindevice.model", PodDevice.Model.UNKNOWN, json, onErrorFallbackToDefault = true)
|
||||
val oldMainDeviceModel =
|
||||
dataStore.createValue("core.maindevice.model", PodModel.UNKNOWN, json, onErrorFallbackToDefault = true)
|
||||
|
||||
val oldMainDeviceIdentityKey = dataStore.createValue<IdentityResolvingKey?>(
|
||||
key = "core.maindevice.identitykey",
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
package eu.darken.capod.main.core
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class MonitorMode(
|
||||
@StringRes val labelRes: Int
|
||||
) {
|
||||
@SerialName("monitor.mode.manual") @Json(name = "monitor.mode.manual") MANUAL(
|
||||
@SerialName("monitor.mode.manual") MANUAL(
|
||||
R.string.settings_monitor_mode_manual_label
|
||||
),
|
||||
@SerialName("monitor.mode.automatic") @Json(name = "monitor.mode.automatic") AUTOMATIC(
|
||||
@SerialName("monitor.mode.automatic") AUTOMATIC(
|
||||
R.string.settings_monitor_mode_automatic_label
|
||||
),
|
||||
@SerialName("monitor.mode.always") @Json(name = "monitor.mode.always") ALWAYS(
|
||||
@SerialName("monitor.mode.always") ALWAYS(
|
||||
R.string.settings_monitor_mode_always_label
|
||||
),
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import eu.darken.capod.reaction.core.ReactionSettings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -42,6 +43,9 @@ class PermissionTool @Inject constructor(
|
||||
}
|
||||
.onEach { log(TAG) { "Missing permission: $it" } }
|
||||
|
||||
val missingScanPermissions: Flow<Set<Permission>> = missingPermissions
|
||||
.map { perms -> perms.filter { it.isScanBlocking }.toSet() }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("PermissionTool")
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -78,6 +80,10 @@ class MainActivity : Activity2() {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
navigationEntries.forEach { entry ->
|
||||
entry.apply {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.darken.capod.main.ui.devicesettings
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class DeviceSettingsNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Main.DeviceSettings> { key ->
|
||||
DeviceSettingsScreenHost(address = key.address)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class Mod {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bind(entry: DeviceSettingsNavigation): NavigationEntry
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
package eu.darken.capod.main.ui.devicesettings
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DeviceSettingsViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
private val targetAddress = MutableStateFlow<BluetoothAddress?>(null)
|
||||
private var initialized = false
|
||||
|
||||
fun initialize(address: BluetoothAddress) {
|
||||
if (initialized && targetAddress.value == address) return
|
||||
initialized = true
|
||||
targetAddress.value = address
|
||||
}
|
||||
|
||||
private val updateTicker = channelFlow<Unit> {
|
||||
while (isActive) {
|
||||
trySend(Unit)
|
||||
delay(3000)
|
||||
}
|
||||
}
|
||||
|
||||
private val isForceConnecting = MutableStateFlow(false)
|
||||
|
||||
sealed interface Event {
|
||||
data object OpenBluetoothSettings : Event
|
||||
data class SendFailed(val command: AapCommand, val message: String?) : Event
|
||||
data object SystemRenameUnavailable : Event
|
||||
}
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
|
||||
val state = targetAddress.flatMapLatest { address ->
|
||||
if (address == null) return@flatMapLatest flowOf(State(device = null))
|
||||
combine(
|
||||
updateTicker,
|
||||
deviceMonitor.devices,
|
||||
upgradeRepo.upgradeInfo,
|
||||
isForceConnecting,
|
||||
) { _, devices, upgrade, forcing ->
|
||||
State(
|
||||
device = devices.firstOrNull { it.address == address },
|
||||
now = Instant.now(),
|
||||
isPro = upgrade.isPro,
|
||||
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
|
||||
isForceConnecting = forcing,
|
||||
)
|
||||
}
|
||||
}.asLiveState()
|
||||
|
||||
data class State(
|
||||
val device: PodDevice?,
|
||||
val now: Instant = Instant.now(),
|
||||
val isPro: Boolean = false,
|
||||
val isNudgeAvailable: Boolean = true,
|
||||
val isForceConnecting: Boolean = false,
|
||||
)
|
||||
|
||||
fun forceConnect() = launch {
|
||||
if (!isForceConnecting.compareAndSet(expect = false, update = true)) {
|
||||
log(TAG) { "forceConnect already in progress" }
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val address = targetAddress.value ?: run {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
val bonded = try {
|
||||
bluetoothManager.bondedDevices().first().firstOrNull { it.address == address }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "bondedDevices() failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
if (bonded == null) {
|
||||
log(TAG, WARN) { "No bonded device for $address — opening Bluetooth settings" }
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
if (!bluetoothManager.isNudgeAvailable) {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
val accepted = try {
|
||||
bluetoothManager.nudgeConnection(bonded)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
|
||||
false
|
||||
}
|
||||
log(TAG) { "nudgeConnection($bonded) accepted=$accepted" }
|
||||
if (!accepted) {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
}
|
||||
// On accepted=true, AapAutoConnect.initialConnect() will pick up the new
|
||||
// connectedDevices entry and trigger the AAP handshake. The card disappears
|
||||
// when device.isAapConnected becomes true.
|
||||
} finally {
|
||||
isForceConnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendInternal(command: AapCommand) {
|
||||
val address = targetAddress.value ?: return
|
||||
try {
|
||||
aapManager.sendCommand(address, command)
|
||||
log(TAG) { "Sent $command to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to send $command: ${e.message}" }
|
||||
// SingleEventFlow is backed by a BUFFERED Channel — use the suspending emit to avoid
|
||||
// dropping the event under momentary backpressure.
|
||||
events.emit(Event.SendFailed(command, e.message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(command: AapCommand) {
|
||||
launch { sendInternal(command) }
|
||||
}
|
||||
|
||||
private fun sendProGated(command: AapCommand) = launch {
|
||||
if (upgradeRepo.isPro()) {
|
||||
sendInternal(command)
|
||||
} else {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAncMode(mode: AapSetting.AncMode.Value) = send(AapCommand.SetAncMode(mode))
|
||||
|
||||
fun setConversationalAwareness(enabled: Boolean) = send(AapCommand.SetConversationalAwareness(enabled))
|
||||
|
||||
fun setNcWithOneAirPod(enabled: Boolean) = send(AapCommand.SetNcWithOneAirPod(enabled))
|
||||
|
||||
fun setPersonalizedVolume(enabled: Boolean) = send(AapCommand.SetPersonalizedVolume(enabled))
|
||||
|
||||
fun setToneVolume(level: Int) = send(AapCommand.SetToneVolume(level))
|
||||
|
||||
fun setAdaptiveAudioNoise(level: Int) = send(AapCommand.SetAdaptiveAudioNoise(level))
|
||||
|
||||
fun setPressSpeed(value: AapSetting.PressSpeed.Value) = send(AapCommand.SetPressSpeed(value))
|
||||
|
||||
fun setPressHoldDuration(value: AapSetting.PressHoldDuration.Value) = send(AapCommand.SetPressHoldDuration(value))
|
||||
|
||||
fun setVolumeSwipe(enabled: Boolean) = send(AapCommand.SetVolumeSwipe(enabled))
|
||||
|
||||
fun setVolumeSwipeLength(value: AapSetting.VolumeSwipeLength.Value) = send(AapCommand.SetVolumeSwipeLength(value))
|
||||
|
||||
fun setEndCallMuteMic(
|
||||
muteMic: AapSetting.EndCallMuteMic.MuteMicMode,
|
||||
endCall: AapSetting.EndCallMuteMic.EndCallMode,
|
||||
) = send(AapCommand.SetEndCallMuteMic(muteMic, endCall))
|
||||
|
||||
fun setMicrophoneMode(mode: AapSetting.MicrophoneMode.Mode) = send(AapCommand.SetMicrophoneMode(mode))
|
||||
|
||||
fun setEarDetectionEnabled(enabled: Boolean) = send(AapCommand.SetEarDetectionEnabled(enabled))
|
||||
|
||||
fun setListeningModeCycle(modeMask: Int) = sendProGated(AapCommand.SetListeningModeCycle(modeMask))
|
||||
|
||||
fun setAllowOffOption(enabled: Boolean) = sendProGated(AapCommand.SetAllowOffOption(enabled))
|
||||
|
||||
fun setSleepDetection(enabled: Boolean) = sendProGated(AapCommand.SetSleepDetection(enabled))
|
||||
|
||||
fun setDeviceName(name: String) = launch {
|
||||
val address = targetAddress.value ?: return@launch
|
||||
sendInternal(AapCommand.SetDeviceName(name))
|
||||
|
||||
// Also try to update the Android-local bond alias so the new name shows in Android's
|
||||
// Bluetooth settings too. The AAP rename only changes what the AirPods themselves report;
|
||||
// Android's system display reads from the bond database and needs a separate update.
|
||||
val bonded = try {
|
||||
bluetoothManager.bondedDevices().first().firstOrNull { it.address == address }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "bondedDevices() failed while renaming: ${e.message}" }
|
||||
null
|
||||
}
|
||||
val aliasOk = bonded?.let { bluetoothManager.setDeviceAlias(it, name) } ?: false
|
||||
if (!aliasOk) {
|
||||
log(TAG, WARN) { "System bond alias rename failed for $address — user must rename in system settings or re-pair" }
|
||||
events.emit(Event.SystemRenameUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
fun navToStemConfig() = launch {
|
||||
if (upgradeRepo.isPro()) {
|
||||
navTo(Nav.Main.StemActionConfig)
|
||||
} else {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("DeviceSettings", "VM")
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.DevicesOther
|
||||
import androidx.compose.material.icons.twotone.Settings
|
||||
import androidx.compose.material.icons.twotone.Stars
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -24,10 +27,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.DevicesOther
|
||||
import androidx.compose.material.icons.twotone.Settings
|
||||
import androidx.compose.material.icons.twotone.Stars
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
@@ -37,16 +36,17 @@ import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
|
||||
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
|
||||
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||
@@ -54,9 +54,9 @@ import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
@@ -129,6 +129,8 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
onSettings = { vm.goToSettings() },
|
||||
onUpgrade = { vm.onUpgrade() },
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,6 +142,8 @@ fun OverviewScreen(
|
||||
onSettings: () -> Unit,
|
||||
onUpgrade: () -> Unit,
|
||||
onToggleUnmatched: () -> Unit,
|
||||
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
||||
onDeviceSettings: (PodDevice) -> Unit = {},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -195,7 +199,7 @@ fun OverviewScreen(
|
||||
) {
|
||||
// 1. Permission cards
|
||||
items(
|
||||
items = state.permissions.toList(),
|
||||
items = state.permissions.sortedByDescending { it.isScanBlocking },
|
||||
key = { it.permissionId },
|
||||
) { permission ->
|
||||
PermissionCard(
|
||||
@@ -205,26 +209,43 @@ fun OverviewScreen(
|
||||
}
|
||||
|
||||
// 2. Bluetooth disabled card
|
||||
if (!state.isBluetoothEnabled && state.permissions.isEmpty()) {
|
||||
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
|
||||
item(key = "bluetooth_disabled") {
|
||||
BluetoothDisabledCard()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No profiles card
|
||||
if (state.profiles.isEmpty() && state.permissions.isEmpty() && state.isBluetoothEnabled) {
|
||||
if (state.profiles.isEmpty() && !state.isScanBlocked && state.isBluetoothEnabled) {
|
||||
item(key = "no_profiles") {
|
||||
NoProfilesCard(onManageDevices = onManageDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Profiled device cards
|
||||
if (state.permissions.isEmpty() && state.isBluetoothEnabled) {
|
||||
// 4. Profiled device cards (limited to 1 for free users)
|
||||
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||
items(
|
||||
items = state.profiledDevices,
|
||||
key = { it.identifier.hashCode() },
|
||||
items = state.visibleProfiledDevices,
|
||||
key = { it.identifier?.toString() ?: it.hashCode() },
|
||||
) { device ->
|
||||
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
showDebug = state.isDebugMode,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onDeviceSettings = { onDeviceSettings(device) },
|
||||
)
|
||||
}
|
||||
|
||||
// 4b. Upgrade card when additional devices are hidden
|
||||
if (state.hiddenProfiledDeviceCount > 0) {
|
||||
item(key = "device_limit_upgrade") {
|
||||
DeviceLimitUpgradeCard(
|
||||
hiddenCount = state.hiddenProfiledDeviceCount,
|
||||
upgradeType = state.upgradeInfo.type,
|
||||
onUpgrade = onUpgrade,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Monitoring active card
|
||||
@@ -247,9 +268,15 @@ fun OverviewScreen(
|
||||
if (state.showUnmatchedDevices) {
|
||||
items(
|
||||
items = state.unmatchedDevices,
|
||||
key = { "unmatched_${it.identifier.hashCode()}" },
|
||||
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
|
||||
) { device ->
|
||||
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
showDebug = state.isDebugMode,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onDeviceSettings = { onDeviceSettings(device) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,11 +286,25 @@ fun OverviewScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) {
|
||||
when (device) {
|
||||
is DualPodDevice -> DualPodsCard(device = device, showDebug = showDebug, now = now)
|
||||
is SinglePodDevice -> SinglePodsCard(device = device, showDebug = showDebug, now = now)
|
||||
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
|
||||
private fun PodDeviceCard(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
||||
onDeviceSettings: (() -> Unit)? = null,
|
||||
) {
|
||||
when {
|
||||
device.hasDualPods -> DualPodsCard(
|
||||
device = device, showDebug = showDebug, now = now,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onDeviceSettings = onDeviceSettings,
|
||||
)
|
||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(
|
||||
device = device, showDebug = showDebug, now = now,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onDeviceSettings = onDeviceSettings,
|
||||
)
|
||||
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,15 +316,15 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
now = Instant.now(),
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.airPodsProMixed(),
|
||||
MockPodDataProvider.airPodsMax(),
|
||||
MockPodDataProvider.unknownDevice(),
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
@@ -306,7 +347,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodDevice.Model.AIRPODS_PRO2)),
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
),
|
||||
@@ -350,7 +391,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = false,
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodDevice.Model.AIRPODS_PRO2)),
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
|
||||
showUnmatchedDevices = false,
|
||||
),
|
||||
|
||||
@@ -3,12 +3,12 @@ package eu.darken.capod.main.ui.overview
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.flow.combine
|
||||
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
@@ -17,12 +17,14 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -33,39 +35,46 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
|
||||
@HiltViewModel
|
||||
class OverviewViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val monitorControl: MonitorControl,
|
||||
private val podMonitor: PodMonitor,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val generalSettings: GeneralSettings,
|
||||
debugSettings: DebugSettings,
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val aapManager: AapConnectionManager,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
val requestPermissionEvent = SingleEventFlow<Permission>()
|
||||
|
||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||
|
||||
val workerAutolaunch = permissionTool.missingPermissions
|
||||
val workerAutolaunch = permissionTool.missingScanPermissions
|
||||
.onEach { permissions ->
|
||||
if (permissions.isNotEmpty()) {
|
||||
log(TAG) { "Missing permissions: $permissions" }
|
||||
log(TAG) { "Missing scan permissions: $permissions" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
|
||||
MonitorMode.MANUAL -> false
|
||||
MonitorMode.AUTOMATIC -> {
|
||||
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
|
||||
devices?.isNotEmpty() == true
|
||||
try {
|
||||
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
|
||||
devices?.isNotEmpty() == true
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
MonitorMode.ALWAYS -> true
|
||||
}
|
||||
if (shouldStart) {
|
||||
@@ -82,12 +91,12 @@ class OverviewViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private val pods = permissionTool.missingPermissions
|
||||
private val pods = permissionTool.missingScanPermissions
|
||||
.flatMapLatest { permissions ->
|
||||
if (permissions.isNotEmpty()) {
|
||||
return@flatMapLatest flowOf(emptyList())
|
||||
}
|
||||
podMonitor.devices
|
||||
deviceMonitor.devices
|
||||
}
|
||||
.catch { errorEvents.emitBlocking(it) }
|
||||
.throttleLatest(1000)
|
||||
@@ -124,8 +133,12 @@ class OverviewViewModel @Inject constructor(
|
||||
val upgradeInfo: UpgradeRepo.Info,
|
||||
val showUnmatchedDevices: Boolean,
|
||||
) {
|
||||
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
|
||||
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
|
||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||
val profiledDevices: List<PodDevice> get() = devices.filter { it.profileId != null }
|
||||
val visibleProfiledDevices: List<PodDevice>
|
||||
get() = if (upgradeInfo.isPro) profiledDevices else profiledDevices.take(FREE_DEVICE_LIMIT)
|
||||
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
|
||||
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
|
||||
}
|
||||
|
||||
fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) {
|
||||
@@ -140,6 +153,11 @@ class OverviewViewModel @Inject constructor(
|
||||
navTo(Nav.Main.DeviceManager)
|
||||
}
|
||||
|
||||
fun goToDeviceSettings(device: PodDevice) {
|
||||
val address = device.address ?: return
|
||||
navTo(Nav.Main.DeviceSettings(address))
|
||||
}
|
||||
|
||||
fun onUpgrade() {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
}
|
||||
@@ -152,7 +170,20 @@ class OverviewViewModel @Inject constructor(
|
||||
requestPermissionEvent.tryEmit(permission)
|
||||
}
|
||||
|
||||
fun setAncMode(device: PodDevice, mode: AapSetting.AncMode.Value) {
|
||||
val address = device.address ?: return
|
||||
launch {
|
||||
try {
|
||||
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
|
||||
log(TAG) { "ANC mode set to $mode for $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to set ANC mode: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FREE_DEVICE_LIMIT = 1
|
||||
private val TAG = logTag("Overview", "VM")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Stars
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
|
||||
@Composable
|
||||
fun DeviceLimitUpgradeCard(
|
||||
hiddenCount: Int,
|
||||
upgradeType: UpgradeRepo.Type,
|
||||
onUpgrade: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.overview_more_devices_upgrade, hiddenCount, hiddenCount),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_capod_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = onUpgrade,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Stars,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
when (upgradeType) {
|
||||
UpgradeRepo.Type.FOSS -> R.string.general_donate_action
|
||||
UpgradeRepo.Type.GPLAY -> R.string.general_upgrade_action
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceLimitUpgradeCardGplayPreview() = PreviewWrapper {
|
||||
DeviceLimitUpgradeCard(
|
||||
hiddenCount = 2,
|
||||
upgradeType = UpgradeRepo.Type.GPLAY,
|
||||
onUpgrade = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceLimitUpgradeCardFossPreview() = PreviewWrapper {
|
||||
DeviceLimitUpgradeCard(
|
||||
hiddenCount = 1,
|
||||
upgradeType = UpgradeRepo.Type.FOSS,
|
||||
onUpgrade = {},
|
||||
)
|
||||
}
|
||||
@@ -18,15 +18,15 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.GridView
|
||||
import androidx.compose.material.icons.twotone.Key
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -44,28 +45,24 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.HasPodStyle
|
||||
import eu.darken.capod.pods.core.HasStateDetection
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getSignalQuality
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Duration
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun DualPodsCard(
|
||||
device: DualPodDevice,
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onDeviceSettings: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -87,73 +84,79 @@ fun DualPodsCard(
|
||||
Image(
|
||||
painter = painterResource(device.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = device.meta.profile?.label ?: "?",
|
||||
text = device.label ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
SignalIndicator(
|
||||
signalQuality = device.rssiQuality,
|
||||
isLive = device.isLive,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
if (device is ApplePods && device.meta.isIRKMatch) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = if (device.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
val deviceLabel = buildString {
|
||||
append(device.getLabel(context))
|
||||
if (device is HasPodStyle && showDebug) {
|
||||
append(" (${device.podStyle.getColor(context)})")
|
||||
val podStyle = device.ble as? HasPodStyle
|
||||
if (podStyle != null && showDebug) {
|
||||
append(" (${podStyle.podStyle.getColor(context)})")
|
||||
}
|
||||
if (device is DualApplePods && showDebug) {
|
||||
append(" [${device.primaryPod.name}]")
|
||||
val dualApple = device.ble as? DualApplePods
|
||||
if (dualApple != null && showDebug) {
|
||||
append(" [${dualApple.primaryPod.name}]")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = deviceLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = deviceLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
DeviceConnectionBadge(
|
||||
state = device.connectionState,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SignalBadge(signalText = device.getSignalQuality(context))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Timestamps
|
||||
Text(
|
||||
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
|
||||
Text(
|
||||
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (device.address != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Circular battery gauges side by side
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 1.dp,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -164,31 +167,31 @@ fun DualPodsCard(
|
||||
) {
|
||||
PodGauge(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeftPodPercent,
|
||||
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
|
||||
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
|
||||
showEarDetection = device is HasEarDetectionDual,
|
||||
isMicrophone = (device as? HasDualMicrophone)?.isLeftPodMicrophone ?: false,
|
||||
showMicrophone = device is HasDualMicrophone,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
isCharging = device.isLeftPodCharging ?: false,
|
||||
isInEar = device.isLeftInEar ?: false,
|
||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
PodGauge(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRightPodPercent,
|
||||
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
|
||||
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
|
||||
showEarDetection = device is HasEarDetectionDual,
|
||||
isMicrophone = (device as? HasDualMicrophone)?.isRightPodMicrophone ?: false,
|
||||
showMicrophone = device is HasDualMicrophone,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
isCharging = device.isRightPodCharging ?: false,
|
||||
isInEar = device.isRightInEar ?: false,
|
||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
// Case row
|
||||
if (device is HasCase) {
|
||||
if (device.hasCase) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
)
|
||||
|
||||
@@ -197,13 +200,37 @@ fun DualPodsCard(
|
||||
}
|
||||
}
|
||||
|
||||
// Connection state
|
||||
if (device is HasStateDetection) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// Cached battery indicator
|
||||
if (device.isBatteryCached && !device.isAapReady) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = device.state.getLabel(context),
|
||||
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
|
||||
// ANC mode selector
|
||||
val ancMode = device.ancMode
|
||||
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val cycleMask = (device.listeningModeCycle?.modeMask ?: 0x0E)
|
||||
val cycleBits = mapOf(
|
||||
AapSetting.AncMode.Value.OFF to 0x01,
|
||||
AapSetting.AncMode.Value.ON to 0x02,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
|
||||
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
|
||||
)
|
||||
val visibleModes = ancMode.supported.filter { mode ->
|
||||
val bit = cycleBits[mode] ?: return@filter true
|
||||
(cycleMask and bit) != 0
|
||||
}
|
||||
AncModeSelector(
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = visibleModes,
|
||||
onModeSelected = { onAncModeChange?.invoke(it) },
|
||||
pendingMode = device.pendingAncMode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -218,7 +245,7 @@ fun DualPodsCard(
|
||||
@Composable
|
||||
private fun PodGauge(
|
||||
iconRes: Int,
|
||||
batteryPercent: Float?,
|
||||
batteryPercent: Float,
|
||||
isCharging: Boolean,
|
||||
isInEar: Boolean,
|
||||
showEarDetection: Boolean,
|
||||
@@ -227,15 +254,15 @@ private fun PodGauge(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clamped = batteryPercent?.coerceIn(0f, 1f)
|
||||
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = if (clamped >= 0f) clamped else 0f,
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "gaugeProgress",
|
||||
)
|
||||
|
||||
val ringColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
@@ -248,12 +275,12 @@ private fun PodGauge(
|
||||
// Ring with icon inside
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(80.dp),
|
||||
modifier = Modifier.size(68.dp),
|
||||
) {
|
||||
// Track ring
|
||||
CircularProgressIndicator(
|
||||
progress = { 1f },
|
||||
modifier = Modifier.size(80.dp),
|
||||
modifier = Modifier.size(68.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeWidth = 6.dp,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -261,10 +288,10 @@ private fun PodGauge(
|
||||
)
|
||||
|
||||
// Progress ring
|
||||
if (clamped != null) {
|
||||
if (clamped >= 0f) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(80.dp),
|
||||
modifier = Modifier.size(68.dp),
|
||||
color = ringColor,
|
||||
strokeWidth = 6.dp,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -284,9 +311,9 @@ private fun PodGauge(
|
||||
|
||||
// Battery percentage
|
||||
Text(
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (batteryPercent != null) {
|
||||
color = if (batteryPercent >= 0f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -312,7 +339,7 @@ private fun PodGauge(
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CaseRow(
|
||||
device: HasCase,
|
||||
device: PodDevice,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -329,13 +356,13 @@ private fun CaseRow(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryCasePercent),
|
||||
text = formatBatteryPercent(context, device.batteryCase),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCasePercent,
|
||||
percent = device.batteryCase.toBatteryFloat(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp),
|
||||
@@ -347,25 +374,23 @@ private fun CaseRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (device.isCaseCharging) {
|
||||
if (device.isCaseCharging == true) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
}
|
||||
|
||||
if (device is DualApplePods) {
|
||||
val lidState = device.caseLidState
|
||||
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.GridView,
|
||||
label = when (lidState) {
|
||||
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
|
||||
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
|
||||
else -> ""
|
||||
},
|
||||
)
|
||||
}
|
||||
val lidState = device.caseLidState
|
||||
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.GridView,
|
||||
label = when (lidState) {
|
||||
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
|
||||
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
|
||||
else -> ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,42 +398,31 @@ private fun CaseRow(
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardFullChargePreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProFullCharge(), showDebug = false, now = Instant.now())
|
||||
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||
showDebug = false,
|
||||
now = Instant.now(),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardMixedBatteryPreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProMixed(), showDebug = false, now = Instant.now())
|
||||
private fun DualPodsCardMinimalPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodMonitored(),
|
||||
showDebug = false,
|
||||
now = Instant.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardLowBatteryPreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProLowBattery(), showDebug = false, now = Instant.now())
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardInCasePreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProInCase(), showDebug = false, now = Instant.now())
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardDebugPreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProMixed(), showDebug = true, now = Instant.now())
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardNoCasePreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.powerBeatsPro(), showDebug = false, now = Instant.now())
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardMicrophonePreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsGen1Wearing(), showDebug = false, now = Instant.now())
|
||||
private fun DualPodsCardCachedPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodCachedOnly(),
|
||||
showDebug = false,
|
||||
now = Instant.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -24,10 +25,17 @@ fun PermissionCard(
|
||||
permission: Permission,
|
||||
onRequest: (Permission) -> Unit,
|
||||
) {
|
||||
val colors = if (permission.isScanBlocking) {
|
||||
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
|
||||
} else {
|
||||
CardDefaults.cardColors()
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
colors = colors,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -20,38 +22,59 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.Bluetooth
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.KeyboardVoice
|
||||
import androidx.compose.material.icons.twotone.LinkOff
|
||||
import androidx.compose.material.icons.twotone.Lock
|
||||
import androidx.compose.material.icons.twotone.SettingsInputAntenna
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.monitor.core.BleKeyState
|
||||
import eu.darken.capod.monitor.core.ConnectionState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
private val CapsuleShape = RoundedCornerShape(6.dp)
|
||||
|
||||
@Composable
|
||||
fun BatteryCapsule(
|
||||
percent: Float?,
|
||||
percent: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val clamped = percent?.coerceIn(0f, 1f)
|
||||
val clamped = if (percent >= 0f) percent.coerceIn(0f, 1f) else -1f
|
||||
val animatedFraction by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = if (clamped >= 0f) clamped else 0f,
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "batteryFill",
|
||||
)
|
||||
|
||||
val barColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
@@ -62,7 +85,7 @@ fun BatteryCapsule(
|
||||
.clip(CapsuleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
if (clamped != null) {
|
||||
if (clamped >= 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
@@ -144,11 +167,89 @@ fun StatusChipRow(
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal enum class SignalLevel { DISCONNECTED, BARS_0, BARS_1, BARS_2, BARS_3, BARS_4 }
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun signalLevelOf(signalQuality: Float, isLive: Boolean): SignalLevel {
|
||||
if (!isLive) return SignalLevel.DISCONNECTED
|
||||
if (!signalQuality.isFinite()) return SignalLevel.BARS_0
|
||||
val q = signalQuality.coerceIn(0f, 1f)
|
||||
return when {
|
||||
q >= 0.80f -> SignalLevel.BARS_4
|
||||
q >= 0.60f -> SignalLevel.BARS_3
|
||||
q >= 0.40f -> SignalLevel.BARS_2
|
||||
q >= 0.20f -> SignalLevel.BARS_1
|
||||
else -> SignalLevel.BARS_0
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SignalBadge(
|
||||
signalText: String,
|
||||
fun SignalIndicator(
|
||||
signalQuality: Float,
|
||||
isLive: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val level = signalLevelOf(signalQuality, isLive)
|
||||
val description = when (level) {
|
||||
SignalLevel.DISCONNECTED -> stringResource(R.string.signal_indicator_disconnected_cd)
|
||||
SignalLevel.BARS_0 -> stringResource(R.string.signal_indicator_bars_cd, 0)
|
||||
SignalLevel.BARS_1 -> stringResource(R.string.signal_indicator_bars_cd, 1)
|
||||
SignalLevel.BARS_2 -> stringResource(R.string.signal_indicator_bars_cd, 2)
|
||||
SignalLevel.BARS_3 -> stringResource(R.string.signal_indicator_bars_cd, 3)
|
||||
SignalLevel.BARS_4 -> stringResource(R.string.signal_indicator_bars_cd, 4)
|
||||
}
|
||||
val tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val rootModifier = modifier
|
||||
.size(12.dp)
|
||||
.semantics(mergeDescendants = true) { contentDescription = description }
|
||||
|
||||
if (level == SignalLevel.DISCONNECTED) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.LinkOff,
|
||||
contentDescription = null,
|
||||
modifier = rootModifier,
|
||||
tint = tint,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val activeBars = when (level) {
|
||||
SignalLevel.BARS_0 -> 0
|
||||
SignalLevel.BARS_1 -> 1
|
||||
SignalLevel.BARS_2 -> 2
|
||||
SignalLevel.BARS_3 -> 3
|
||||
SignalLevel.BARS_4 -> 4
|
||||
SignalLevel.DISCONNECTED -> 0 // unreachable; handled above
|
||||
}
|
||||
val inactiveColor = tint.copy(alpha = 0.3f)
|
||||
Canvas(modifier = rootModifier) {
|
||||
val barCount = 4
|
||||
val gapWidth = size.width * 0.12f
|
||||
val totalGap = gapWidth * (barCount - 1)
|
||||
val barWidth = (size.width - totalGap) / barCount
|
||||
val corner = CornerRadius(barWidth * 0.4f, barWidth * 0.4f)
|
||||
for (i in 0 until barCount) {
|
||||
val heightFraction = (i + 1).toFloat() / barCount
|
||||
val barHeight = size.height * heightFraction
|
||||
val x = i * (barWidth + gapWidth)
|
||||
val y = size.height - barHeight
|
||||
drawRoundRect(
|
||||
color = if (i < activeBars) tint else inactiveColor,
|
||||
topLeft = Offset(x, y),
|
||||
size = Size(barWidth, barHeight),
|
||||
cornerRadius = corner,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeviceConnectionBadge(
|
||||
state: ConnectionState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!state.hasBleData && state.bleKeyState == BleKeyState.NONE && !state.isAapConnected) return
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -157,19 +258,40 @@ fun SignalBadge(
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.SettingsInputAntenna,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(3.dp))
|
||||
Text(
|
||||
text = signalText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (state.hasBleData) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.SettingsInputAntenna,
|
||||
contentDescription = stringResource(R.string.signal_badge_ble_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.bleKeyState != BleKeyState.NONE) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Key,
|
||||
contentDescription = stringResource(R.string.signal_badge_key_irk_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Lock,
|
||||
contentDescription = stringResource(R.string.signal_badge_key_encrypted_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.isAapConnected) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Bluetooth,
|
||||
contentDescription = stringResource(R.string.signal_badge_aap_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,3 +315,149 @@ fun DebugSection(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AncModeSelector(
|
||||
currentMode: AapSetting.AncMode.Value,
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
|
||||
pendingMode: AapSetting.AncMode.Value? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val displayMode = pendingMode ?: currentMode
|
||||
SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) {
|
||||
supportedModes.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = mode == displayMode,
|
||||
onClick = { onModeSelected(mode) },
|
||||
enabled = enabled,
|
||||
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
|
||||
colors = if (pendingMode != null && mode == displayMode) {
|
||||
SegmentedButtonDefaults.colors(
|
||||
activeContainerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
activeContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else {
|
||||
SegmentedButtonDefaults.colors()
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = when (mode) {
|
||||
AapSetting.AncMode.Value.OFF -> stringResource(R.string.anc_mode_off)
|
||||
AapSetting.AncMode.Value.ON -> stringResource(R.string.anc_mode_on)
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.anc_mode_transparency)
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.anc_mode_adaptive)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConversationAwarenessToggle(
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.conversation_awareness_label),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = true,
|
||||
bleKeyState = BleKeyState.IRK_AND_ENCRYPTED,
|
||||
isAapConnected = true,
|
||||
rssiQuality = 1f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeBleOnlyPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = true,
|
||||
bleKeyState = BleKeyState.NONE,
|
||||
isAapConnected = false,
|
||||
rssiQuality = 0.7f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeBleIrkPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = true,
|
||||
bleKeyState = BleKeyState.IRK_ONLY,
|
||||
isAapConnected = false,
|
||||
rssiQuality = 0.5f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeAapOnlyPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = false,
|
||||
bleKeyState = BleKeyState.NONE,
|
||||
isAapConnected = true,
|
||||
rssiQuality = 0f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeEmptyPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = false,
|
||||
bleKeyState = BleKeyState.NONE,
|
||||
isAapConnected = false,
|
||||
rssiQuality = 0f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SignalIndicatorPreviewsGroup() = PreviewWrapper {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SignalIndicator(signalQuality = 0.10f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.35f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.55f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.75f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.95f, isLive = true)
|
||||
SignalIndicator(signalQuality = Float.NaN, isLive = true)
|
||||
SignalIndicator(signalQuality = -0.3f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.85f, isLive = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -31,6 +34,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -41,26 +45,24 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.pods.core.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getSignalQuality
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Duration
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import java.time.Instant
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SinglePodsCard(
|
||||
device: SinglePodDevice,
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onDeviceSettings: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val clamped = device.batteryHeadsetPercent?.coerceIn(0f, 1f)
|
||||
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
@@ -92,53 +94,68 @@ fun SinglePodsCard(
|
||||
Image(
|
||||
painter = painterResource(device.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = device.meta.profile?.label ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = device.getLabel(context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = device.label ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
SignalIndicator(
|
||||
signalQuality = device.rssiQuality,
|
||||
isLive = device.isLive,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = device.getLabel(context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
DeviceConnectionBadge(
|
||||
state = device.connectionState,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SignalBadge(signalText = device.getSignalQuality(context))
|
||||
if (device.address != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Timestamps
|
||||
Text(
|
||||
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
|
||||
Text(
|
||||
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Central gauge
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 1.dp,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -148,12 +165,12 @@ fun SinglePodsCard(
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(100.dp),
|
||||
modifier = Modifier.size(88.dp),
|
||||
) {
|
||||
// Track ring
|
||||
CircularProgressIndicator(
|
||||
progress = { 1f },
|
||||
modifier = Modifier.size(100.dp),
|
||||
modifier = Modifier.size(88.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeWidth = 8.dp,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -164,7 +181,7 @@ fun SinglePodsCard(
|
||||
if (clamped != null) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(100.dp),
|
||||
modifier = Modifier.size(88.dp),
|
||||
color = ringColor,
|
||||
strokeWidth = 8.dp,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -174,9 +191,9 @@ fun SinglePodsCard(
|
||||
|
||||
// Battery text inside ring
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryHeadsetPercent),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = if (device.batteryHeadsetPercent != null) {
|
||||
text = formatBatteryPercent(context, device.batteryHeadset),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (device.batteryHeadset != null) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -192,13 +209,13 @@ fun SinglePodsCard(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (device is HasChargeDetection && device.isHeadsetBeingCharged) {
|
||||
if (device.isHeadsetBeingCharged == true) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
}
|
||||
if (device is HasEarDetection && device.isBeingWorn) {
|
||||
if (device.isBeingWorn == true) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.Hearing,
|
||||
label = stringResource(R.string.pods_inear_label),
|
||||
@@ -208,6 +225,40 @@ fun SinglePodsCard(
|
||||
}
|
||||
}
|
||||
|
||||
// Cached battery indicator
|
||||
if (device.isBatteryCached && !device.isAapReady) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
|
||||
// ANC mode selector
|
||||
val ancMode = device.ancMode
|
||||
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val cycleMask = (device.listeningModeCycle?.modeMask ?: 0x0E)
|
||||
val cycleBits = mapOf(
|
||||
AapSetting.AncMode.Value.OFF to 0x01,
|
||||
AapSetting.AncMode.Value.ON to 0x02,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
|
||||
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
|
||||
)
|
||||
val visibleModes = ancMode.supported.filter { mode ->
|
||||
val bit = cycleBits[mode] ?: return@filter true
|
||||
(cycleMask and bit) != 0
|
||||
}
|
||||
AncModeSelector(
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = visibleModes,
|
||||
onModeSelected = { onAncModeChange?.invoke(it) },
|
||||
pendingMode = device.pendingAncMode,
|
||||
)
|
||||
}
|
||||
|
||||
// Debug info
|
||||
if (showDebug) {
|
||||
DebugSection(rawDataHex = device.rawDataHex)
|
||||
@@ -218,18 +269,31 @@ fun SinglePodsCard(
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardWearingPreview() = PreviewWrapper {
|
||||
SinglePodsCard(device = MockPodDataProvider.airPodsMax(), showDebug = false, now = Instant.now())
|
||||
private fun SinglePodsCardFullPreview() = PreviewWrapper {
|
||||
SinglePodsCard(
|
||||
device = MockPodDataProvider.singlePodMonitoredWithAap(),
|
||||
showDebug = false,
|
||||
now = Instant.now(),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardChargingPreview() = PreviewWrapper {
|
||||
SinglePodsCard(device = MockPodDataProvider.airPodsMaxCharging(), showDebug = false, now = Instant.now())
|
||||
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
|
||||
SinglePodsCard(
|
||||
device = MockPodDataProvider.singlePodMonitored(),
|
||||
showDebug = false,
|
||||
now = Instant.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardDebugPreview() = PreviewWrapper {
|
||||
SinglePodsCard(device = MockPodDataProvider.beatsSolo3(), showDebug = true, now = Instant.now())
|
||||
private fun SinglePodsCardCachedPreview() = PreviewWrapper {
|
||||
SinglePodsCard(
|
||||
device = MockPodDataProvider.singlePodCachedOnly(),
|
||||
showDebug = false,
|
||||
now = Instant.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.SettingsInputAntenna
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -19,22 +14,19 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.getSignalQuality
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import java.time.Instant
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||
|
||||
@Composable
|
||||
fun UnknownPodDeviceCard(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -46,7 +38,7 @@ fun UnknownPodDeviceCard(
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
// Header with signal in top-right
|
||||
// Header: label + signal indicator (sits right after the text)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -54,37 +46,24 @@ fun UnknownPodDeviceCard(
|
||||
Text(
|
||||
text = device.getLabel(context),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = device.getSignalQuality(context),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.SettingsInputAntenna,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
SignalIndicator(
|
||||
signalQuality = device.rssiQuality,
|
||||
isLive = device.isLive,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = when (device) {
|
||||
is ApplePods -> stringResource(R.string.pods_unknown_contact_dev)
|
||||
else -> stringResource(R.string.pods_unknown_label)
|
||||
text = if (device.ble is ApplePods) {
|
||||
stringResource(R.string.pods_unknown_contact_dev)
|
||||
} else {
|
||||
stringResource(R.string.pods_unknown_label)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
@@ -110,11 +89,11 @@ fun UnknownPodDeviceCard(
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun UnknownPodDeviceCardPreview() = PreviewWrapper {
|
||||
UnknownPodDeviceCard(device = MockPodDataProvider.unknownDevice(), showDebug = false, now = Instant.now())
|
||||
UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = false)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun UnknownPodDeviceCardDebugPreview() = PreviewWrapper {
|
||||
UnknownPodDeviceCard(device = MockPodDataProvider.unknownDevice(), showDebug = true, now = Instant.now())
|
||||
UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = true)
|
||||
}
|
||||
|
||||
@@ -86,6 +86,13 @@ fun AcknowledgementsScreen(
|
||||
onClick = { onOpenUrl("https://github.com/steam3d/MagicPods-Windows") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "LibrePods project",
|
||||
subtitle = "Thanks to Kavish Devar for all the reverse engineering work.",
|
||||
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "FuriousMAC",
|
||||
@@ -124,6 +131,20 @@ fun AcknowledgementsScreen(
|
||||
onClick = { onOpenUrl("https://iconduck.com/sets/zwicon-icon-set") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "MagicPodsCore",
|
||||
subtitle = "Backend service for managing AirPods on Linux. (GPL-3.0)",
|
||||
onClick = { onOpenUrl("https://github.com/steam3d/MagicPodsCore") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "LibrePods",
|
||||
subtitle = "Unlocking AirPods features on non-Apple devices. (GPL-3.0)",
|
||||
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Kotlin",
|
||||
@@ -138,13 +159,6 @@ fun AcknowledgementsScreen(
|
||||
onClick = { onOpenUrl("https://github.com/google/dagger") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Moshi",
|
||||
subtitle = "A modern JSON library for Kotlin and Java. (APACHE 2.0)",
|
||||
onClick = { onOpenUrl("https://github.com/square/moshi") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Android",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.darken.capod.main.ui.stemactions
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class StemActionConfigNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Main.StemActionConfig> {
|
||||
StemActionConfigScreenHost()
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class Mod {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bind(entry: StemActionConfigNavigation): NavigationEntry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package eu.darken.capod.main.ui.stemactions
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.twotone.RestartAlt
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.settings.SettingsCategoryHeader
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
|
||||
@Composable
|
||||
fun StemActionConfigScreenHost(
|
||||
vm: StemActionConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val currentState = state ?: return
|
||||
|
||||
StemActionConfigScreen(
|
||||
state = currentState,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onReset = { vm.resetAll() },
|
||||
onLeftSingle = { vm.setLeftSingle(it) },
|
||||
onLeftDouble = { vm.setLeftDouble(it) },
|
||||
onLeftTriple = { vm.setLeftTriple(it) },
|
||||
onLeftLong = { vm.setLeftLong(it) },
|
||||
onRightSingle = { vm.setRightSingle(it) },
|
||||
onRightDouble = { vm.setRightDouble(it) },
|
||||
onRightTriple = { vm.setRightTriple(it) },
|
||||
onRightLong = { vm.setRightLong(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StemActionConfigScreen(
|
||||
state: StemActionConfigViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onReset: () -> Unit = {},
|
||||
onLeftSingle: (StemAction) -> Unit = {},
|
||||
onLeftDouble: (StemAction) -> Unit = {},
|
||||
onLeftTriple: (StemAction) -> Unit = {},
|
||||
onLeftLong: (StemAction) -> Unit = {},
|
||||
onRightSingle: (StemAction) -> Unit = {},
|
||||
onRightDouble: (StemAction) -> Unit = {},
|
||||
onRightTriple: (StemAction) -> Unit = {},
|
||||
onRightLong: (StemAction) -> Unit = {},
|
||||
) {
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onReset()
|
||||
showResetDialog = false
|
||||
}) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showResetDialog = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
text = { Text(stringResource(R.string.stem_actions_reset_confirm_message)) },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.stem_actions_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateUp) {
|
||||
Icon(Icons.AutoMirrored.TwoTone.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showResetDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.RestartAlt,
|
||||
contentDescription = stringResource(R.string.stem_actions_reset_label),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(modifier = Modifier.padding(paddingValues)) {
|
||||
item("description") {
|
||||
Text(
|
||||
text = stringResource(R.string.stem_actions_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Press type rows with left/right dropdowns
|
||||
item("single_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_single_press))
|
||||
}
|
||||
item("single_row") {
|
||||
StemActionRow(
|
||||
leftAction = state.leftSingle,
|
||||
rightAction = state.rightSingle,
|
||||
onLeftChange = onLeftSingle,
|
||||
onRightChange = onRightSingle,
|
||||
)
|
||||
}
|
||||
|
||||
item("double_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_double_press))
|
||||
}
|
||||
item("double_row") {
|
||||
StemActionRow(
|
||||
leftAction = state.leftDouble,
|
||||
rightAction = state.rightDouble,
|
||||
onLeftChange = onLeftDouble,
|
||||
onRightChange = onRightDouble,
|
||||
)
|
||||
}
|
||||
|
||||
item("triple_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_triple_press))
|
||||
}
|
||||
item("triple_row") {
|
||||
StemActionRow(
|
||||
leftAction = state.leftTriple,
|
||||
rightAction = state.rightTriple,
|
||||
onLeftChange = onLeftTriple,
|
||||
onRightChange = onRightTriple,
|
||||
)
|
||||
}
|
||||
|
||||
item("long_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_long_press))
|
||||
}
|
||||
item("long_row") {
|
||||
StemActionRow(
|
||||
leftAction = state.leftLong,
|
||||
rightAction = state.rightLong,
|
||||
onLeftChange = onLeftLong,
|
||||
onRightChange = onRightLong,
|
||||
)
|
||||
}
|
||||
|
||||
item("bottom_spacer") {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StemActionRow(
|
||||
leftAction: StemAction,
|
||||
rightAction: StemAction,
|
||||
onLeftChange: (StemAction) -> Unit,
|
||||
onRightChange: (StemAction) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.stem_actions_left),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
StemActionDropdown(
|
||||
selected = leftAction,
|
||||
onSelected = onLeftChange,
|
||||
otherSideAction = rightAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.stem_actions_right),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
StemActionDropdown(
|
||||
selected = rightAction,
|
||||
onSelected = onRightChange,
|
||||
otherSideAction = leftAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StemActionDropdown(
|
||||
selected: StemAction,
|
||||
onSelected: (StemAction) -> Unit,
|
||||
otherSideAction: StemAction,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val options = if (otherSideAction == StemAction.NONE) {
|
||||
StemAction.entries.filter { it != StemAction.NO_ACTION }
|
||||
} else {
|
||||
StemAction.entries.toList()
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = modifier,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selected.label(),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
for (action in options) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(action.label()) },
|
||||
onClick = {
|
||||
onSelected(action)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StemAction.label(): String = when (this) {
|
||||
StemAction.NONE -> stringResource(R.string.stem_action_none)
|
||||
StemAction.NO_ACTION -> stringResource(R.string.stem_action_no_action)
|
||||
StemAction.PLAY_PAUSE -> stringResource(R.string.stem_action_play_pause)
|
||||
StemAction.NEXT_TRACK -> stringResource(R.string.stem_action_next_track)
|
||||
StemAction.PREVIOUS_TRACK -> stringResource(R.string.stem_action_previous_track)
|
||||
StemAction.VOLUME_UP -> stringResource(R.string.stem_action_volume_up)
|
||||
StemAction.VOLUME_DOWN -> stringResource(R.string.stem_action_volume_down)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package eu.darken.capod.main.ui.stemactions
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionSettings
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class StemActionConfigViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val stemActionSettings: StemActionSettings,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
val state = combine(
|
||||
stemActionSettings.leftSingle.flow,
|
||||
stemActionSettings.leftDouble.flow,
|
||||
stemActionSettings.leftTriple.flow,
|
||||
stemActionSettings.leftLong.flow,
|
||||
stemActionSettings.rightSingle.flow,
|
||||
stemActionSettings.rightDouble.flow,
|
||||
stemActionSettings.rightTriple.flow,
|
||||
stemActionSettings.rightLong.flow,
|
||||
) { values ->
|
||||
State(
|
||||
leftSingle = values[0],
|
||||
leftDouble = values[1],
|
||||
leftTriple = values[2],
|
||||
leftLong = values[3],
|
||||
rightSingle = values[4],
|
||||
rightDouble = values[5],
|
||||
rightTriple = values[6],
|
||||
rightLong = values[7],
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
data class State(
|
||||
val leftSingle: StemAction = StemAction.NONE,
|
||||
val leftDouble: StemAction = StemAction.NONE,
|
||||
val leftTriple: StemAction = StemAction.NONE,
|
||||
val leftLong: StemAction = StemAction.NONE,
|
||||
val rightSingle: StemAction = StemAction.NONE,
|
||||
val rightDouble: StemAction = StemAction.NONE,
|
||||
val rightTriple: StemAction = StemAction.NONE,
|
||||
val rightLong: StemAction = StemAction.NONE,
|
||||
)
|
||||
|
||||
fun setLeftSingle(action: StemAction) = launch {
|
||||
stemActionSettings.leftSingle.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.rightSingle)
|
||||
}
|
||||
|
||||
fun setLeftDouble(action: StemAction) = launch {
|
||||
stemActionSettings.leftDouble.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.rightDouble)
|
||||
}
|
||||
|
||||
fun setLeftTriple(action: StemAction) = launch {
|
||||
stemActionSettings.leftTriple.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.rightTriple)
|
||||
}
|
||||
|
||||
fun setLeftLong(action: StemAction) = launch {
|
||||
stemActionSettings.leftLong.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.rightLong)
|
||||
}
|
||||
|
||||
fun setRightSingle(action: StemAction) = launch {
|
||||
stemActionSettings.rightSingle.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.leftSingle)
|
||||
}
|
||||
|
||||
fun setRightDouble(action: StemAction) = launch {
|
||||
stemActionSettings.rightDouble.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.leftDouble)
|
||||
}
|
||||
|
||||
fun setRightTriple(action: StemAction) = launch {
|
||||
stemActionSettings.rightTriple.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.leftTriple)
|
||||
}
|
||||
|
||||
fun setRightLong(action: StemAction) = launch {
|
||||
stemActionSettings.rightLong.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.leftLong)
|
||||
}
|
||||
|
||||
private suspend fun applyCrossSideEffect(
|
||||
selected: StemAction,
|
||||
otherSide: eu.darken.capod.common.datastore.DataStoreValue<StemAction>,
|
||||
) {
|
||||
when (selected) {
|
||||
StemAction.NONE -> otherSide.update { StemAction.NONE }
|
||||
StemAction.NO_ACTION -> {} // No side-effect
|
||||
else -> {
|
||||
if (otherSide.flow.first() == StemAction.NONE) {
|
||||
otherSide.update { StemAction.NO_ACTION }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAll() = launch { stemActionSettings.resetAll() }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.glance.GlanceId
|
||||
@@ -10,7 +11,6 @@ import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.annotation.Keep
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -22,8 +22,8 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
|
||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
@@ -34,7 +34,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface WidgetEntryPoint {
|
||||
fun podMonitor(): PodMonitor
|
||||
fun deviceMonitor(): DeviceMonitor
|
||||
fun upgradeRepo(): UpgradeRepo
|
||||
fun widgetSettings(): WidgetSettings
|
||||
fun deviceProfilesRepo(): DeviceProfilesRepo
|
||||
@@ -53,7 +53,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||
initialIsPro = ep.upgradeRepo().isPro()
|
||||
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
cachedDevice = initialProfileId?.let { ep.podMonitor().getDeviceForProfile(it) }
|
||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||
provideContent {
|
||||
@@ -73,7 +73,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
provideContent {
|
||||
// Composable reads — must be outside try-catch
|
||||
val devices by ep.podMonitor().devices.collectAsState(initial = emptyList())
|
||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||
val widthDp = LocalSize.current.width
|
||||
@@ -86,8 +86,8 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||
|
||||
val liveDevice = devices.firstOrNull { it.meta.profile?.id == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.meta.profile?.id == profileId }
|
||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
||||
|
||||
val profileLabel = profileId?.let { pid ->
|
||||
profiles.firstOrNull { it.id == pid }?.label
|
||||
@@ -139,7 +139,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
var n = 2
|
||||
while (70 * n - 30 < size) {
|
||||
++n
|
||||
}
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@@ -191,7 +192,7 @@ private fun SinglePodPreview(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent),
|
||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
@@ -293,7 +294,7 @@ private fun WidgetContainer(
|
||||
@Composable
|
||||
private fun PodItemRow(
|
||||
icon: Int,
|
||||
percent: Float?,
|
||||
percent: Float,
|
||||
charging: Boolean,
|
||||
inEar: Boolean,
|
||||
textColor: Color,
|
||||
@@ -313,7 +314,7 @@ private fun PodItemRow(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent),
|
||||
text = formatPercent(percent.toBatteryOrNull()),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@@ -105,7 +106,7 @@ private fun GlanceSinglePod(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent),
|
||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
@@ -203,7 +204,7 @@ private fun GlanceWidgetRoot(
|
||||
@Composable
|
||||
private fun GlancePodItem(
|
||||
icon: Int,
|
||||
percent: Float?,
|
||||
percent: Float,
|
||||
charging: Boolean,
|
||||
inEar: Boolean,
|
||||
textStyle: TextStyle,
|
||||
@@ -222,7 +223,7 @@ private fun GlancePodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent),
|
||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
|
||||
@@ -24,10 +24,10 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
@@ -77,7 +77,7 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -413,7 +413,7 @@ private fun ProfileSelectionItem(
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
val modelText = when (profile.model) {
|
||||
PodDevice.Model.UNKNOWN -> stringResource(R.string.pods_unknown_label)
|
||||
PodModel.UNKNOWN -> stringResource(R.string.pods_unknown_label)
|
||||
else -> profile.model.label
|
||||
}
|
||||
Text(
|
||||
@@ -456,7 +456,9 @@ private fun WidgetConfigPreview(
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasTransparency) {
|
||||
@@ -691,8 +693,8 @@ private fun HexColorInput(
|
||||
@Composable
|
||||
private fun WidgetConfigurationScreenPreview() = PreviewWrapper {
|
||||
val profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
)
|
||||
WidgetConfigurationScreen(
|
||||
state = WidgetConfigurationViewModel.State(
|
||||
@@ -720,7 +722,7 @@ private fun WidgetConfigurationScreenPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun WidgetConfigurationScreenCustomPreview() = PreviewWrapper {
|
||||
val profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
)
|
||||
WidgetConfigurationScreen(
|
||||
state = WidgetConfigurationViewModel.State(
|
||||
@@ -753,7 +755,7 @@ private fun WidgetConfigurationScreenCustomPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun WidgetConfigurationScreenNonProPreview() = PreviewWrapper {
|
||||
val profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
)
|
||||
WidgetConfigurationScreen(
|
||||
state = WidgetConfigurationViewModel.State(
|
||||
|
||||
@@ -18,15 +18,15 @@ sealed class WidgetRenderState {
|
||||
val isWide: Boolean,
|
||||
val deviceLabel: String?,
|
||||
@DrawableRes val leftIcon: Int,
|
||||
val leftPercent: Float?,
|
||||
val leftPercent: Float,
|
||||
val leftCharging: Boolean,
|
||||
val leftInEar: Boolean,
|
||||
@DrawableRes val rightIcon: Int,
|
||||
val rightPercent: Float?,
|
||||
val rightPercent: Float,
|
||||
val rightCharging: Boolean,
|
||||
val rightInEar: Boolean,
|
||||
@DrawableRes val caseIcon: Int,
|
||||
val casePercent: Float?,
|
||||
val casePercent: Float,
|
||||
val caseCharging: Boolean,
|
||||
) : WidgetRenderState()
|
||||
|
||||
@@ -37,7 +37,7 @@ sealed class WidgetRenderState {
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
val deviceLabel: String?,
|
||||
@DrawableRes val headsetIcon: Int,
|
||||
val percent: Float?,
|
||||
val percent: Float,
|
||||
@DrawableRes val batteryIcon: Int,
|
||||
val charging: Boolean,
|
||||
val worn: Boolean,
|
||||
|
||||
@@ -4,14 +4,10 @@ import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||
|
||||
object WidgetRenderStateMapper {
|
||||
|
||||
@@ -38,7 +34,7 @@ object WidgetRenderStateMapper {
|
||||
secondaryText = context.getString(R.string.upgrade_capod_description),
|
||||
)
|
||||
|
||||
device is DualPodDevice -> WidgetRenderState.DualPod(
|
||||
device != null && device.hasDualPods -> WidgetRenderState.DualPod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
@@ -46,32 +42,32 @@ object WidgetRenderStateMapper {
|
||||
isWide = isWide,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
leftIcon = device.leftPodIcon,
|
||||
leftPercent = device.batteryLeftPodPercent,
|
||||
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
|
||||
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
|
||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
||||
leftCharging = device.isLeftPodCharging == true,
|
||||
leftInEar = device.isLeftInEar == true,
|
||||
rightIcon = device.rightPodIcon,
|
||||
rightPercent = device.batteryRightPodPercent,
|
||||
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
|
||||
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
|
||||
caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case,
|
||||
casePercent = (device as? HasCase)?.batteryCasePercent,
|
||||
caseCharging = device is HasCase && device.isCaseCharging,
|
||||
rightPercent = device.batteryRight.toBatteryFloat(),
|
||||
rightCharging = device.isRightPodCharging == true,
|
||||
rightInEar = device.isRightInEar == true,
|
||||
caseIcon = device.caseIcon,
|
||||
casePercent = device.batteryCase.toBatteryFloat(),
|
||||
caseCharging = device.isCaseCharging == true,
|
||||
)
|
||||
|
||||
device is SinglePodDevice -> WidgetRenderState.SinglePod(
|
||||
device != null && device.model != PodModel.UNKNOWN -> WidgetRenderState.SinglePod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
headsetIcon = device.iconRes,
|
||||
percent = device.batteryHeadsetPercent,
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
|
||||
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
|
||||
worn = device is HasEarDetection && device.isBeingWorn,
|
||||
percent = device.batteryHeadset.toBatteryFloat(),
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||
charging = device.isHeadsetBeingCharged == true,
|
||||
worn = device.isBeingWorn == true,
|
||||
)
|
||||
|
||||
device is PodDevice -> WidgetRenderState.Message(
|
||||
device != null -> WidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.monitor.core.aap.AapLifecycleManager
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.cache.DeviceStateCache
|
||||
import eu.darken.capod.monitor.core.cache.toCachedState
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Single merge point: combines BLE scan data ([eu.darken.capod.monitor.core.ble.BlePodMonitor]) with AAP connection data
|
||||
* ([AapConnectionManager]) and cached device state ([eu.darken.capod.monitor.core.cache.DeviceStateCache]) into unified [PodDevice] objects.
|
||||
*
|
||||
* Includes cached-only devices for profiles that have cached state but no live BLE data.
|
||||
* Persists live device state to [eu.darken.capod.monitor.core.cache.DeviceStateCache] as a side effect.
|
||||
* ViewModels should observe [devices] instead of accessing BlePodMonitor directly.
|
||||
*/
|
||||
@Singleton
|
||||
class DeviceMonitor @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val blePodMonitor: BlePodMonitor,
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val deviceStateCache: DeviceStateCache,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val aapLifecycleManager: AapLifecycleManager,
|
||||
) {
|
||||
init {
|
||||
aapLifecycleManager.start()
|
||||
}
|
||||
|
||||
val devices: Flow<List<PodDevice>> = combine(
|
||||
blePodMonitor.devices,
|
||||
aapManager.allStates,
|
||||
deviceStateCache.cachedStates,
|
||||
profilesRepo.profiles,
|
||||
) { pods, aapStates, cachedStates, profiles ->
|
||||
// Live devices — BLE + AAP + cached fallback for missing fields
|
||||
val liveDevices = pods.map { pod ->
|
||||
val profile = pod.meta.profile
|
||||
PodDevice(
|
||||
profileId = profile?.id,
|
||||
label = profile?.label,
|
||||
ble = pod,
|
||||
aap = aapStates.forProfile(profile),
|
||||
cached = profile?.id?.let { cachedStates[it] },
|
||||
profileAddress = profile?.address,
|
||||
profileModel = profile?.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
)
|
||||
}
|
||||
|
||||
// Non-live devices — profiles with no live BLE detection. Synthesize from cache
|
||||
// and/or AAP, whichever is available:
|
||||
// - cache only → "cached-only" case (PR #484 fixed AAP attach here)
|
||||
// - AAP only → cold-start case: AAP connected but no battery message
|
||||
// received yet, so no cache write has happened
|
||||
// - cache + AAP → mid-session BLE staleness with AAP still alive
|
||||
val liveProfileIds = liveDevices.mapNotNull { it.profileId }.toSet()
|
||||
val nonLiveDevices = profiles
|
||||
.filter { it.id !in liveProfileIds }
|
||||
.mapNotNull { profile ->
|
||||
val cached = cachedStates[profile.id]
|
||||
val aap = aapStates.forProfile(profile)
|
||||
if (cached == null && aap == null) return@mapNotNull null
|
||||
PodDevice(
|
||||
profileId = profile.id,
|
||||
label = profile.label,
|
||||
ble = null,
|
||||
aap = aap,
|
||||
cached = cached,
|
||||
profileAddress = profile.address,
|
||||
profileModel = profile.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
)
|
||||
}
|
||||
|
||||
// De-dupe: if a synthesized non-live device with active AAP shares its model with
|
||||
// an anonymous (profileId == null) BLE pod, the anonymous pod is most likely the
|
||||
// same physical AirPods — the IRK match failed (e.g. wrong identity key, or keys
|
||||
// haven't propagated yet). Hide the anonymous BLE pod to avoid two cards for the
|
||||
// same device. Best-effort: model match only — BLE addresses are RPAs and can't
|
||||
// be matched directly.
|
||||
val nonLiveAapModels = nonLiveDevices
|
||||
.filter { it.aap != null }
|
||||
.map { it.model }
|
||||
.filter { it != PodModel.UNKNOWN }
|
||||
.toSet()
|
||||
val dedupedLiveDevices = if (nonLiveAapModels.isEmpty()) {
|
||||
liveDevices
|
||||
} else {
|
||||
val seenAnonymousModels = mutableSetOf<PodModel>()
|
||||
liveDevices.filter { device ->
|
||||
val isAnonymous = device.profileId == null
|
||||
val matchesNonLive = device.model in nonLiveAapModels
|
||||
// Hide at most one anonymous BLE pod per non-live AAP model so a second
|
||||
// physical device of the same model isn't accidentally hidden.
|
||||
!(isAnonymous && matchesNonLive && seenAnonymousModels.add(device.model))
|
||||
}
|
||||
}
|
||||
|
||||
dedupedLiveDevices + nonLiveDevices
|
||||
}
|
||||
.onEach { devices -> persistLiveDevices(devices) }
|
||||
.replayingShare(appScope)
|
||||
|
||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||
for (device in devices) {
|
||||
val profileId = device.profileId ?: continue
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.toCachedState(existing) ?: continue
|
||||
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
|
||||
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
|
||||
|
||||
val liveDevice = devices.firstOrNull()?.firstOrNull { it.profileId == profileId }
|
||||
if (liveDevice != null) {
|
||||
log(TAG) { "Found live device for profile $profileId" }
|
||||
return liveDevice
|
||||
}
|
||||
|
||||
val profile = profilesRepo.profiles.firstOrNull()?.firstOrNull { it.id == profileId }
|
||||
val cached = deviceStateCache.load(profileId)
|
||||
val aap = profile?.let { aapManager.allStates.value.forProfile(it) }
|
||||
|
||||
if (cached == null && aap == null) {
|
||||
log(TAG) { "No device found for profile $profileId" }
|
||||
return null
|
||||
}
|
||||
|
||||
log(TAG) { "Found fallback device for profile $profileId (cached=${cached != null}, aap=${aap != null})" }
|
||||
return PodDevice(
|
||||
profileId = profileId,
|
||||
label = profile?.label,
|
||||
ble = null,
|
||||
aap = aap,
|
||||
cached = cached,
|
||||
profileAddress = profile?.address,
|
||||
profileModel = profile?.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for "find the AAP state that belongs to this profile".
|
||||
* AAP is keyed by the bonded BR/EDR address, which lives on [DeviceProfile.address].
|
||||
* Used by both the live and cached-only branches of [devices] so they cannot drift apart.
|
||||
*/
|
||||
private fun Map<BluetoothAddress, AapPodState>.forProfile(profile: DeviceProfile?): AapPodState? =
|
||||
profile?.address?.let { this[it] }
|
||||
|
||||
/**
|
||||
* Derives the stable badge key state from the profile's stored IRK/ENC. Used so the badge
|
||||
* icons don't evaporate every time the BLE scanner misses a scan batch.
|
||||
*/
|
||||
private fun DeviceProfile?.toBleKeyState(): BleKeyState {
|
||||
val apple = this as? AppleDeviceProfile ?: return BleKeyState.NONE
|
||||
if (apple.identityKey == null) return BleKeyState.NONE
|
||||
return if (apple.encryptionKey != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("DeviceMonitor")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import android.icu.text.RelativeDateTimeFormatter
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
fun DeviceMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
|
||||
.map { devices -> devices.filter { it.profileId != null } }
|
||||
|
||||
fun DeviceMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
|
||||
|
||||
fun PodDevice.lastSeenFormatted(now: Instant): String {
|
||||
val lastAt = seenLastAt ?: return ""
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(lastAt, now)
|
||||
return if (duration > Duration.ofMinutes(1)) {
|
||||
formatter.format(
|
||||
duration.toMinutes().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
||||
)
|
||||
} else {
|
||||
formatter.format(
|
||||
duration.seconds.toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.SECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun PodDevice.firstSeenFormatted(now: Instant): String {
|
||||
val firstAt = seenFirstAt ?: return ""
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(firstAt, now)
|
||||
return formatter.format(
|
||||
duration.toMinutes().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
||||
)
|
||||
}
|
||||
|
||||
fun PodDevice.cachedBatteryFormatted(now: Instant): String {
|
||||
val cachedAt = cachedBatteryAt ?: return ""
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(cachedAt, now)
|
||||
return when {
|
||||
duration > Duration.ofHours(1) -> formatter.format(
|
||||
duration.toHours().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.HOURS
|
||||
)
|
||||
duration > Duration.ofMinutes(1) -> formatter.format(
|
||||
duration.toMinutes().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
||||
)
|
||||
else -> formatter.format(
|
||||
duration.seconds.toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.SECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Unified device facade combining BLE scan data and AAP connection data.
|
||||
* All properties are dynamically resolved from the best available source.
|
||||
* Consumers don't need to know whether data came from BLE or AAP.
|
||||
*/
|
||||
@Stable
|
||||
data class PodDevice(
|
||||
val profileId: String?,
|
||||
val label: String? = null,
|
||||
internal val ble: BlePodSnapshot?,
|
||||
internal val aap: AapPodState?,
|
||||
internal val cached: CachedDeviceState? = null,
|
||||
/** Bonded BR/EDR address from the profile — current source of truth, preferred over cache snapshot. */
|
||||
internal val profileAddress: BluetoothAddress? = null,
|
||||
/** Pod model from the profile — current source of truth, preferred over cache snapshot. */
|
||||
internal val profileModel: PodModel? = null,
|
||||
/**
|
||||
* Key state derived from the profile's stored IRK/ENC. Used as the source of truth for the
|
||||
* badge icons when the device is live — per-scan IRK matching would otherwise evaporate
|
||||
* every time the BLE scanner misses the next advertisement batch.
|
||||
*/
|
||||
internal val profileKeyState: BleKeyState = BleKeyState.NONE,
|
||||
) {
|
||||
val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN
|
||||
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
|
||||
val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: profileAddress ?: cached?.address
|
||||
/** BLE scan address (RPA, rotates). */
|
||||
val bleAddress: BluetoothAddress? get() = ble?.address
|
||||
val identifier: BlePodSnapshot.Id? get() = ble?.identifier
|
||||
|
||||
/** True when at least one live data source (BLE or AAP) is present. */
|
||||
val isLive: Boolean get() = ble != null || aap != null
|
||||
|
||||
// Capabilities from Model.Features
|
||||
val hasCase: Boolean get() = model.features.hasCase
|
||||
val hasDualPods: Boolean get() = model.features.hasDualPods
|
||||
val hasEarDetection: Boolean get() = model.features.hasEarDetection
|
||||
val hasAncControl: Boolean get() = model.features.hasAncControl
|
||||
val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone
|
||||
|
||||
// Signal / timing
|
||||
val seenLastAt: Instant?
|
||||
get() = listOfNotNull(ble?.seenLastAt, aap?.lastMessageAt, cached?.lastSeenAt).maxOrNull()
|
||||
val seenFirstAt: Instant? get() = ble?.seenFirstAt
|
||||
val signalQuality: Float
|
||||
get() {
|
||||
val bleQuality = ble?.signalQuality ?: 0f
|
||||
return (bleQuality + computeAapBoost(Instant.now())).coerceAtMost(1f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality value for the signal bar display. Prefers live BLE RSSI; when BLE is absent
|
||||
* (common for a device that's actively connected to us via classic BT — we stop receiving
|
||||
* its BLE advertisements), falls back to a full-bars constant as long as AAP is READY.
|
||||
*
|
||||
* Note: we don't try to graduate this off AAP message age. The AirPods only push messages
|
||||
* on state changes (battery %, ear detection, setting toggles, user taps). A user quietly
|
||||
* listening to music can go minutes without any AAP traffic, which is still a perfectly
|
||||
* healthy connection. A quiet channel is not a weak channel.
|
||||
*/
|
||||
val rssiQuality: Float
|
||||
get() {
|
||||
ble?.rssiQuality?.let { return it }
|
||||
val state = aap ?: return 0f
|
||||
return if (state.connectionState == AapPodState.ConnectionState.READY) 1.0f else 0f
|
||||
}
|
||||
|
||||
val rssi: Int get() = ble?.rssi ?: 0
|
||||
|
||||
/** Snapshot of connection-related state for badges and bars. */
|
||||
val connectionState: ConnectionState
|
||||
get() = ConnectionState(
|
||||
hasBleData = ble != null,
|
||||
bleKeyState = bleKeyState,
|
||||
isAapConnected = isAapConnected,
|
||||
rssiQuality = rssiQuality,
|
||||
)
|
||||
|
||||
internal fun computeAapBoost(now: Instant): Float {
|
||||
val state = aap ?: return 0f
|
||||
if (state.connectionState != AapPodState.ConnectionState.READY) return 0.05f
|
||||
val lastMessage = state.lastMessageAt ?: return 0.05f
|
||||
val ageSeconds = Duration.between(lastMessage, now).seconds
|
||||
return when {
|
||||
ageSeconds < 0 -> 0.05f // future timestamp (defensive)
|
||||
ageSeconds < 10 -> 0.15f // fresh
|
||||
ageSeconds < 30 -> 0.10f // warm
|
||||
else -> 0.05f // stale
|
||||
}
|
||||
}
|
||||
|
||||
// Battery — AAP preferred, BLE fallback, then cached
|
||||
val batteryLeft: Float?
|
||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
|
||||
|
||||
val batteryRight: Float?
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
|
||||
|
||||
val batteryCase: Float?
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
|
||||
|
||||
val batteryHeadset: Float?
|
||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
|
||||
|
||||
/** True when at least one displayed battery value was filled from cache (not live). */
|
||||
val isBatteryCached: Boolean
|
||||
get() {
|
||||
if (cached == null) return false
|
||||
val usedLeft = aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null && cached.left != null
|
||||
val usedRight = aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null && cached.right != null
|
||||
val usedCase = aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null && cached.case != null
|
||||
val usedHeadset = aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null && cached.headset != null
|
||||
return usedLeft || usedRight || usedCase || usedHeadset
|
||||
}
|
||||
|
||||
/** Oldest per-slot timestamp among battery values that fell through to cache. Null if all live. */
|
||||
val cachedBatteryAt: Instant?
|
||||
get() {
|
||||
if (cached == null) return null
|
||||
return listOfNotNull(
|
||||
cached.left?.updatedAt.takeIf { aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null },
|
||||
cached.right?.updatedAt.takeIf { aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null },
|
||||
cached.case?.updatedAt.takeIf { aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null },
|
||||
cached.headset?.updatedAt.takeIf { aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null },
|
||||
).minOrNull()
|
||||
}
|
||||
|
||||
// Charging — AAP preferred, BLE fallback, then cached
|
||||
val isLeftPodCharging: Boolean?
|
||||
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging
|
||||
|
||||
val isRightPodCharging: Boolean?
|
||||
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging
|
||||
|
||||
val isCaseCharging: Boolean?
|
||||
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging
|
||||
|
||||
val isHeadsetBeingCharged: Boolean?
|
||||
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
|
||||
|
||||
// Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback.
|
||||
private val resolvedPrimaryPod: DualBlePodSnapshot.Pod?
|
||||
get() = aap?.aapPrimaryPod?.pod?.let { aapPod ->
|
||||
when (aapPod) {
|
||||
AapSetting.PrimaryPod.Pod.LEFT -> DualBlePodSnapshot.Pod.LEFT
|
||||
AapSetting.PrimaryPod.Pod.RIGHT -> DualBlePodSnapshot.Pod.RIGHT
|
||||
}
|
||||
} ?: (ble as? DualApplePods)?.primaryPod
|
||||
|
||||
// Ear detection — AAP preferred (lower latency), BLE fallback.
|
||||
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
|
||||
val isLeftInEar: Boolean?
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
}
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isLeftPodInEar
|
||||
}
|
||||
|
||||
val isRightInEar: Boolean?
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
}
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isRightPodInEar
|
||||
}
|
||||
|
||||
val isBeingWorn: Boolean?
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
if (earDetection != null) {
|
||||
return earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
&& earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
}
|
||||
return (ble as? HasEarDetection)?.isBeingWorn
|
||||
}
|
||||
|
||||
val isEitherPodInEar: Boolean?
|
||||
get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar
|
||||
|
||||
val caseLidState: DualApplePods.LidState?
|
||||
get() = (ble as? DualApplePods)?.caseLidState
|
||||
|
||||
// Microphone — AAP preferred (from primary pod identity), BLE fallback
|
||||
val isLeftPodMicrophone: Boolean?
|
||||
get() {
|
||||
aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.LEFT }
|
||||
return (ble as? HasDualMicrophone)?.isLeftPodMicrophone
|
||||
}
|
||||
|
||||
val isRightPodMicrophone: Boolean?
|
||||
get() {
|
||||
aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.RIGHT }
|
||||
return (ble as? HasDualMicrophone)?.isRightPodMicrophone
|
||||
}
|
||||
|
||||
// Icons / labels
|
||||
val iconRes: Int get() = ble?.iconRes ?: model.iconRes
|
||||
|
||||
val leftPodIcon: Int
|
||||
get() = (ble as? DualBlePodSnapshot)?.leftPodIcon
|
||||
?: model.leftPodIconRes
|
||||
?: R.drawable.device_airpods_gen1_left
|
||||
|
||||
val rightPodIcon: Int
|
||||
get() = (ble as? DualBlePodSnapshot)?.rightPodIcon
|
||||
?: model.rightPodIconRes
|
||||
?: R.drawable.device_airpods_gen1_right
|
||||
|
||||
val caseIcon: Int
|
||||
get() = (ble as? HasCase)?.caseIcon
|
||||
?: model.caseIconRes
|
||||
?: R.drawable.device_airpods_gen1_case
|
||||
|
||||
fun getLabel(context: Context): String = ble?.getLabel(context) ?: model.label
|
||||
|
||||
// Debug
|
||||
val rawDataHex: List<String> get() = ble?.rawDataHex ?: emptyList()
|
||||
|
||||
// AAP controls
|
||||
val ancMode: AapSetting.AncMode?
|
||||
get() = aap?.setting()
|
||||
|
||||
val pendingAncMode: AapSetting.AncMode.Value?
|
||||
get() = aap?.pendingAncMode
|
||||
|
||||
val conversationalAwareness: AapSetting.ConversationalAwareness?
|
||||
get() = aap?.setting()
|
||||
|
||||
val toneVolume: AapSetting.ToneVolume?
|
||||
get() = aap?.setting()
|
||||
|
||||
val personalizedVolume: AapSetting.PersonalizedVolume?
|
||||
get() = aap?.setting()
|
||||
|
||||
val volumeSwipe: AapSetting.VolumeSwipe?
|
||||
get() = aap?.setting()
|
||||
|
||||
val ncWithOneAirPod: AapSetting.NcWithOneAirPod?
|
||||
get() = aap?.setting()
|
||||
|
||||
val adaptiveAudioNoise: AapSetting.AdaptiveAudioNoise?
|
||||
get() = aap?.setting()
|
||||
|
||||
val pressSpeed: AapSetting.PressSpeed?
|
||||
get() = aap?.setting()
|
||||
|
||||
val pressHoldDuration: AapSetting.PressHoldDuration?
|
||||
get() = aap?.setting()
|
||||
|
||||
val volumeSwipeLength: AapSetting.VolumeSwipeLength?
|
||||
get() = aap?.setting()
|
||||
|
||||
val endCallMuteMic: AapSetting.EndCallMuteMic?
|
||||
get() = aap?.setting()
|
||||
|
||||
val microphoneMode: AapSetting.MicrophoneMode?
|
||||
get() = aap?.setting()
|
||||
|
||||
val earDetectionEnabled: AapSetting.EarDetectionEnabled?
|
||||
get() = aap?.setting()
|
||||
|
||||
val listeningModeCycle: AapSetting.ListeningModeCycle?
|
||||
get() = aap?.setting()
|
||||
|
||||
val allowOffOption: AapSetting.AllowOffOption?
|
||||
get() = aap?.setting()
|
||||
|
||||
val stemConfig: AapSetting.StemConfig?
|
||||
get() = aap?.setting()
|
||||
|
||||
val sleepDetection: AapSetting.SleepDetection?
|
||||
get() = aap?.setting()
|
||||
|
||||
val connectedDevices: AapSetting.ConnectedDevices?
|
||||
get() = aap?.setting()
|
||||
|
||||
val audioSource: AapSetting.AudioSource?
|
||||
get() = aap?.setting()
|
||||
|
||||
val eqBands: AapSetting.EqBands?
|
||||
get() = aap?.setting()
|
||||
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
get() = aap?.deviceInfo ?: cached?.deviceInfo
|
||||
|
||||
val isAapConnected: Boolean get() = aap != null
|
||||
|
||||
val isAapReady: Boolean get() = aap?.connectionState == AapPodState.ConnectionState.READY
|
||||
|
||||
/**
|
||||
* Badge key state for the overview card. Profile-stored keys are the source of truth while
|
||||
* the device is live: a profile that has an IRK (and optionally ENC) keeps showing those
|
||||
* icons whether the current scan happens to include a fresh BLE advertisement or not. For
|
||||
* anonymous BLE pods (no profile), we still derive the state from the live scan result.
|
||||
*/
|
||||
val bleKeyState: BleKeyState
|
||||
get() {
|
||||
if (!isLive) return BleKeyState.NONE
|
||||
if (profileKeyState != BleKeyState.NONE) return profileKeyState
|
||||
val applePod = ble as? ApplePods ?: return BleKeyState.NONE
|
||||
if (!applePod.meta.isIRKMatch) return BleKeyState.NONE
|
||||
return if (applePod.payload.private != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY
|
||||
}
|
||||
}
|
||||
|
||||
enum class BleKeyState { NONE, IRK_ONLY, IRK_AND_ENCRYPTED }
|
||||
|
||||
data class ConnectionState(
|
||||
val hasBleData: Boolean,
|
||||
val bleKeyState: BleKeyState,
|
||||
val isAapConnected: Boolean,
|
||||
val rssiQuality: Float,
|
||||
)
|
||||
@@ -1,85 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PodDeviceCache @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
moshi: Moshi,
|
||||
) {
|
||||
private val cacheDir by lazy {
|
||||
File(context.cacheDir, "device_cache").apply { mkdirs() }
|
||||
}
|
||||
private val jsonAdapter = moshi.adapter<BleScanResult>()
|
||||
private val lock = Mutex()
|
||||
|
||||
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
|
||||
|
||||
suspend fun load(id: ProfileId): BleScanResult? = withContext(dispatcherProvider.IO) {
|
||||
log(TAG, VERBOSE) { "load(id=$id)" }
|
||||
val cacheFile = id.toCacheFile()
|
||||
lock.withLock {
|
||||
if (!cacheFile.exists()) return@withLock null
|
||||
try {
|
||||
val raw = cacheFile.readText()
|
||||
jsonAdapter.fromJson(raw)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" }
|
||||
cacheFile.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveAll(data: Map<ProfileId, BleScanResult>) {
|
||||
log(TAG, VERBOSE) { "saveAll(): ${data.size} entries" }
|
||||
lock.withLock {
|
||||
data.forEach { (id, device) ->
|
||||
log(TAG, VERBOSE) { "save(id=$id, device=$device)" }
|
||||
val cacheFile = id.toCacheFile()
|
||||
try {
|
||||
val json = jsonAdapter.toJson(device)
|
||||
cacheFile.writeText(json)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" }
|
||||
cacheFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(id: ProfileId) {
|
||||
log(TAG, VERBOSE) { "delete(): profileId=$id" }
|
||||
lock.withLock {
|
||||
val cacheFile = id.toCacheFile()
|
||||
try {
|
||||
cacheFile.delete()
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to delete profile for $id: ${e.asLog()}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "PodMonitor", "Cache")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.collections.firstOrNull
|
||||
|
||||
fun PodMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
|
||||
.map { devices -> devices.filter { it.meta.profile != null } }
|
||||
|
||||
fun PodMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
|
||||
@@ -1,9 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import dagger.Reusable
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class PodSorter @Inject constructor(
|
||||
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Singleton
|
||||
class AapAutoConnect @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val blePodMonitor: BlePodMonitor,
|
||||
) {
|
||||
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
||||
fun monitor(): Flow<Unit> = merge(
|
||||
initialConnect(),
|
||||
reconnectOnDisconnect(),
|
||||
correctModelOnDeviceInfo(),
|
||||
)
|
||||
|
||||
private fun initialConnect(): Flow<Unit> = combine(
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices,
|
||||
) { profiles, connectedDevices ->
|
||||
val connectedAddresses = connectedDevices.map { it.address }.toSet()
|
||||
profiles.filter { it.address in connectedAddresses }
|
||||
}
|
||||
.mapLatest { profiles ->
|
||||
val bondedDevices = bluetoothManager.bondedDevices().first()
|
||||
|
||||
coroutineScope {
|
||||
for (profile in profiles) {
|
||||
launch { connectWithRetries(profile, bondedDevices) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "initialConnect" }
|
||||
|
||||
private suspend fun connectWithRetries(
|
||||
profile: eu.darken.capod.profiles.core.DeviceProfile,
|
||||
bondedDevices: Set<eu.darken.capod.common.bluetooth.BluetoothDevice2>,
|
||||
) {
|
||||
val address = profile.address ?: return
|
||||
val bonded = bondedDevices.firstOrNull { it.address == address } ?: return
|
||||
|
||||
val currentState = aapManager.allStates.value[address]
|
||||
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG, VERBOSE) { "AAP already connected/connecting to $address" }
|
||||
return
|
||||
}
|
||||
|
||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||
try {
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
log(TAG) { "AAP connected to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||
|
||||
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
||||
delay(delayMs)
|
||||
|
||||
// Bail out if classic BT disconnected
|
||||
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
||||
if (address !in currentConnected) {
|
||||
log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
val retryState = aapManager.allStates.value[address]
|
||||
if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "AAP initial retry: $address already reconnected, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
||||
break
|
||||
} catch (retryException: Exception) {
|
||||
log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnectOnDisconnect(): Flow<Unit> = aapManager.disconnectEvents
|
||||
.onEach { address ->
|
||||
if (!activeReconnects.add(address)) {
|
||||
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
try {
|
||||
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
||||
delay(delayMs)
|
||||
|
||||
// Check if still profiled
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (profile == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still bonded
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still classically connected
|
||||
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
||||
if (address !in currentConnected) {
|
||||
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still visible in BLE
|
||||
val bleDevices = blePodMonitor.devices.first()
|
||||
if (bleDevices.none { it.meta?.profile?.address == address }) {
|
||||
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if already reconnected (e.g., by initialConnect)
|
||||
val currentState = aapManager.allStates.value[address]
|
||||
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "AAP reconnect: $address already reconnected" }
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
log(TAG) { "AAP reconnected to $address" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
activeReconnects.remove(address)
|
||||
}
|
||||
}
|
||||
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
|
||||
.setupCommonEventHandlers(TAG) { "reconnect" }
|
||||
|
||||
private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
|
||||
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
|
||||
.distinctUntilChanged()
|
||||
.map { modelNumbers ->
|
||||
// Clean up tracking for addresses that disconnected
|
||||
processedModelCorrections.retainAll(modelNumbers.keys)
|
||||
|
||||
for ((address, modelNumber) in modelNumbers) {
|
||||
if (modelNumber == null) continue
|
||||
if (address in processedModelCorrections) continue
|
||||
processedModelCorrections.add(address)
|
||||
|
||||
try {
|
||||
val detectedModel = PodModel.fromModelNumber(modelNumber) ?: continue
|
||||
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.firstOrNull { it.address == address }
|
||||
?: continue
|
||||
|
||||
if (profile.model == detectedModel) {
|
||||
log(TAG, VERBOSE) { "AAP model confirmed for $address: $detectedModel" }
|
||||
continue
|
||||
}
|
||||
|
||||
log(TAG) { "AAP model mismatch for $address: profile=${profile.model}, detected=$detectedModel" }
|
||||
|
||||
// Allow key exchange (AapKeyPersister) to complete before disconnecting
|
||||
delay(2.seconds)
|
||||
|
||||
aapManager.disconnect(address)
|
||||
profilesRepo.updateProfile(profile.copy(model = detectedModel))
|
||||
log(TAG) { "AAP model corrected for $address: ${profile.model} -> $detectedModel" }
|
||||
|
||||
// Reconnect explicitly
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded != null) {
|
||||
aapManager.connect(address, bonded.internal, detectedModel)
|
||||
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP model correction failed for $address: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "correctModel" }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "AapAutoConnect")
|
||||
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
||||
private val CONNECT_TIMEOUT = 5.seconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persists IRK/ENC keys from AAP key exchange to [AppleDeviceProfile],
|
||||
* enabling BLE encrypted battery decryption with 1% granularity.
|
||||
*/
|
||||
@Singleton
|
||||
class AapKeyPersister @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
fun monitor(): Flow<Unit> = aapManager.keysReceived
|
||||
.onEach { (address, keys) ->
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.firstOrNull { it.address == address }
|
||||
|
||||
if (profile == null) {
|
||||
log(TAG) { "No profile found for $address, skipping key persistence" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
val updated = profile.copy(
|
||||
identityKey = keys.irk ?: profile.identityKey,
|
||||
encryptionKey = keys.encKey ?: profile.encryptionKey,
|
||||
)
|
||||
|
||||
if (updated != profile) {
|
||||
profilesRepo.updateProfile(updated)
|
||||
log(TAG) { "Persisted keys for $address (IRK=${keys.irk != null}, ENC=${keys.encKey != null})" }
|
||||
}
|
||||
}
|
||||
.map { }
|
||||
.setupCommonEventHandlers(TAG) { "keyPersister" }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "AapKeyPersister")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Launches AAP auto-connect and key persistence in [appScope],
|
||||
* independent of [eu.darken.capod.monitor.core.worker.MonitorService] lifecycle.
|
||||
*
|
||||
* Call [start] from [eu.darken.capod.monitor.core.DeviceMonitor] init to activate.
|
||||
*/
|
||||
@Singleton
|
||||
class AapLifecycleManager @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val aapAutoConnect: AapAutoConnect,
|
||||
private val aapKeyPersister: AapKeyPersister,
|
||||
private val stemConfigSender: StemConfigSender,
|
||||
private val stemPressReaction: StemPressReaction,
|
||||
) {
|
||||
fun start() {
|
||||
log(TAG) { "start()" }
|
||||
merge(
|
||||
aapAutoConnect.monitor(),
|
||||
aapKeyPersister.monitor(),
|
||||
stemConfigSender.monitor(),
|
||||
stemPressReaction.monitor(),
|
||||
)
|
||||
.catch { e -> log(TAG, WARN) { "AAP lifecycle error: ${e.asLog()}" } }
|
||||
.setupCommonEventHandlers(TAG) { "aapActive" }
|
||||
.launchIn(appScope)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "AapLifecycleManager")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionSettings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class StemConfigSender @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val stemActionSettings: StemActionSettings,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
fun monitor(): Flow<Unit> = combine(
|
||||
aapManager.allStates,
|
||||
stemActionMask(),
|
||||
) { states, mask ->
|
||||
states.entries
|
||||
.filter { (_, s) -> s.connectionState == AapPodState.ConnectionState.READY }
|
||||
.mapNotNull { (address, _) ->
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.firstOrNull { it.address == address }
|
||||
if (profile != null && profile.model.features.hasStemConfig) {
|
||||
address to mask
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { commands ->
|
||||
for ((address, mask) in commands) {
|
||||
try {
|
||||
aapManager.sendCommand(address, AapCommand.SetStemConfig(mask))
|
||||
log(TAG) { "Sent stem config 0x${"%02X".format(mask)} to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "StemConfig send failed for $address: $e" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.map { }
|
||||
.setupCommonEventHandlers(TAG) { "stemConfig" }
|
||||
|
||||
private fun stemActionMask(): Flow<Int> = combine(
|
||||
stemActionSettings.leftSingle.flow,
|
||||
stemActionSettings.leftDouble.flow,
|
||||
stemActionSettings.leftTriple.flow,
|
||||
stemActionSettings.leftLong.flow,
|
||||
stemActionSettings.rightSingle.flow,
|
||||
stemActionSettings.rightDouble.flow,
|
||||
stemActionSettings.rightTriple.flow,
|
||||
stemActionSettings.rightLong.flow,
|
||||
) { values ->
|
||||
var mask = 0
|
||||
if (values[0] != StemAction.NONE || values[4] != StemAction.NONE) mask = mask or 0x01 // single
|
||||
if (values[1] != StemAction.NONE || values[5] != StemAction.NONE) mask = mask or 0x02 // double
|
||||
if (values[2] != StemAction.NONE || values[6] != StemAction.NONE) mask = mask or 0x04 // triple
|
||||
if (values[3] != StemAction.NONE || values[7] != StemAction.NONE) mask = mask or 0x08 // long
|
||||
mask
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "StemConfigSender")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import android.view.KeyEvent
|
||||
import eu.darken.capod.common.MediaControl
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionSettings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class StemPressReaction @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val stemActionSettings: StemActionSettings,
|
||||
private val mediaControl: MediaControl,
|
||||
) {
|
||||
fun monitor(): Flow<Unit> = aapManager.stemPressEvents
|
||||
.onEach { (_, event) ->
|
||||
val action = resolveAction(event)
|
||||
log(TAG) { "Stem ${event.bud} ${event.pressType} -> $action" }
|
||||
executeAction(action)
|
||||
}
|
||||
.map { }
|
||||
.setupCommonEventHandlers(TAG) { "stemReaction" }
|
||||
|
||||
private suspend fun resolveAction(event: StemPressEvent): StemAction {
|
||||
val setting = when (event.bud) {
|
||||
StemPressEvent.Bud.LEFT -> when (event.pressType) {
|
||||
StemPressEvent.PressType.SINGLE -> stemActionSettings.leftSingle
|
||||
StemPressEvent.PressType.DOUBLE -> stemActionSettings.leftDouble
|
||||
StemPressEvent.PressType.TRIPLE -> stemActionSettings.leftTriple
|
||||
StemPressEvent.PressType.LONG -> stemActionSettings.leftLong
|
||||
}
|
||||
StemPressEvent.Bud.RIGHT -> when (event.pressType) {
|
||||
StemPressEvent.PressType.SINGLE -> stemActionSettings.rightSingle
|
||||
StemPressEvent.PressType.DOUBLE -> stemActionSettings.rightDouble
|
||||
StemPressEvent.PressType.TRIPLE -> stemActionSettings.rightTriple
|
||||
StemPressEvent.PressType.LONG -> stemActionSettings.rightLong
|
||||
}
|
||||
}
|
||||
return setting.flow.first()
|
||||
}
|
||||
|
||||
private suspend fun executeAction(action: StemAction) = when (action) {
|
||||
StemAction.NONE, StemAction.NO_ACTION -> Unit
|
||||
StemAction.PLAY_PAUSE -> mediaControl.sendPlayPause()
|
||||
StemAction.NEXT_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_NEXT)
|
||||
StemAction.PREVIOUS_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
|
||||
StemAction.VOLUME_UP -> mediaControl.adjustVolumeUp()
|
||||
StemAction.VOLUME_DOWN -> mediaControl.adjustVolumeDown()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "StemPressReaction")
|
||||
}
|
||||
}
|
||||
+37
-51
@@ -1,14 +1,14 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
package eu.darken.capod.monitor.core.ble
|
||||
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.bluetooth.BleScanner
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
@@ -16,19 +16,20 @@ import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.PodFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.PodFactory
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.profiles.core.currentProfiles
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -39,34 +40,39 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PodMonitor @Inject constructor(
|
||||
class BlePodMonitor @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val bleScanner: BleScanner,
|
||||
private val podFactory: PodFactory,
|
||||
private val generalSettings: GeneralSettings,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val podDeviceCache: PodDeviceCache,
|
||||
permissionTool: PermissionTool,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
|
||||
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
|
||||
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
||||
private val cacheLock = Mutex()
|
||||
|
||||
val devices: Flow<List<PodDevice>> = combine(
|
||||
permissionTool.missingPermissions,
|
||||
val devices: Flow<List<BlePodSnapshot>> = combine(
|
||||
permissionTool.missingScanPermissions,
|
||||
bluetoothManager.isBluetoothEnabled
|
||||
) { missingPermissions, isBluetoothEnabled ->
|
||||
log(TAG) { "devices: missingPermissions=$missingPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
|
||||
missingPermissions.isEmpty() && isBluetoothEnabled
|
||||
) { missingScanPermissions, isBluetoothEnabled ->
|
||||
log(TAG) { "devices: missingScanPermissions=$missingScanPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
|
||||
missingScanPermissions.isEmpty() && isBluetoothEnabled
|
||||
}
|
||||
.flatMapLatest { isReady ->
|
||||
if (!isReady) {
|
||||
log(TAG, WARN) { "Bluetooth is not ready" }
|
||||
log(TAG, Logging.Priority.WARN) { "Bluetooth is not ready" }
|
||||
flowOf(null)
|
||||
} else {
|
||||
createBleScanner()
|
||||
val staleEvictionTicker: Flow<Collection<BleScanResult>> = flow {
|
||||
while (true) {
|
||||
delay(STALE_EVICTION_INTERVAL.toMillis())
|
||||
emit(emptyList())
|
||||
}
|
||||
}
|
||||
merge(createBleScanner(), staleEvictionTicker)
|
||||
}
|
||||
}
|
||||
.map { results -> results?.mapNotNull { podFactory.createPod(it) } }
|
||||
@@ -76,10 +82,13 @@ class PodMonitor @Inject constructor(
|
||||
}
|
||||
.retryWhen { cause, attempt ->
|
||||
if (cause is SecurityException) {
|
||||
log(TAG, WARN) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.WARN
|
||||
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
||||
false
|
||||
} else {
|
||||
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||
delay(3000)
|
||||
true
|
||||
}
|
||||
@@ -87,12 +96,12 @@ class PodMonitor @Inject constructor(
|
||||
.onStart { emit(emptyList()) }
|
||||
.replayingShare(appScope)
|
||||
|
||||
private suspend fun sortPodsToInterest(devices: Collection<PodDevice>): List<PodDevice> {
|
||||
private suspend fun sortPodsToInterest(devices: Collection<BlePodSnapshot>): List<BlePodSnapshot> {
|
||||
val now = Instant.now()
|
||||
val profiles = profilesRepo.currentProfiles() ?: emptyList()
|
||||
|
||||
return devices.sortedWith(
|
||||
compareBy<PodDevice> { device ->
|
||||
compareBy<BlePodSnapshot> { device ->
|
||||
// Use profile position in list as priority (0 = highest priority)
|
||||
device.meta.profile?.let { profile ->
|
||||
profiles.indexOfFirst { it.id == profile.id }.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
||||
@@ -140,7 +149,7 @@ class PodMonitor @Inject constructor(
|
||||
.flatMapLatest { options ->
|
||||
val filters = when {
|
||||
options.showUnfiltered -> {
|
||||
log(TAG, WARN) { "Using unfiltered scan mode" }
|
||||
log(TAG, Logging.Priority.WARN) { "Using unfiltered scan mode" }
|
||||
setOf(ScanFilter.Builder().build())
|
||||
}
|
||||
|
||||
@@ -159,7 +168,7 @@ class PodMonitor @Inject constructor(
|
||||
|
||||
private suspend fun processWithCache(
|
||||
newPods: List<PodFactory.Result>?
|
||||
): Map<PodDevice.Id, PodDevice> = cacheLock.withLock {
|
||||
): Map<BlePodSnapshot.Id, BlePodSnapshot> = cacheLock.withLock {
|
||||
if (newPods == null) {
|
||||
log(TAG) { "Null result, Bluetooth is disabled." }
|
||||
deviceCache.clear()
|
||||
@@ -168,13 +177,13 @@ class PodMonitor @Inject constructor(
|
||||
|
||||
val now = Instant.now()
|
||||
deviceCache.toList().forEach { (key, value) ->
|
||||
if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) {
|
||||
log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
|
||||
if (Duration.between(value.seenLastAt, now) > STALE_DEVICE_TIMEOUT) {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "Removing stale device from cache: $value" }
|
||||
deviceCache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
|
||||
val pods = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
||||
|
||||
pods.putAll(deviceCache)
|
||||
|
||||
@@ -183,35 +192,12 @@ class PodMonitor @Inject constructor(
|
||||
pods[it.identifier] = it
|
||||
}
|
||||
|
||||
newPods
|
||||
.mapNotNull {
|
||||
val profileId = it.device.meta.profile?.id ?: return@mapNotNull null
|
||||
profileId to it.device.scanResult
|
||||
}
|
||||
.toMap()
|
||||
.run { podDeviceCache.saveAll(this) }
|
||||
return pods
|
||||
}
|
||||
|
||||
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
|
||||
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
|
||||
|
||||
val liveDevice = devices.firstOrNull()?.firstOrNull { device ->
|
||||
device.meta.profile?.id == profileId
|
||||
}
|
||||
if (liveDevice != null) {
|
||||
log(TAG) { "Found live device for profile $profileId: $liveDevice" }
|
||||
return liveDevice
|
||||
}
|
||||
|
||||
val cachedDevice = podDeviceCache.load(profileId)?.let {
|
||||
podFactory.createPod(it)?.device
|
||||
}
|
||||
log(TAG) { "Cached device for profile $profileId: $cachedDevice" }
|
||||
return cachedDevice
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "PodMonitor")
|
||||
private val STALE_DEVICE_TIMEOUT = Duration.ofSeconds(20)
|
||||
private val STALE_EVICTION_INTERVAL = Duration.ofSeconds(10)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package eu.darken.capod.monitor.core.cache
|
||||
|
||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
@Serializable
|
||||
data class CachedDeviceState(
|
||||
@SerialName("profileId") val profileId: String,
|
||||
@SerialName("model") val model: PodModel,
|
||||
@SerialName("address") val address: String? = null,
|
||||
@SerialName("left") val left: CachedBatterySlot? = null,
|
||||
@SerialName("right") val right: CachedBatterySlot? = null,
|
||||
@SerialName("case") val case: CachedBatterySlot? = null,
|
||||
@SerialName("headset") val headset: CachedBatterySlot? = null,
|
||||
@SerialName("isLeftCharging") val isLeftCharging: Boolean? = null,
|
||||
@SerialName("isRightCharging") val isRightCharging: Boolean? = null,
|
||||
@SerialName("isCaseCharging") val isCaseCharging: Boolean? = null,
|
||||
@SerialName("isHeadsetCharging") val isHeadsetCharging: Boolean? = null,
|
||||
@SerialName("deviceName") val deviceName: String? = null,
|
||||
@SerialName("serialNumber") val serialNumber: String? = null,
|
||||
@SerialName("firmwareVersion") val firmwareVersion: String? = null,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("lastSeenAt") val lastSeenAt: Instant,
|
||||
) {
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
get() {
|
||||
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
|
||||
return AapDeviceInfo(
|
||||
name = deviceName ?: "",
|
||||
modelNumber = "",
|
||||
manufacturer = "",
|
||||
serialNumber = serialNumber ?: "",
|
||||
firmwareVersion = firmwareVersion ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CachedBatterySlot(
|
||||
@SerialName("percent") val percent: Float,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("updatedAt") val updatedAt: Instant,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package eu.darken.capod.monitor.core.cache
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceStateCache @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@SerializationCapod private val json: Json,
|
||||
) {
|
||||
private val cacheDir by lazy {
|
||||
File(context.filesDir, "device_state_cache").apply { mkdirs() }
|
||||
}
|
||||
private val lock = Mutex()
|
||||
|
||||
private val _cachedStates = MutableStateFlow<Map<ProfileId, CachedDeviceState>>(emptyMap())
|
||||
val cachedStates: StateFlow<Map<ProfileId, CachedDeviceState>> = _cachedStates
|
||||
|
||||
init {
|
||||
appScope.launch { loadAll() }
|
||||
}
|
||||
|
||||
private suspend fun loadAll() = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
val dir = cacheDir
|
||||
if (!dir.exists()) return@withLock
|
||||
|
||||
val loaded = mutableMapOf<ProfileId, CachedDeviceState>()
|
||||
dir.listFiles()?.filter { it.name.startsWith("profile_") && it.name.endsWith(".json") }?.forEach { file ->
|
||||
val profileId = file.name.removePrefix("profile_").removeSuffix(".json")
|
||||
try {
|
||||
val state = json.decodeFromString<CachedDeviceState>(file.readText())
|
||||
loaded[profileId] = state
|
||||
} catch (e: Exception) {
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.ERROR
|
||||
) { "Failed to load cached state from ${file.name}: ${e.asLog()}, deleting" }
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
|
||||
_cachedStates.value = loaded
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
|
||||
|
||||
suspend fun save(id: ProfileId, state: CachedDeviceState) = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "save(id=$id)" }
|
||||
val file = id.toCacheFile()
|
||||
try {
|
||||
file.writeText(json.encodeToString(CachedDeviceState.serializer(), state))
|
||||
_cachedStates.value += (id to state)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, Logging.Priority.ERROR) { "Failed to save state for $id: ${e.asLog()}" }
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun load(id: ProfileId): CachedDeviceState? = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
val cached = _cachedStates.value[id]
|
||||
if (cached != null) return@withContext cached
|
||||
|
||||
val file = id.toCacheFile()
|
||||
if (!file.exists()) return@withContext null
|
||||
try {
|
||||
json.decodeFromString<CachedDeviceState>(file.readText())
|
||||
} catch (e: Exception) {
|
||||
log(TAG, Logging.Priority.ERROR) { "Failed to load state for $id: ${e.asLog()}, deleting" }
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
|
||||
id.toCacheFile().delete()
|
||||
_cachedStates.value -= id
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" }
|
||||
cacheDir.listFiles()?.forEach { it.delete() }
|
||||
_cachedStates.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "DeviceStateCache")
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package eu.darken.capod.monitor.core.cache
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Creates a [CachedDeviceState] from this live device's raw BLE/AAP battery data.
|
||||
* Returns null if:
|
||||
* - The device is not live (cached-only)
|
||||
* - The device has no profile
|
||||
* - All live battery values are null
|
||||
* - The state hasn't changed from [existing] (dedup)
|
||||
*/
|
||||
fun PodDevice.toCachedState(
|
||||
existing: CachedDeviceState?,
|
||||
now: Instant = Instant.now(),
|
||||
): CachedDeviceState? {
|
||||
if (!isLive) return null
|
||||
val pid = profileId ?: return null
|
||||
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
|
||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
|
||||
|
||||
val liveDeviceInfo = aap?.deviceInfo
|
||||
|
||||
val newState = CachedDeviceState(
|
||||
profileId = pid,
|
||||
model = model,
|
||||
address = address,
|
||||
left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left,
|
||||
right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right,
|
||||
case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case,
|
||||
headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset,
|
||||
isLeftCharging = isLeftPodCharging,
|
||||
isRightCharging = isRightPodCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
isHeadsetCharging = isHeadsetBeingCharged,
|
||||
deviceName = liveDeviceInfo?.name ?: existing?.deviceName,
|
||||
serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber,
|
||||
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
|
||||
lastSeenAt = seenLastAt ?: now,
|
||||
)
|
||||
|
||||
if (existing != null && !hasStateChanged(existing, newState)) return null
|
||||
|
||||
return newState
|
||||
}
|
||||
|
||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
||||
return old.left?.percent != new.left?.percent
|
||||
|| old.right?.percent != new.right?.percent
|
||||
|| old.case?.percent != new.case?.percent
|
||||
|| old.headset?.percent != new.headset?.percent
|
||||
|| old.isLeftCharging != new.isLeftCharging
|
||||
|| old.isRightCharging != new.isRightCharging
|
||||
|| old.isCaseCharging != new.isCaseCharging
|
||||
|| old.isHeadsetCharging != new.isHeadsetCharging
|
||||
|| old.deviceName != new.deviceName
|
||||
|| old.serialNumber != new.serialNumber
|
||||
|| old.firmwareVersion != new.firmwareVersion
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -34,7 +34,12 @@ class BluetoothEventReceiver : BroadcastReceiver() {
|
||||
} else {
|
||||
log { "Event related to $bluetoothDevice" }
|
||||
}
|
||||
val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
|
||||
val supportedFeatures = try {
|
||||
ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "Missing BLUETOOTH_CONNECT, can't check device features: ${e.message}" }
|
||||
return
|
||||
}
|
||||
|
||||
if (supportedFeatures.isEmpty()) {
|
||||
log(TAG) { "Device has no features we support." }
|
||||
|
||||
@@ -22,7 +22,7 @@ class MonitorControl @Inject constructor(
|
||||
log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" }
|
||||
|
||||
val hasBluetoothPermission =
|
||||
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_CONNECT.isGranted(context)
|
||||
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_SCAN.isGranted(context)
|
||||
if (!hasBluetoothPermission) {
|
||||
log(TAG, WARN) { "Missing Bluetooth permission, not starting monitor service." }
|
||||
return
|
||||
|
||||
@@ -14,6 +14,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
@@ -26,10 +27,12 @@ import eu.darken.capod.common.hasApiLevel
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
||||
@@ -53,7 +56,6 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MonitorService : Service() {
|
||||
@@ -63,13 +65,15 @@ class MonitorService : Service() {
|
||||
@Inject lateinit var notificationManager: NotificationManager
|
||||
@Inject lateinit var generalSettings: GeneralSettings
|
||||
@Inject lateinit var permissionTool: PermissionTool
|
||||
@Inject lateinit var podMonitor: PodMonitor
|
||||
@Inject lateinit var blePodMonitor: BlePodMonitor
|
||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||
@Inject lateinit var bluetoothManager: BluetoothManager2
|
||||
@Inject lateinit var playPause: PlayPause
|
||||
@Inject lateinit var autoConnect: AutoConnect
|
||||
@Inject lateinit var popUpReaction: PopUpReaction
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||
|
||||
private val monitorScope = MonitorCoroutineScope()
|
||||
private var monitoringJob: Job? = null
|
||||
@@ -114,7 +118,15 @@ class MonitorService : Service() {
|
||||
return
|
||||
}
|
||||
|
||||
super.onCreate()
|
||||
try {
|
||||
super.onCreate()
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Hilt DI failed in onCreate(), stopping service: ${e.asLog()}" }
|
||||
Bugs.report(tag = TAG, "Hilt DI failed in onCreate()", exception = e)
|
||||
foregroundStartFailed = true
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
log(TAG, VERBOSE) { "onCreate()" }
|
||||
|
||||
// Replace early notification with the full one from injected MonitorNotifications.
|
||||
@@ -162,14 +174,14 @@ class MonitorService : Service() {
|
||||
}
|
||||
|
||||
private suspend fun doMonitor() {
|
||||
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
|
||||
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
|
||||
if (permissionsMissingOnStart.isNotEmpty()) {
|
||||
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
|
||||
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
|
||||
return
|
||||
}
|
||||
|
||||
val monitorJob = podMonitor.primaryDevice()
|
||||
.setupCommonEventHandlers(TAG) { "PodMonitor" }
|
||||
val monitorJob = deviceMonitor.primaryDevice()
|
||||
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
||||
.distinctUntilChanged()
|
||||
.throttleLatest(1000)
|
||||
.onEach { currentDevice ->
|
||||
@@ -190,10 +202,10 @@ class MonitorService : Service() {
|
||||
}
|
||||
.launchIn(monitorScope)
|
||||
|
||||
permissionTool.missingPermissions
|
||||
permissionTool.missingScanPermissions
|
||||
.flatMapLatest { missingPermsFlow ->
|
||||
if (missingPermsFlow.isNotEmpty()) {
|
||||
log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
|
||||
log(TAG, WARN) { "Aborting, scan permissions are missing: $missingPermsFlow" }
|
||||
monitorScope.coroutineContext.cancelChildren()
|
||||
emptyFlow()
|
||||
} else {
|
||||
@@ -201,8 +213,9 @@ class MonitorService : Service() {
|
||||
generalSettings.monitorMode.flow,
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices,
|
||||
) { monitorMode, profiles, connectedDevices ->
|
||||
listOf(monitorMode, profiles, connectedDevices)
|
||||
aapConnectionManager.allStates,
|
||||
) { monitorMode, profiles, connectedDevices, aapStates ->
|
||||
listOf(monitorMode, profiles, connectedDevices, aapStates)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,9 +225,13 @@ class MonitorService : Service() {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val profiles = arguments[1] as List<DeviceProfile>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val devices = arguments[2] as Collection<BluetoothDevice2>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val aapStates = arguments[3] as Map<*, *>
|
||||
|
||||
val connectedAddresses = devices.map { it.address }.toSet()
|
||||
val knownAddresses = profiles.mapNotNull { it.address }.toSet()
|
||||
log(TAG) { "Monitor mode: $monitorMode" }
|
||||
@@ -233,7 +250,7 @@ class MonitorService : Service() {
|
||||
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
|
||||
}
|
||||
|
||||
knownAddresses.any { it in connectedAddresses } -> {
|
||||
knownAddresses.any { it in connectedAddresses } || aapStates.isNotEmpty() -> {
|
||||
log(TAG) { "A device is connected, aborting any timeout." }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,10 @@ import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -22,58 +17,61 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
|
||||
fun createContentView(device: PodDevice): RemoteViews = when (device) {
|
||||
is DualPodDevice -> createDualPods(device)
|
||||
is SinglePodDevice -> createSinglePod(device)
|
||||
fun createContentView(device: PodDevice): RemoteViews = when {
|
||||
device.hasDualPods -> createDualPods(device)
|
||||
device.model != PodModel.UNKNOWN -> createSinglePod(device)
|
||||
else -> createUnknownDevice(device)
|
||||
}
|
||||
|
||||
private fun createDualPods(device: DualPodDevice): RemoteViews = RemoteViews(
|
||||
private fun createDualPods(device: PodDevice): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_dual_pods_small
|
||||
).apply {
|
||||
// Left
|
||||
val leftPercent = device.batteryLeftPodPercent
|
||||
val leftPercent = device.batteryLeft
|
||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
||||
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
|
||||
val isLeftPodCharging = device.isLeftPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
|
||||
val isLeftPodInEar = device.isLeftInEar ?: false
|
||||
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
|
||||
|
||||
// Case
|
||||
setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE)
|
||||
(device as? HasCase)?.let { case ->
|
||||
setViewVisibility(R.id.pod_case_charging, if (device.hasCase) View.VISIBLE else View.GONE)
|
||||
if (device.hasCase) {
|
||||
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
|
||||
val casePercent = case.batteryCasePercent
|
||||
val casePercent = device.batteryCase
|
||||
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
|
||||
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
|
||||
setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
// Right
|
||||
val rightPercent = device.batteryRightPodPercent
|
||||
val rightPercent = device.batteryRight
|
||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
||||
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
|
||||
val isRightPodCharging = device.isRightPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
|
||||
val isRightPodInEar = device.isRightInEar ?: false
|
||||
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
private fun createSinglePod(device: SinglePodDevice): RemoteViews = RemoteViews(
|
||||
private fun createSinglePod(device: PodDevice): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_single_pods_small
|
||||
).apply {
|
||||
val headsetPercent = device.batteryHeadsetPercent
|
||||
val headsetPercent = device.batteryHeadset
|
||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
|
||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||
if (device is HasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
|
||||
if (device.hasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
if (device is HasChargeDetectionDual) {
|
||||
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
||||
if (device.isHeadsetBeingCharged != null) {
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,61 +82,64 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.device, device.getLabel(context))
|
||||
}
|
||||
|
||||
fun createBigContentView(device: PodDevice): RemoteViews = when (device) {
|
||||
is DualPodDevice -> createDualPodsBig(device)
|
||||
is SinglePodDevice -> createSinglePodBig(device)
|
||||
fun createBigContentView(device: PodDevice): RemoteViews = when {
|
||||
device.hasDualPods -> createDualPodsBig(device)
|
||||
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
|
||||
else -> createUnknownDeviceBig(device)
|
||||
}
|
||||
|
||||
private fun createDualPodsBig(device: DualPodDevice): RemoteViews = RemoteViews(
|
||||
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_dual_pods_big
|
||||
).apply {
|
||||
// Left
|
||||
val leftPercent = device.batteryLeftPodPercent
|
||||
val leftPercent = device.batteryLeft
|
||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
|
||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
||||
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
|
||||
val isLeftPodCharging = device.isLeftPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
|
||||
val isLeftPodInEar = device.isLeftInEar ?: false
|
||||
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
|
||||
|
||||
// Case
|
||||
setViewVisibility(R.id.pod_case_container, if (device is HasCase) View.VISIBLE else View.GONE)
|
||||
(device as? HasCase)?.let { case ->
|
||||
setViewVisibility(R.id.pod_case_container, if (device.hasCase) View.VISIBLE else View.GONE)
|
||||
if (device.hasCase) {
|
||||
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
|
||||
val casePercent = case.batteryCasePercent
|
||||
val casePercent = device.batteryCase
|
||||
setProgressBar(R.id.pod_case_progress, 100, percentToInt(casePercent), false)
|
||||
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
|
||||
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
|
||||
setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
// Right
|
||||
val rightPercent = device.batteryRightPodPercent
|
||||
val rightPercent = device.batteryRight
|
||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
|
||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
||||
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
|
||||
val isRightPodCharging = device.isRightPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
|
||||
val isRightPodInEar = device.isRightInEar ?: false
|
||||
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
private fun createSinglePodBig(device: SinglePodDevice): RemoteViews = RemoteViews(
|
||||
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_single_pods_big
|
||||
).apply {
|
||||
val headsetPercent = device.batteryHeadsetPercent
|
||||
val headsetPercent = device.batteryHeadset
|
||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
|
||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||
if (device is HasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
|
||||
if (device.hasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
if (device is HasChargeDetectionDual) {
|
||||
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
||||
if (device.isHeadsetBeingCharged != null) {
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,4 +155,4 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
return (percent * 100).roundToInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,9 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -57,7 +53,11 @@ class MonitorNotifications @Inject constructor(
|
||||
setOngoing(true)
|
||||
}
|
||||
|
||||
private fun getBuilder(device: PodDevice?, channelId: String, showHint: Boolean = false): NotificationCompat.Builder {
|
||||
private fun getBuilder(
|
||||
device: PodDevice?,
|
||||
channelId: String,
|
||||
showHint: Boolean = false
|
||||
): NotificationCompat.Builder {
|
||||
if (device == null) {
|
||||
return baseBuilder(channelId).apply {
|
||||
if (showHint) {
|
||||
@@ -75,59 +75,42 @@ class MonitorNotifications @Inject constructor(
|
||||
|
||||
return baseBuilder(channelId).apply {
|
||||
|
||||
// Options here should be mutually exclusive, and are prioritized by their order of importance
|
||||
// Some options are omitted here, as they will conflict with other options
|
||||
// TODO: Implement a settings pane to allow user to customize this
|
||||
val stateText = when {
|
||||
// Pods charging state
|
||||
// This goes first as pods should not be worn if it is still charging
|
||||
device is HasChargeDetection && device.isHeadsetBeingCharged -> {
|
||||
device.isHeadsetBeingCharged == true -> {
|
||||
context.getString(R.string.pods_charging_label)
|
||||
}
|
||||
|
||||
// Pods wear state
|
||||
device is HasEarDetection -> {
|
||||
if (device.isBeingWorn) context.getString(R.string.headset_being_worn_label)
|
||||
device.hasEarDetection -> {
|
||||
if (device.isBeingWorn == true) context.getString(R.string.headset_being_worn_label)
|
||||
else context.getString(R.string.headset_not_being_worn_label)
|
||||
}
|
||||
|
||||
// Case charge state
|
||||
// This is under pods wear state as we don't want it conflicting with it
|
||||
device is HasCase && device.isCaseCharging -> {
|
||||
device.hasCase && device.isCaseCharging == true -> {
|
||||
context.getString(R.string.pods_charging_label)
|
||||
}
|
||||
|
||||
else -> context.getString(R.string.pods_case_unknown_state)
|
||||
}
|
||||
|
||||
val batteryText = when (device) {
|
||||
is DualPodDevice -> {
|
||||
val leftPercent = device.batteryLeftPodPercent
|
||||
val rightPercent = device.batteryRightPodPercent
|
||||
val left = formatBatteryPercent(context, leftPercent)
|
||||
val right = formatBatteryPercent(context, rightPercent)
|
||||
when {
|
||||
device is HasCase -> {
|
||||
val casePercent = device.batteryCasePercent
|
||||
val case = formatBatteryPercent(context, casePercent)
|
||||
"$left $case $right"
|
||||
}
|
||||
|
||||
else -> "$left $right"
|
||||
val batteryText = when {
|
||||
device.hasDualPods -> {
|
||||
val left = formatBatteryPercent(context, device.batteryLeft)
|
||||
val right = formatBatteryPercent(context, device.batteryRight)
|
||||
if (device.hasCase) {
|
||||
val case = formatBatteryPercent(context, device.batteryCase)
|
||||
"$left $case $right"
|
||||
} else {
|
||||
"$left $right"
|
||||
}
|
||||
}
|
||||
|
||||
is SinglePodDevice -> {
|
||||
val headsetPercent = device.batteryHeadsetPercent
|
||||
val headset = formatBatteryPercent(context, headsetPercent)
|
||||
when {
|
||||
device is HasCase -> {
|
||||
val casePercent = device.batteryCasePercent
|
||||
val case = formatBatteryPercent(context, casePercent)
|
||||
"$headset $case"
|
||||
}
|
||||
|
||||
else -> headset
|
||||
device.model != PodModel.UNKNOWN -> {
|
||||
val headset = formatBatteryPercent(context, device.batteryHeadset)
|
||||
if (device.hasCase) {
|
||||
val case = formatBatteryPercent(context, device.batteryCase)
|
||||
"$headset $case"
|
||||
} else {
|
||||
headset
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
interface PodDevice {
|
||||
|
||||
val identifier: Id
|
||||
|
||||
val model: Model
|
||||
|
||||
val address: BluetoothAddress
|
||||
get() = scanResult.address
|
||||
|
||||
val seenLastAt: Instant
|
||||
|
||||
val seenFirstAt: Instant
|
||||
|
||||
val seenCounter: Int
|
||||
|
||||
val scanResult: BleScanResult
|
||||
|
||||
val rssi: Int
|
||||
get() = scanResult.rssi
|
||||
|
||||
val reliability: Float
|
||||
|
||||
val signalQuality: Float
|
||||
get() {
|
||||
/**
|
||||
* This is not correct but it works ¯\_(ツ)_/¯
|
||||
* The range of the RSSI is device specific (ROMs).
|
||||
*/
|
||||
val sqRssi = ((100 - abs(rssi)) / 100f)
|
||||
val sqReliability = max(BASE_CONFIDENCE, reliability)
|
||||
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
|
||||
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
|
||||
return (sqRssi + sqReliability + sqAge) / 2f
|
||||
}
|
||||
|
||||
val rawData: Map<Int, ByteArray>
|
||||
get() = scanResult.manufacturerSpecificData
|
||||
|
||||
val rawDataHex: List<String>
|
||||
get() = rawData.entries.map { entry ->
|
||||
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
val profile: DeviceProfile?
|
||||
}
|
||||
|
||||
val meta: Meta
|
||||
|
||||
fun getLabel(context: Context): String = model.label
|
||||
|
||||
@get:DrawableRes
|
||||
val iconRes: Int
|
||||
get() = model.iconRes
|
||||
|
||||
@JvmInline
|
||||
value class Id(private val id: UUID = UUID.randomUUID())
|
||||
|
||||
@Serializable
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class Model(
|
||||
val label: String,
|
||||
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
|
||||
) {
|
||||
@SerialName("airpods.gen1") @Json(name = "airpods.gen1") AIRPODS_GEN1(
|
||||
label = "AirPods (Gen 1)",
|
||||
iconRes = R.drawable.device_airpods_gen1_both,
|
||||
),
|
||||
@SerialName("airpods.gen2") @Json(name = "airpods.gen2") AIRPODS_GEN2(
|
||||
"AirPods (Gen 2)",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
),
|
||||
@SerialName("airpods.gen3") @Json(name = "airpods.gen3") AIRPODS_GEN3(
|
||||
"AirPods (Gen 3)",
|
||||
R.drawable.device_airpods_gen3_both,
|
||||
),
|
||||
@SerialName("airpods.gen4") @Json(name = "airpods.gen4") AIRPODS_GEN4(
|
||||
"AirPods (Gen 4)",
|
||||
R.drawable.device_airpods_gen3_both,
|
||||
),
|
||||
@SerialName("airpods.gen4.anc") @Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
|
||||
"AirPods (Gen 4 ANC)",
|
||||
R.drawable.device_airpods_gen4anc_both,
|
||||
),
|
||||
@SerialName("airpods.pro") @Json(name = "airpods.pro") AIRPODS_PRO(
|
||||
"AirPods Pro",
|
||||
R.drawable.device_airpods_pro2_both
|
||||
),
|
||||
@SerialName("airpods.pro2") @Json(name = "airpods.pro2") AIRPODS_PRO2(
|
||||
"AirPods Pro 2",
|
||||
R.drawable.device_airpods_pro2_both
|
||||
),
|
||||
@SerialName("airpods.pro2.usbc") @Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
|
||||
"AirPods Pro 2 USB-C",
|
||||
R.drawable.device_airpods_pro2_both
|
||||
),
|
||||
@SerialName("airpods.pro3") @Json(name = "airpods.pro3") AIRPODS_PRO3(
|
||||
"AirPods Pro 3",
|
||||
R.drawable.device_airpods_pro2_both
|
||||
),
|
||||
@SerialName("airpods.max") @Json(name = "airpods.max") AIRPODS_MAX(
|
||||
"AirPods Max",
|
||||
R.drawable.device_airpods_max
|
||||
),
|
||||
@SerialName("airpods.max.usbc") @Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
|
||||
"AirPods Max USB-C",
|
||||
R.drawable.device_airpods_max
|
||||
),
|
||||
@SerialName("beats.flex") @Json(name = "beats.flex") BEATS_FLEX(
|
||||
"Beats Flex",
|
||||
R.drawable.device_beats_earbuds,
|
||||
),
|
||||
@SerialName("beats.solo.3") @Json(name = "beats.solo.3") BEATS_SOLO_3(
|
||||
"Beats Solo 3",
|
||||
R.drawable.device_beats_headphones,
|
||||
),
|
||||
@SerialName("beats.studio.3") @Json(name = "beats.studio.3") BEATS_STUDIO_3(
|
||||
"Beats Studio 3",
|
||||
R.drawable.device_beats_studio3,
|
||||
),
|
||||
@SerialName("beats.x") @Json(name = "beats.x") BEATS_X(
|
||||
"Beats X",
|
||||
R.drawable.device_beats_x,
|
||||
),
|
||||
@SerialName("beats.powerbeats.3") @Json(name = "beats.powerbeats.3") POWERBEATS_3(
|
||||
"Power Beats 3",
|
||||
R.drawable.device_powerbeats_3,
|
||||
),
|
||||
@SerialName("beats.powerbeats.4") @Json(name = "beats.powerbeats.4") POWERBEATS_4(
|
||||
"Power Beats 4",
|
||||
R.drawable.device_powerbeats_4,
|
||||
),
|
||||
@SerialName("beats.powerbeats.pro") @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
|
||||
"Power Beats Pro",
|
||||
R.drawable.device_powerbeats_pro_both,
|
||||
),
|
||||
@SerialName("beats.powerbeats.pro2") @Json(name = "beats.powerbeats.pro2") POWERBEATS_PRO2(
|
||||
"Power Beats Pro 2",
|
||||
R.drawable.device_powerbeats_pro2_both,
|
||||
),
|
||||
@SerialName("beats.fit.pro") @Json(name = "beats.fit.pro") BEATS_FIT_PRO(
|
||||
"Beats Fit Pro",
|
||||
R.drawable.device_beats_fitpro_both,
|
||||
),
|
||||
@SerialName("fakes.tws.i99999") @Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
|
||||
"AirPods (Gen 1)? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
),
|
||||
@SerialName("fakes.generic.airpods.gen2") @Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
|
||||
"AirPods (Gen 2)? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
),
|
||||
@SerialName("fakes.generic.airpods.gen3") @Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
|
||||
"AirPods (Gen 3)? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_gen3_both,
|
||||
),
|
||||
@SerialName("fakes.varunr.airpodspro") @Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
|
||||
"AirPods Pro? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
),
|
||||
@SerialName("fakes.generic.airpods.pro2") @Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
|
||||
"AirPods Pro2? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
),
|
||||
@SerialName("unknown") @Json(name = "unknown") UNKNOWN(
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BASE_CONFIDENCE = 0.0f
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface SinglePodDevice : PodDevice {
|
||||
|
||||
val batteryHeadsetPercent: Float?
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen2
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen3
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4Anc
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsMaxUsbc
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2Usbc
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro3
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsFitPro
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsFlex
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsSolo3
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsStudio3
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsX
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeats3
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeats4
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro2
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen1
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen2
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
abstract class AppleFactoryModule {
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro3(factory: AirPodsPro3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeatsPro2(factory: PowerBeatsPro2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user