mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
72
Commits
v4.0.2-rc1
...
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 |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-vital:
|
||||
name: Lint vitals
|
||||
@@ -19,6 +22,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
@@ -37,6 +42,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
@@ -54,6 +61,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
@@ -66,5 +75,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate metadata lengths
|
||||
run: bash fastlane/check_metadata_length.sh
|
||||
@@ -8,10 +8,15 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validation:
|
||||
name: "Validation"
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
|
||||
|
||||
@@ -11,6 +11,9 @@ on:
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-github:
|
||||
name: Create GitHub release
|
||||
@@ -33,6 +36,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
@@ -108,6 +112,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project and build environment
|
||||
uses: ./.github/actions/common-setup
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="eu.darken.capod.debug.l2cap.L2capPocActivity"
|
||||
android:exported="true"
|
||||
android:label="L2CAP PoC">
|
||||
<intent-filter>
|
||||
<action android:name="eu.darken.capod.L2CAP_POC" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,256 @@
|
||||
package eu.darken.capod.debug.l2cap
|
||||
|
||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class L2capPocActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "L2capPoc"
|
||||
private const val PSM = 0x1001
|
||||
|
||||
private val HANDSHAKE = byteArrayOf(
|
||||
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
private val ANC_OFF = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x01, 0x00, 0x00, 0x00
|
||||
)
|
||||
private val ANC_ON = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x02, 0x00, 0x00, 0x00
|
||||
)
|
||||
private val TRANSPARENCY = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x03, 0x00, 0x00, 0x00
|
||||
)
|
||||
}
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) Log.d(TAG, "BLUETOOTH_CONNECT granted")
|
||||
else Log.w(TAG, "BLUETOOTH_CONNECT denied")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
PocScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@SuppressLint("MissingPermission")
|
||||
@Composable
|
||||
private fun PocScreen() {
|
||||
val btManager = remember { getSystemService(BluetoothManager::class.java) }
|
||||
val adapter: BluetoothAdapter? = remember { btManager?.adapter }
|
||||
val logEntries = remember { mutableStateListOf<String>() }
|
||||
val logListState = rememberLazyListState()
|
||||
var selectedDevice by remember { mutableStateOf<BluetoothDevice?>(null) }
|
||||
var socket by remember { mutableStateOf<BluetoothSocket?>(null) }
|
||||
var outputStream by remember { mutableStateOf<OutputStream?>(null) }
|
||||
var connected by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var readerJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val timeFmt = remember { SimpleDateFormat("HH:mm:ss.SSS", Locale.US) }
|
||||
|
||||
fun log(msg: String) {
|
||||
val line = "${timeFmt.format(Date())} $msg"
|
||||
Log.d(TAG, msg)
|
||||
logEntries.add(line)
|
||||
}
|
||||
|
||||
fun ByteArray.hex(): String = joinToString(" ") { "%02X".format(it) }
|
||||
|
||||
LaunchedEffect(logEntries.size) {
|
||||
if (logEntries.isNotEmpty()) logListState.animateScrollToItem(logEntries.size - 1)
|
||||
}
|
||||
|
||||
fun startReader(input: InputStream) {
|
||||
readerJob = scope.launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(1024)
|
||||
try {
|
||||
while (isActive) {
|
||||
val len = input.read(buf)
|
||||
if (len == -1) {
|
||||
launch(Dispatchers.Main) { log("<<< Stream closed by remote") }
|
||||
break
|
||||
}
|
||||
val data = buf.copyOf(len)
|
||||
launch(Dispatchers.Main) { log("<<< [${data.size}] ${data.hex()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
launch(Dispatchers.Main) { log("<<< Read error: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text("L2CAP PoC — AAP Connection", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
if (adapter == null) {
|
||||
Text("No Bluetooth adapter found", color = MaterialTheme.colorScheme.error)
|
||||
return@Column
|
||||
}
|
||||
|
||||
// Bonded devices
|
||||
if (!connected) {
|
||||
Text("Bonded Devices:", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
||||
val bonded = remember(adapter) {
|
||||
try { adapter.bondedDevices?.toList() ?: emptyList() }
|
||||
catch (_: SecurityException) { emptyList() }
|
||||
}
|
||||
bonded.forEach { device ->
|
||||
val name = try { device.name ?: "Unknown" } catch (_: SecurityException) { "Unknown" }
|
||||
val isSelected = selectedDevice == device
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { selectedDevice = device },
|
||||
colors = if (isSelected) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else CardDefaults.cardColors()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(8.dp)) {
|
||||
Text("$name ${device.address}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection controls
|
||||
FlowRow(modifier = Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
val dev = selectedDevice ?: return@Button
|
||||
log("=== Connecting to ${dev.address} ===")
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val sock = L2capSocketFactory().createSocket(dev, PSM)
|
||||
withContext(Dispatchers.Main) { log("Socket created, connecting...") }
|
||||
sock.connect()
|
||||
withContext(Dispatchers.Main) {
|
||||
log("Connected!")
|
||||
socket = sock
|
||||
outputStream = sock.outputStream
|
||||
connected = true
|
||||
}
|
||||
startReader(sock.inputStream)
|
||||
|
||||
withContext(Dispatchers.Main) { log(">>> Handshake [${HANDSHAKE.size}] ${HANDSHAKE.hex()}") }
|
||||
sock.outputStream.write(HANDSHAKE)
|
||||
sock.outputStream.flush()
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) { log("Failed: ${e::class.simpleName}: ${e.message}") }
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = selectedDevice != null && !connected
|
||||
) { Text("Connect") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
log("=== Disconnecting ===")
|
||||
readerJob?.cancel()
|
||||
readerJob = null
|
||||
try { socket?.close() } catch (_: Exception) {}
|
||||
socket = null
|
||||
outputStream = null
|
||||
connected = false
|
||||
},
|
||||
enabled = connected
|
||||
) { Text("Disconnect") }
|
||||
}
|
||||
|
||||
// AAP command buttons
|
||||
if (connected) {
|
||||
FlowRow(modifier = Modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
fun sendCommand(name: String, cmd: ByteArray) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) { log(">>> $name [${cmd.size}] ${cmd.hex()}") }
|
||||
outputStream?.write(cmd)
|
||||
outputStream?.flush()
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) { log(">>> Send error: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(onClick = { sendCommand("ANC Off", ANC_OFF) }) { Text("ANC Off") }
|
||||
Button(onClick = { sendCommand("ANC On", ANC_ON) }) { Text("ANC On") }
|
||||
Button(onClick = { sendCommand("Transparency", TRANSPARENCY) }) { Text("Transparency") }
|
||||
Button(onClick = { sendCommand("Handshake", HANDSHAKE) }) { Text("Handshake") }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
LazyColumn(state = logListState, modifier = Modifier.weight(1f)) {
|
||||
items(logEntries) { entry ->
|
||||
Text(text = entry, fontFamily = FontFamily.Monospace, fontSize = 11.sp, lineHeight = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
||||
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
||||
import eu.darken.capod.main.ui.widget.WidgetTheme
|
||||
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.common.theming.CapodTheme
|
||||
@@ -29,7 +22,12 @@ import eu.darken.capod.main.ui.overview.OverviewScreen
|
||||
import eu.darken.capod.main.ui.overview.OverviewViewModel
|
||||
import eu.darken.capod.main.ui.settings.SettingsScreen
|
||||
import eu.darken.capod.main.ui.settings.SettingsViewModel
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
||||
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
||||
import eu.darken.capod.main.ui.widget.WidgetTheme
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.profiles.ui.DeviceManagerScreen
|
||||
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
||||
import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationScreen
|
||||
@@ -37,6 +35,7 @@ import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationViewModel
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.ui.ReactionSettingsScreen
|
||||
import eu.darken.capod.reaction.ui.ReactionSettingsViewModel
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
||||
|
||||
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||
|
||||
@@ -47,15 +46,15 @@ internal fun DashboardContent() = PreviewWrapper {
|
||||
now = MOCK_NOW,
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.airPodsProMixed(),
|
||||
MockPodDataProvider.airPodsMax(),
|
||||
MockPodDataProvider.unknownDevice(),
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
),
|
||||
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
|
||||
showUnmatchedDevices = false,
|
||||
@@ -73,9 +72,9 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
||||
DeviceManagerScreen(
|
||||
state = DeviceManagerViewModel.State(
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("Work AirPods", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("Gym Beats", PodDevice.Model.POWERBEATS_PRO),
|
||||
MockPodDataProvider.profile("Work AirPods", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("Gym Beats", PodModel.POWERBEATS_PRO),
|
||||
),
|
||||
),
|
||||
onBack = {},
|
||||
@@ -92,11 +91,11 @@ internal fun AddProfileContent() = PreviewWrapper {
|
||||
name = "",
|
||||
nameError = null,
|
||||
selectedModel = null,
|
||||
availableModels = PodDevice.Model.entries.filter { it != PodDevice.Model.UNKNOWN },
|
||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||
identityKey = null,
|
||||
encryptionKey = null,
|
||||
selectedDevice = null,
|
||||
bondedDevices = emptyList(),
|
||||
bondedDeviceItems = emptyList(),
|
||||
minimumSignalQuality = 0.15f,
|
||||
canSave = false,
|
||||
),
|
||||
@@ -158,8 +157,8 @@ internal fun ReactionSettingsContent() = PreviewWrapper {
|
||||
@Composable
|
||||
internal fun WidgetConfigurationContent() = PreviewWrapper {
|
||||
val profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
)
|
||||
WidgetConfigurationScreen(
|
||||
state = WidgetConfigurationViewModel.State(
|
||||
@@ -201,7 +200,7 @@ internal fun CasePopUpContent() {
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PopUpCard(
|
||||
device = MockPodDataProvider.airPodsProMixed(),
|
||||
device = MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
onClose = {},
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
|
||||
@@ -5,14 +5,11 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -24,6 +21,8 @@ import androidx.compose.material.icons.twotone.BluetoothConnected
|
||||
import androidx.compose.material.icons.twotone.Favorite
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.twotone.PlayCircle
|
||||
import androidx.compose.material.icons.twotone.Headphones
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material.icons.twotone.Widgets
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -36,6 +35,7 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -111,24 +112,35 @@ fun UpgradeScreen(
|
||||
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
|
||||
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
|
||||
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
|
||||
Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
|
||||
Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
|
||||
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateUp) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
@@ -208,6 +220,14 @@ fun UpgradeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_benefit_disclaimer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
@@ -238,18 +258,6 @@ fun UpgradeScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onNavigateUp,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<string name="foss_upgrade_donate_label">Пожертвовать</string>
|
||||
<string name="foss_upgrade_alreadydonated_label">Я уже пожертвовал</string>
|
||||
<string name="foss_upgrade_no_money_label">Я потратил все свои деньги на AirPods</string>
|
||||
<string name="upgrade_foss_preamble">CAPod FOSS бесплатный и с открытым исходным кодом. Если вам полезно приложение, рассмотрите возможность спонсировать разработку, чтобы помочь проекту продолжать развиваться.</string>
|
||||
<string name="upgrade_foss_preamble">CAPod FOSS бесплатный и с открытым исходным кодом. Если Вам полезно приложение, рассмотрите возможность спонсировать разработку, чтобы помочь проекту продолжать развиваться.</string>
|
||||
<string name="upgrade_foss_sponsor_action">Поддержать разработку</string>
|
||||
<string name="upgrade_foss_sponsor_subtitle">Без рекламы. Без слежки. Без привязки к Google Play.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">Вернулись уже? Ваша поддержка помогает CAPod жить.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">Вернулись? Ваша поддержка помогает CAPod жить.</string>
|
||||
</resources>
|
||||
|
||||
@@ -7,14 +7,11 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -27,6 +24,8 @@ import androidx.compose.material.icons.twotone.Favorite
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.twotone.PlayCircle
|
||||
import androidx.compose.material.icons.twotone.Stars
|
||||
import androidx.compose.material.icons.twotone.Headphones
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material.icons.twotone.Widgets
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -37,8 +36,10 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -125,17 +126,33 @@ fun UpgradeScreen(
|
||||
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
|
||||
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
|
||||
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
|
||||
Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
|
||||
Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
|
||||
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars)) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateUp) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
@@ -215,6 +232,14 @@ fun UpgradeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_benefit_disclaimer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (state == null) {
|
||||
@@ -231,17 +256,6 @@ fun UpgradeScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onNavigateUp,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
|
||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -27,7 +28,7 @@ import javax.inject.Inject
|
||||
open class App : Application() {
|
||||
|
||||
@Inject lateinit var autoReporting: AutomaticBugReporter
|
||||
@Inject lateinit var podMonitor: PodMonitor
|
||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||
@Inject lateinit var widgetManager: WidgetManager
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
@Inject @AppScope lateinit var appScope: CoroutineScope
|
||||
@@ -42,7 +43,7 @@ open class App : Application() {
|
||||
|
||||
appScope.launch { widgetManager.refreshWidgets() }
|
||||
|
||||
podMonitor.devicesWithProfiles()
|
||||
deviceMonitor.devicesWithProfiles()
|
||||
.distinctUntilChanged()
|
||||
.throttleLatest(1000)
|
||||
.onEach {
|
||||
|
||||
@@ -45,7 +45,7 @@ class MediaControl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendKey(keyCode: Int) {
|
||||
internal suspend fun sendKey(keyCode: Int) {
|
||||
log(TAG) { "Sending up+down KeyEvent: $keyCode" }
|
||||
val eventTime = SystemClock.uptimeMillis()
|
||||
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||
@@ -53,6 +53,24 @@ class MediaControl @Inject constructor(
|
||||
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime + 200, eventTime + 200, KeyEvent.ACTION_UP, keyCode, 0))
|
||||
}
|
||||
|
||||
fun adjustVolumeUp() {
|
||||
log(TAG, INFO) { "adjustVolumeUp()" }
|
||||
audioManager.adjustSuggestedStreamVolume(
|
||||
AudioManager.ADJUST_RAISE,
|
||||
AudioManager.USE_DEFAULT_STREAM_TYPE,
|
||||
AudioManager.FLAG_SHOW_UI,
|
||||
)
|
||||
}
|
||||
|
||||
fun adjustVolumeDown() {
|
||||
log(TAG, INFO) { "adjustVolumeDown()" }
|
||||
audioManager.adjustSuggestedStreamVolume(
|
||||
AudioManager.ADJUST_LOWER,
|
||||
AudioManager.USE_DEFAULT_STREAM_TYPE,
|
||||
AudioManager.FLAG_SHOW_UI,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("MediaControl")
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ object Nav {
|
||||
|
||||
@Serializable
|
||||
data object Upgrade : Main
|
||||
|
||||
@Serializable
|
||||
data class DeviceSettings(val address: String) : Main
|
||||
|
||||
@Serializable
|
||||
data object StemActionConfig : Main
|
||||
}
|
||||
|
||||
sealed interface Settings : NavigationDestination {
|
||||
|
||||
@@ -58,18 +58,26 @@ fun SettingsBaseItem(
|
||||
Color.Unspecified
|
||||
}
|
||||
|
||||
val iconModifier = if (subtitle != null) {
|
||||
Modifier
|
||||
.align(Alignment.Top)
|
||||
.padding(top = 2.dp)
|
||||
.size(iconSize)
|
||||
} else {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
modifier = iconModifier,
|
||||
tint = tint,
|
||||
)
|
||||
} else if (iconPainter != null) {
|
||||
Icon(
|
||||
painter = iconPainter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
modifier = iconModifier,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package eu.darken.capod.common.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
@@ -26,6 +29,7 @@ fun SettingsSliderItem(
|
||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||
steps: Int = 0,
|
||||
enabled: Boolean = true,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
valueLabel: ((Float) -> String)? = null,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
@@ -35,28 +39,33 @@ fun SettingsSliderItem(
|
||||
subtitle = subtitle,
|
||||
onClick = {},
|
||||
enabled = enabled,
|
||||
trailingContent = if (valueLabel != null) {
|
||||
{
|
||||
Text(
|
||||
text = valueLabel(value),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
enabled = enabled,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 56.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
onValueChangeFinished = onValueChangeFinished,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (valueLabel != null) {
|
||||
Text(
|
||||
text = valueLabel(value),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
|
||||
modifier = Modifier.width(42.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import eu.darken.capod.common.theming.ThemeColor
|
||||
import eu.darken.capod.common.theming.ThemeMode
|
||||
import eu.darken.capod.common.theming.ThemeStyle
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
||||
import kotlinx.serialization.builtins.nullable
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
@@ -54,7 +54,8 @@ class GeneralSettings @Inject constructor(
|
||||
writer = { value -> value },
|
||||
)
|
||||
|
||||
val oldMainDeviceModel = dataStore.createValue("core.maindevice.model", PodDevice.Model.UNKNOWN, json, onErrorFallbackToDefault = true)
|
||||
val oldMainDeviceModel =
|
||||
dataStore.createValue("core.maindevice.model", PodModel.UNKNOWN, json, onErrorFallbackToDefault = true)
|
||||
|
||||
val oldMainDeviceIdentityKey = dataStore.createValue<IdentityResolvingKey?>(
|
||||
key = "core.maindevice.identitykey",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.darken.capod.main.ui.devicesettings
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class DeviceSettingsNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Main.DeviceSettings> { key ->
|
||||
DeviceSettingsScreenHost(address = key.address)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class Mod {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bind(entry: DeviceSettingsNavigation): NavigationEntry
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
package eu.darken.capod.main.ui.devicesettings
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DeviceSettingsViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
private val targetAddress = MutableStateFlow<BluetoothAddress?>(null)
|
||||
private var initialized = false
|
||||
|
||||
fun initialize(address: BluetoothAddress) {
|
||||
if (initialized && targetAddress.value == address) return
|
||||
initialized = true
|
||||
targetAddress.value = address
|
||||
}
|
||||
|
||||
private val updateTicker = channelFlow<Unit> {
|
||||
while (isActive) {
|
||||
trySend(Unit)
|
||||
delay(3000)
|
||||
}
|
||||
}
|
||||
|
||||
private val isForceConnecting = MutableStateFlow(false)
|
||||
|
||||
sealed interface Event {
|
||||
data object OpenBluetoothSettings : Event
|
||||
data class SendFailed(val command: AapCommand, val message: String?) : Event
|
||||
data object SystemRenameUnavailable : Event
|
||||
}
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
|
||||
val state = targetAddress.flatMapLatest { address ->
|
||||
if (address == null) return@flatMapLatest flowOf(State(device = null))
|
||||
combine(
|
||||
updateTicker,
|
||||
deviceMonitor.devices,
|
||||
upgradeRepo.upgradeInfo,
|
||||
isForceConnecting,
|
||||
) { _, devices, upgrade, forcing ->
|
||||
State(
|
||||
device = devices.firstOrNull { it.address == address },
|
||||
now = Instant.now(),
|
||||
isPro = upgrade.isPro,
|
||||
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
|
||||
isForceConnecting = forcing,
|
||||
)
|
||||
}
|
||||
}.asLiveState()
|
||||
|
||||
data class State(
|
||||
val device: PodDevice?,
|
||||
val now: Instant = Instant.now(),
|
||||
val isPro: Boolean = false,
|
||||
val isNudgeAvailable: Boolean = true,
|
||||
val isForceConnecting: Boolean = false,
|
||||
)
|
||||
|
||||
fun forceConnect() = launch {
|
||||
if (!isForceConnecting.compareAndSet(expect = false, update = true)) {
|
||||
log(TAG) { "forceConnect already in progress" }
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val address = targetAddress.value ?: run {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
val bonded = try {
|
||||
bluetoothManager.bondedDevices().first().firstOrNull { it.address == address }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "bondedDevices() failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
if (bonded == null) {
|
||||
log(TAG, WARN) { "No bonded device for $address — opening Bluetooth settings" }
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
if (!bluetoothManager.isNudgeAvailable) {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
val accepted = try {
|
||||
bluetoothManager.nudgeConnection(bonded)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
|
||||
false
|
||||
}
|
||||
log(TAG) { "nudgeConnection($bonded) accepted=$accepted" }
|
||||
if (!accepted) {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
}
|
||||
// On accepted=true, AapAutoConnect.initialConnect() will pick up the new
|
||||
// connectedDevices entry and trigger the AAP handshake. The card disappears
|
||||
// when device.isAapConnected becomes true.
|
||||
} finally {
|
||||
isForceConnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendInternal(command: AapCommand) {
|
||||
val address = targetAddress.value ?: return
|
||||
try {
|
||||
aapManager.sendCommand(address, command)
|
||||
log(TAG) { "Sent $command to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to send $command: ${e.message}" }
|
||||
// SingleEventFlow is backed by a BUFFERED Channel — use the suspending emit to avoid
|
||||
// dropping the event under momentary backpressure.
|
||||
events.emit(Event.SendFailed(command, e.message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(command: AapCommand) {
|
||||
launch { sendInternal(command) }
|
||||
}
|
||||
|
||||
private fun sendProGated(command: AapCommand) = launch {
|
||||
if (upgradeRepo.isPro()) {
|
||||
sendInternal(command)
|
||||
} else {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAncMode(mode: AapSetting.AncMode.Value) = send(AapCommand.SetAncMode(mode))
|
||||
|
||||
fun setConversationalAwareness(enabled: Boolean) = send(AapCommand.SetConversationalAwareness(enabled))
|
||||
|
||||
fun setNcWithOneAirPod(enabled: Boolean) = send(AapCommand.SetNcWithOneAirPod(enabled))
|
||||
|
||||
fun setPersonalizedVolume(enabled: Boolean) = send(AapCommand.SetPersonalizedVolume(enabled))
|
||||
|
||||
fun setToneVolume(level: Int) = send(AapCommand.SetToneVolume(level))
|
||||
|
||||
fun setAdaptiveAudioNoise(level: Int) = send(AapCommand.SetAdaptiveAudioNoise(level))
|
||||
|
||||
fun setPressSpeed(value: AapSetting.PressSpeed.Value) = send(AapCommand.SetPressSpeed(value))
|
||||
|
||||
fun setPressHoldDuration(value: AapSetting.PressHoldDuration.Value) = send(AapCommand.SetPressHoldDuration(value))
|
||||
|
||||
fun setVolumeSwipe(enabled: Boolean) = send(AapCommand.SetVolumeSwipe(enabled))
|
||||
|
||||
fun setVolumeSwipeLength(value: AapSetting.VolumeSwipeLength.Value) = send(AapCommand.SetVolumeSwipeLength(value))
|
||||
|
||||
fun setEndCallMuteMic(
|
||||
muteMic: AapSetting.EndCallMuteMic.MuteMicMode,
|
||||
endCall: AapSetting.EndCallMuteMic.EndCallMode,
|
||||
) = send(AapCommand.SetEndCallMuteMic(muteMic, endCall))
|
||||
|
||||
fun setMicrophoneMode(mode: AapSetting.MicrophoneMode.Mode) = send(AapCommand.SetMicrophoneMode(mode))
|
||||
|
||||
fun setEarDetectionEnabled(enabled: Boolean) = send(AapCommand.SetEarDetectionEnabled(enabled))
|
||||
|
||||
fun setListeningModeCycle(modeMask: Int) = sendProGated(AapCommand.SetListeningModeCycle(modeMask))
|
||||
|
||||
fun setAllowOffOption(enabled: Boolean) = sendProGated(AapCommand.SetAllowOffOption(enabled))
|
||||
|
||||
fun setSleepDetection(enabled: Boolean) = sendProGated(AapCommand.SetSleepDetection(enabled))
|
||||
|
||||
fun setDeviceName(name: String) = launch {
|
||||
val address = targetAddress.value ?: return@launch
|
||||
sendInternal(AapCommand.SetDeviceName(name))
|
||||
|
||||
// Also try to update the Android-local bond alias so the new name shows in Android's
|
||||
// Bluetooth settings too. The AAP rename only changes what the AirPods themselves report;
|
||||
// Android's system display reads from the bond database and needs a separate update.
|
||||
val bonded = try {
|
||||
bluetoothManager.bondedDevices().first().firstOrNull { it.address == address }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "bondedDevices() failed while renaming: ${e.message}" }
|
||||
null
|
||||
}
|
||||
val aliasOk = bonded?.let { bluetoothManager.setDeviceAlias(it, name) } ?: false
|
||||
if (!aliasOk) {
|
||||
log(TAG, WARN) { "System bond alias rename failed for $address — user must rename in system settings or re-pair" }
|
||||
events.emit(Event.SystemRenameUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
fun navToStemConfig() = launch {
|
||||
if (upgradeRepo.isPro()) {
|
||||
navTo(Nav.Main.StemActionConfig)
|
||||
} else {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("DeviceSettings", "VM")
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.DevicesOther
|
||||
import androidx.compose.material.icons.twotone.Settings
|
||||
import androidx.compose.material.icons.twotone.Stars
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -24,10 +27,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.DevicesOther
|
||||
import androidx.compose.material.icons.twotone.Settings
|
||||
import androidx.compose.material.icons.twotone.Stars
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
@@ -37,16 +36,17 @@ import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
|
||||
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
|
||||
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||
@@ -54,9 +54,9 @@ import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
@@ -129,6 +129,8 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
onSettings = { vm.goToSettings() },
|
||||
onUpgrade = { vm.onUpgrade() },
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,6 +142,8 @@ fun OverviewScreen(
|
||||
onSettings: () -> Unit,
|
||||
onUpgrade: () -> Unit,
|
||||
onToggleUnmatched: () -> Unit,
|
||||
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
||||
onDeviceSettings: (PodDevice) -> Unit = {},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -218,13 +222,30 @@ fun OverviewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Profiled device cards
|
||||
// 4. Profiled device cards (limited to 1 for free users)
|
||||
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||
items(
|
||||
items = state.profiledDevices,
|
||||
key = { it.identifier.hashCode() },
|
||||
items = state.visibleProfiledDevices,
|
||||
key = { it.identifier?.toString() ?: it.hashCode() },
|
||||
) { device ->
|
||||
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
showDebug = state.isDebugMode,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onDeviceSettings = { onDeviceSettings(device) },
|
||||
)
|
||||
}
|
||||
|
||||
// 4b. Upgrade card when additional devices are hidden
|
||||
if (state.hiddenProfiledDeviceCount > 0) {
|
||||
item(key = "device_limit_upgrade") {
|
||||
DeviceLimitUpgradeCard(
|
||||
hiddenCount = state.hiddenProfiledDeviceCount,
|
||||
upgradeType = state.upgradeInfo.type,
|
||||
onUpgrade = onUpgrade,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Monitoring active card
|
||||
@@ -247,9 +268,15 @@ fun OverviewScreen(
|
||||
if (state.showUnmatchedDevices) {
|
||||
items(
|
||||
items = state.unmatchedDevices,
|
||||
key = { "unmatched_${it.identifier.hashCode()}" },
|
||||
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
|
||||
) { device ->
|
||||
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
showDebug = state.isDebugMode,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onDeviceSettings = { onDeviceSettings(device) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,11 +286,25 @@ fun OverviewScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) {
|
||||
when (device) {
|
||||
is DualPodDevice -> DualPodsCard(device = device, showDebug = showDebug, now = now)
|
||||
is SinglePodDevice -> SinglePodsCard(device = device, showDebug = showDebug, now = now)
|
||||
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
|
||||
private fun PodDeviceCard(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
||||
onDeviceSettings: (() -> Unit)? = null,
|
||||
) {
|
||||
when {
|
||||
device.hasDualPods -> DualPodsCard(
|
||||
device = device, showDebug = showDebug, now = now,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onDeviceSettings = onDeviceSettings,
|
||||
)
|
||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(
|
||||
device = device, showDebug = showDebug, now = now,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onDeviceSettings = onDeviceSettings,
|
||||
)
|
||||
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,15 +316,15 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
now = Instant.now(),
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.airPodsProMixed(),
|
||||
MockPodDataProvider.airPodsMax(),
|
||||
MockPodDataProvider.unknownDevice(),
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
@@ -306,7 +347,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodDevice.Model.AIRPODS_PRO2)),
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
),
|
||||
@@ -350,7 +391,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = false,
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodDevice.Model.AIRPODS_PRO2)),
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
|
||||
showUnmatchedDevices = false,
|
||||
),
|
||||
|
||||
@@ -3,12 +3,12 @@ package eu.darken.capod.main.ui.overview
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.flow.combine
|
||||
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
@@ -17,12 +17,14 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -33,20 +35,21 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
|
||||
@HiltViewModel
|
||||
class OverviewViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val monitorControl: MonitorControl,
|
||||
private val podMonitor: PodMonitor,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val generalSettings: GeneralSettings,
|
||||
debugSettings: DebugSettings,
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val aapManager: AapConnectionManager,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
val requestPermissionEvent = SingleEventFlow<Permission>()
|
||||
@@ -71,6 +74,7 @@ class OverviewViewModel @Inject constructor(
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
MonitorMode.ALWAYS -> true
|
||||
}
|
||||
if (shouldStart) {
|
||||
@@ -92,7 +96,7 @@ class OverviewViewModel @Inject constructor(
|
||||
if (permissions.isNotEmpty()) {
|
||||
return@flatMapLatest flowOf(emptyList())
|
||||
}
|
||||
podMonitor.devices
|
||||
deviceMonitor.devices
|
||||
}
|
||||
.catch { errorEvents.emitBlocking(it) }
|
||||
.throttleLatest(1000)
|
||||
@@ -130,8 +134,11 @@ class OverviewViewModel @Inject constructor(
|
||||
val showUnmatchedDevices: Boolean,
|
||||
) {
|
||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
|
||||
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
|
||||
val profiledDevices: List<PodDevice> get() = devices.filter { it.profileId != null }
|
||||
val visibleProfiledDevices: List<PodDevice>
|
||||
get() = if (upgradeInfo.isPro) profiledDevices else profiledDevices.take(FREE_DEVICE_LIMIT)
|
||||
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
|
||||
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
|
||||
}
|
||||
|
||||
fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) {
|
||||
@@ -146,6 +153,11 @@ class OverviewViewModel @Inject constructor(
|
||||
navTo(Nav.Main.DeviceManager)
|
||||
}
|
||||
|
||||
fun goToDeviceSettings(device: PodDevice) {
|
||||
val address = device.address ?: return
|
||||
navTo(Nav.Main.DeviceSettings(address))
|
||||
}
|
||||
|
||||
fun onUpgrade() {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
}
|
||||
@@ -158,7 +170,20 @@ class OverviewViewModel @Inject constructor(
|
||||
requestPermissionEvent.tryEmit(permission)
|
||||
}
|
||||
|
||||
fun setAncMode(device: PodDevice, mode: AapSetting.AncMode.Value) {
|
||||
val address = device.address ?: return
|
||||
launch {
|
||||
try {
|
||||
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
|
||||
log(TAG) { "ANC mode set to $mode for $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to set ANC mode: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FREE_DEVICE_LIMIT = 1
|
||||
private val TAG = logTag("Overview", "VM")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Stars
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
|
||||
@Composable
|
||||
fun DeviceLimitUpgradeCard(
|
||||
hiddenCount: Int,
|
||||
upgradeType: UpgradeRepo.Type,
|
||||
onUpgrade: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.overview_more_devices_upgrade, hiddenCount, hiddenCount),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.upgrade_capod_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = onUpgrade,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Stars,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
when (upgradeType) {
|
||||
UpgradeRepo.Type.FOSS -> R.string.general_donate_action
|
||||
UpgradeRepo.Type.GPLAY -> R.string.general_upgrade_action
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceLimitUpgradeCardGplayPreview() = PreviewWrapper {
|
||||
DeviceLimitUpgradeCard(
|
||||
hiddenCount = 2,
|
||||
upgradeType = UpgradeRepo.Type.GPLAY,
|
||||
onUpgrade = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceLimitUpgradeCardFossPreview() = PreviewWrapper {
|
||||
DeviceLimitUpgradeCard(
|
||||
hiddenCount = 1,
|
||||
upgradeType = UpgradeRepo.Type.FOSS,
|
||||
onUpgrade = {},
|
||||
)
|
||||
}
|
||||
@@ -18,15 +18,15 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.GridView
|
||||
import androidx.compose.material.icons.twotone.Key
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -44,30 +45,24 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.HasPodStyle
|
||||
import eu.darken.capod.pods.core.HasStateDetection
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.toBatteryFloat
|
||||
import eu.darken.capod.pods.core.toBatteryOrNull
|
||||
import eu.darken.capod.pods.core.getSignalQuality
|
||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||
import java.time.Duration
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun DualPodsCard(
|
||||
device: DualPodDevice,
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onDeviceSettings: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -89,73 +84,79 @@ fun DualPodsCard(
|
||||
Image(
|
||||
painter = painterResource(device.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = device.meta.profile?.label ?: "?",
|
||||
text = device.label ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
SignalIndicator(
|
||||
signalQuality = device.rssiQuality,
|
||||
isLive = device.isLive,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
if (device is ApplePods && device.meta.isIRKMatch) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = if (device.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
val deviceLabel = buildString {
|
||||
append(device.getLabel(context))
|
||||
if (device is HasPodStyle && showDebug) {
|
||||
append(" (${device.podStyle.getColor(context)})")
|
||||
val podStyle = device.ble as? HasPodStyle
|
||||
if (podStyle != null && showDebug) {
|
||||
append(" (${podStyle.podStyle.getColor(context)})")
|
||||
}
|
||||
if (device is DualApplePods && showDebug) {
|
||||
append(" [${device.primaryPod.name}]")
|
||||
val dualApple = device.ble as? DualApplePods
|
||||
if (dualApple != null && showDebug) {
|
||||
append(" [${dualApple.primaryPod.name}]")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = deviceLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = deviceLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
DeviceConnectionBadge(
|
||||
state = device.connectionState,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SignalBadge(signalText = device.getSignalQuality(context))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Timestamps
|
||||
Text(
|
||||
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
|
||||
Text(
|
||||
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (device.address != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Circular battery gauges side by side
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 1.dp,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -166,31 +167,31 @@ fun DualPodsCard(
|
||||
) {
|
||||
PodGauge(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(),
|
||||
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
|
||||
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
|
||||
showEarDetection = device is HasEarDetectionDual,
|
||||
isMicrophone = (device as? HasDualMicrophone)?.isLeftPodMicrophone ?: false,
|
||||
showMicrophone = device is HasDualMicrophone,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
isCharging = device.isLeftPodCharging ?: false,
|
||||
isInEar = device.isLeftInEar ?: false,
|
||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
PodGauge(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRightPodPercent.toBatteryFloat(),
|
||||
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
|
||||
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
|
||||
showEarDetection = device is HasEarDetectionDual,
|
||||
isMicrophone = (device as? HasDualMicrophone)?.isRightPodMicrophone ?: false,
|
||||
showMicrophone = device is HasDualMicrophone,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
isCharging = device.isRightPodCharging ?: false,
|
||||
isInEar = device.isRightInEar ?: false,
|
||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
// Case row
|
||||
if (device is HasCase) {
|
||||
if (device.hasCase) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
)
|
||||
|
||||
@@ -199,13 +200,37 @@ fun DualPodsCard(
|
||||
}
|
||||
}
|
||||
|
||||
// Connection state
|
||||
if (device is HasStateDetection) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// Cached battery indicator
|
||||
if (device.isBatteryCached && !device.isAapReady) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = device.state.getLabel(context),
|
||||
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
|
||||
// ANC mode selector
|
||||
val ancMode = device.ancMode
|
||||
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val cycleMask = (device.listeningModeCycle?.modeMask ?: 0x0E)
|
||||
val cycleBits = mapOf(
|
||||
AapSetting.AncMode.Value.OFF to 0x01,
|
||||
AapSetting.AncMode.Value.ON to 0x02,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
|
||||
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
|
||||
)
|
||||
val visibleModes = ancMode.supported.filter { mode ->
|
||||
val bit = cycleBits[mode] ?: return@filter true
|
||||
(cycleMask and bit) != 0
|
||||
}
|
||||
AncModeSelector(
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = visibleModes,
|
||||
onModeSelected = { onAncModeChange?.invoke(it) },
|
||||
pendingMode = device.pendingAncMode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -250,12 +275,12 @@ private fun PodGauge(
|
||||
// Ring with icon inside
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(80.dp),
|
||||
modifier = Modifier.size(68.dp),
|
||||
) {
|
||||
// Track ring
|
||||
CircularProgressIndicator(
|
||||
progress = { 1f },
|
||||
modifier = Modifier.size(80.dp),
|
||||
modifier = Modifier.size(68.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeWidth = 6.dp,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -266,7 +291,7 @@ private fun PodGauge(
|
||||
if (clamped >= 0f) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(80.dp),
|
||||
modifier = Modifier.size(68.dp),
|
||||
color = ringColor,
|
||||
strokeWidth = 6.dp,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -314,7 +339,7 @@ private fun PodGauge(
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CaseRow(
|
||||
device: HasCase,
|
||||
device: PodDevice,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -331,13 +356,13 @@ private fun CaseRow(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryCasePercent),
|
||||
text = formatBatteryPercent(context, device.batteryCase),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCasePercent.toBatteryFloat(),
|
||||
percent = device.batteryCase.toBatteryFloat(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp),
|
||||
@@ -349,25 +374,23 @@ private fun CaseRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (device.isCaseCharging) {
|
||||
if (device.isCaseCharging == true) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
}
|
||||
|
||||
if (device is DualApplePods) {
|
||||
val lidState = device.caseLidState
|
||||
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.GridView,
|
||||
label = when (lidState) {
|
||||
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
|
||||
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
|
||||
else -> ""
|
||||
},
|
||||
)
|
||||
}
|
||||
val lidState = device.caseLidState
|
||||
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.GridView,
|
||||
label = when (lidState) {
|
||||
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
|
||||
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
|
||||
else -> ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,42 +398,31 @@ private fun CaseRow(
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardFullChargePreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProFullCharge(), showDebug = false, now = Instant.now())
|
||||
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||
showDebug = false,
|
||||
now = Instant.now(),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardMixedBatteryPreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProMixed(), showDebug = false, now = Instant.now())
|
||||
private fun DualPodsCardMinimalPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodMonitored(),
|
||||
showDebug = false,
|
||||
now = Instant.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardLowBatteryPreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProLowBattery(), showDebug = false, now = Instant.now())
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardInCasePreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProInCase(), showDebug = false, now = Instant.now())
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardDebugPreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsProMixed(), showDebug = true, now = Instant.now())
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardNoCasePreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.powerBeatsPro(), showDebug = false, now = Instant.now())
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardMicrophonePreview() = PreviewWrapper {
|
||||
DualPodsCard(device = MockPodDataProvider.airPodsGen1Wearing(), showDebug = false, now = Instant.now())
|
||||
private fun DualPodsCardCachedPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodCachedOnly(),
|
||||
showDebug = false,
|
||||
now = Instant.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -20,21 +22,42 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.Bluetooth
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.KeyboardVoice
|
||||
import androidx.compose.material.icons.twotone.LinkOff
|
||||
import androidx.compose.material.icons.twotone.Lock
|
||||
import androidx.compose.material.icons.twotone.SettingsInputAntenna
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.monitor.core.BleKeyState
|
||||
import eu.darken.capod.monitor.core.ConnectionState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
private val CapsuleShape = RoundedCornerShape(6.dp)
|
||||
|
||||
@@ -144,11 +167,89 @@ fun StatusChipRow(
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal enum class SignalLevel { DISCONNECTED, BARS_0, BARS_1, BARS_2, BARS_3, BARS_4 }
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun signalLevelOf(signalQuality: Float, isLive: Boolean): SignalLevel {
|
||||
if (!isLive) return SignalLevel.DISCONNECTED
|
||||
if (!signalQuality.isFinite()) return SignalLevel.BARS_0
|
||||
val q = signalQuality.coerceIn(0f, 1f)
|
||||
return when {
|
||||
q >= 0.80f -> SignalLevel.BARS_4
|
||||
q >= 0.60f -> SignalLevel.BARS_3
|
||||
q >= 0.40f -> SignalLevel.BARS_2
|
||||
q >= 0.20f -> SignalLevel.BARS_1
|
||||
else -> SignalLevel.BARS_0
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SignalBadge(
|
||||
signalText: String,
|
||||
fun SignalIndicator(
|
||||
signalQuality: Float,
|
||||
isLive: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val level = signalLevelOf(signalQuality, isLive)
|
||||
val description = when (level) {
|
||||
SignalLevel.DISCONNECTED -> stringResource(R.string.signal_indicator_disconnected_cd)
|
||||
SignalLevel.BARS_0 -> stringResource(R.string.signal_indicator_bars_cd, 0)
|
||||
SignalLevel.BARS_1 -> stringResource(R.string.signal_indicator_bars_cd, 1)
|
||||
SignalLevel.BARS_2 -> stringResource(R.string.signal_indicator_bars_cd, 2)
|
||||
SignalLevel.BARS_3 -> stringResource(R.string.signal_indicator_bars_cd, 3)
|
||||
SignalLevel.BARS_4 -> stringResource(R.string.signal_indicator_bars_cd, 4)
|
||||
}
|
||||
val tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val rootModifier = modifier
|
||||
.size(12.dp)
|
||||
.semantics(mergeDescendants = true) { contentDescription = description }
|
||||
|
||||
if (level == SignalLevel.DISCONNECTED) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.LinkOff,
|
||||
contentDescription = null,
|
||||
modifier = rootModifier,
|
||||
tint = tint,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val activeBars = when (level) {
|
||||
SignalLevel.BARS_0 -> 0
|
||||
SignalLevel.BARS_1 -> 1
|
||||
SignalLevel.BARS_2 -> 2
|
||||
SignalLevel.BARS_3 -> 3
|
||||
SignalLevel.BARS_4 -> 4
|
||||
SignalLevel.DISCONNECTED -> 0 // unreachable; handled above
|
||||
}
|
||||
val inactiveColor = tint.copy(alpha = 0.3f)
|
||||
Canvas(modifier = rootModifier) {
|
||||
val barCount = 4
|
||||
val gapWidth = size.width * 0.12f
|
||||
val totalGap = gapWidth * (barCount - 1)
|
||||
val barWidth = (size.width - totalGap) / barCount
|
||||
val corner = CornerRadius(barWidth * 0.4f, barWidth * 0.4f)
|
||||
for (i in 0 until barCount) {
|
||||
val heightFraction = (i + 1).toFloat() / barCount
|
||||
val barHeight = size.height * heightFraction
|
||||
val x = i * (barWidth + gapWidth)
|
||||
val y = size.height - barHeight
|
||||
drawRoundRect(
|
||||
color = if (i < activeBars) tint else inactiveColor,
|
||||
topLeft = Offset(x, y),
|
||||
size = Size(barWidth, barHeight),
|
||||
cornerRadius = corner,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeviceConnectionBadge(
|
||||
state: ConnectionState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!state.hasBleData && state.bleKeyState == BleKeyState.NONE && !state.isAapConnected) return
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -157,19 +258,40 @@ fun SignalBadge(
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.SettingsInputAntenna,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(3.dp))
|
||||
Text(
|
||||
text = signalText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (state.hasBleData) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.SettingsInputAntenna,
|
||||
contentDescription = stringResource(R.string.signal_badge_ble_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.bleKeyState != BleKeyState.NONE) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Key,
|
||||
contentDescription = stringResource(R.string.signal_badge_key_irk_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Lock,
|
||||
contentDescription = stringResource(R.string.signal_badge_key_encrypted_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.isAapConnected) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Bluetooth,
|
||||
contentDescription = stringResource(R.string.signal_badge_aap_cd),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,3 +315,149 @@ fun DebugSection(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AncModeSelector(
|
||||
currentMode: AapSetting.AncMode.Value,
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
|
||||
pendingMode: AapSetting.AncMode.Value? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val displayMode = pendingMode ?: currentMode
|
||||
SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) {
|
||||
supportedModes.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = mode == displayMode,
|
||||
onClick = { onModeSelected(mode) },
|
||||
enabled = enabled,
|
||||
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
|
||||
colors = if (pendingMode != null && mode == displayMode) {
|
||||
SegmentedButtonDefaults.colors(
|
||||
activeContainerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
activeContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else {
|
||||
SegmentedButtonDefaults.colors()
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = when (mode) {
|
||||
AapSetting.AncMode.Value.OFF -> stringResource(R.string.anc_mode_off)
|
||||
AapSetting.AncMode.Value.ON -> stringResource(R.string.anc_mode_on)
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.anc_mode_transparency)
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.anc_mode_adaptive)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConversationAwarenessToggle(
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.conversation_awareness_label),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = true,
|
||||
bleKeyState = BleKeyState.IRK_AND_ENCRYPTED,
|
||||
isAapConnected = true,
|
||||
rssiQuality = 1f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeBleOnlyPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = true,
|
||||
bleKeyState = BleKeyState.NONE,
|
||||
isAapConnected = false,
|
||||
rssiQuality = 0.7f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeBleIrkPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = true,
|
||||
bleKeyState = BleKeyState.IRK_ONLY,
|
||||
isAapConnected = false,
|
||||
rssiQuality = 0.5f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeAapOnlyPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = false,
|
||||
bleKeyState = BleKeyState.NONE,
|
||||
isAapConnected = true,
|
||||
rssiQuality = 0f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceConnectionBadgeEmptyPreview() = PreviewWrapper {
|
||||
DeviceConnectionBadge(
|
||||
state = ConnectionState(
|
||||
hasBleData = false,
|
||||
bleKeyState = BleKeyState.NONE,
|
||||
isAapConnected = false,
|
||||
rssiQuality = 0f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SignalIndicatorPreviewsGroup() = PreviewWrapper {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SignalIndicator(signalQuality = 0.10f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.35f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.55f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.75f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.95f, isLive = true)
|
||||
SignalIndicator(signalQuality = Float.NaN, isLive = true)
|
||||
SignalIndicator(signalQuality = -0.3f, isLive = true)
|
||||
SignalIndicator(signalQuality = 0.85f, isLive = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.darken.capod.main.ui.stemactions
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class StemActionConfigNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Main.StemActionConfig> {
|
||||
StemActionConfigScreenHost()
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class Mod {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bind(entry: StemActionConfigNavigation): NavigationEntry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package eu.darken.capod.main.ui.stemactions
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.twotone.RestartAlt
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.settings.SettingsCategoryHeader
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
|
||||
@Composable
|
||||
fun StemActionConfigScreenHost(
|
||||
vm: StemActionConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val currentState = state ?: return
|
||||
|
||||
StemActionConfigScreen(
|
||||
state = currentState,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onReset = { vm.resetAll() },
|
||||
onLeftSingle = { vm.setLeftSingle(it) },
|
||||
onLeftDouble = { vm.setLeftDouble(it) },
|
||||
onLeftTriple = { vm.setLeftTriple(it) },
|
||||
onLeftLong = { vm.setLeftLong(it) },
|
||||
onRightSingle = { vm.setRightSingle(it) },
|
||||
onRightDouble = { vm.setRightDouble(it) },
|
||||
onRightTriple = { vm.setRightTriple(it) },
|
||||
onRightLong = { vm.setRightLong(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StemActionConfigScreen(
|
||||
state: StemActionConfigViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onReset: () -> Unit = {},
|
||||
onLeftSingle: (StemAction) -> Unit = {},
|
||||
onLeftDouble: (StemAction) -> Unit = {},
|
||||
onLeftTriple: (StemAction) -> Unit = {},
|
||||
onLeftLong: (StemAction) -> Unit = {},
|
||||
onRightSingle: (StemAction) -> Unit = {},
|
||||
onRightDouble: (StemAction) -> Unit = {},
|
||||
onRightTriple: (StemAction) -> Unit = {},
|
||||
onRightLong: (StemAction) -> Unit = {},
|
||||
) {
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onReset()
|
||||
showResetDialog = false
|
||||
}) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showResetDialog = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
text = { Text(stringResource(R.string.stem_actions_reset_confirm_message)) },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.stem_actions_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateUp) {
|
||||
Icon(Icons.AutoMirrored.TwoTone.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showResetDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.RestartAlt,
|
||||
contentDescription = stringResource(R.string.stem_actions_reset_label),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(modifier = Modifier.padding(paddingValues)) {
|
||||
item("description") {
|
||||
Text(
|
||||
text = stringResource(R.string.stem_actions_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Press type rows with left/right dropdowns
|
||||
item("single_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_single_press))
|
||||
}
|
||||
item("single_row") {
|
||||
StemActionRow(
|
||||
leftAction = state.leftSingle,
|
||||
rightAction = state.rightSingle,
|
||||
onLeftChange = onLeftSingle,
|
||||
onRightChange = onRightSingle,
|
||||
)
|
||||
}
|
||||
|
||||
item("double_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_double_press))
|
||||
}
|
||||
item("double_row") {
|
||||
StemActionRow(
|
||||
leftAction = state.leftDouble,
|
||||
rightAction = state.rightDouble,
|
||||
onLeftChange = onLeftDouble,
|
||||
onRightChange = onRightDouble,
|
||||
)
|
||||
}
|
||||
|
||||
item("triple_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_triple_press))
|
||||
}
|
||||
item("triple_row") {
|
||||
StemActionRow(
|
||||
leftAction = state.leftTriple,
|
||||
rightAction = state.rightTriple,
|
||||
onLeftChange = onLeftTriple,
|
||||
onRightChange = onRightTriple,
|
||||
)
|
||||
}
|
||||
|
||||
item("long_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_long_press))
|
||||
}
|
||||
item("long_row") {
|
||||
StemActionRow(
|
||||
leftAction = state.leftLong,
|
||||
rightAction = state.rightLong,
|
||||
onLeftChange = onLeftLong,
|
||||
onRightChange = onRightLong,
|
||||
)
|
||||
}
|
||||
|
||||
item("bottom_spacer") {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StemActionRow(
|
||||
leftAction: StemAction,
|
||||
rightAction: StemAction,
|
||||
onLeftChange: (StemAction) -> Unit,
|
||||
onRightChange: (StemAction) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.stem_actions_left),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
StemActionDropdown(
|
||||
selected = leftAction,
|
||||
onSelected = onLeftChange,
|
||||
otherSideAction = rightAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.stem_actions_right),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
StemActionDropdown(
|
||||
selected = rightAction,
|
||||
onSelected = onRightChange,
|
||||
otherSideAction = leftAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StemActionDropdown(
|
||||
selected: StemAction,
|
||||
onSelected: (StemAction) -> Unit,
|
||||
otherSideAction: StemAction,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val options = if (otherSideAction == StemAction.NONE) {
|
||||
StemAction.entries.filter { it != StemAction.NO_ACTION }
|
||||
} else {
|
||||
StemAction.entries.toList()
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = modifier,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selected.label(),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
for (action in options) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(action.label()) },
|
||||
onClick = {
|
||||
onSelected(action)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StemAction.label(): String = when (this) {
|
||||
StemAction.NONE -> stringResource(R.string.stem_action_none)
|
||||
StemAction.NO_ACTION -> stringResource(R.string.stem_action_no_action)
|
||||
StemAction.PLAY_PAUSE -> stringResource(R.string.stem_action_play_pause)
|
||||
StemAction.NEXT_TRACK -> stringResource(R.string.stem_action_next_track)
|
||||
StemAction.PREVIOUS_TRACK -> stringResource(R.string.stem_action_previous_track)
|
||||
StemAction.VOLUME_UP -> stringResource(R.string.stem_action_volume_up)
|
||||
StemAction.VOLUME_DOWN -> stringResource(R.string.stem_action_volume_down)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package eu.darken.capod.main.ui.stemactions
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionSettings
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class StemActionConfigViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val stemActionSettings: StemActionSettings,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
val state = combine(
|
||||
stemActionSettings.leftSingle.flow,
|
||||
stemActionSettings.leftDouble.flow,
|
||||
stemActionSettings.leftTriple.flow,
|
||||
stemActionSettings.leftLong.flow,
|
||||
stemActionSettings.rightSingle.flow,
|
||||
stemActionSettings.rightDouble.flow,
|
||||
stemActionSettings.rightTriple.flow,
|
||||
stemActionSettings.rightLong.flow,
|
||||
) { values ->
|
||||
State(
|
||||
leftSingle = values[0],
|
||||
leftDouble = values[1],
|
||||
leftTriple = values[2],
|
||||
leftLong = values[3],
|
||||
rightSingle = values[4],
|
||||
rightDouble = values[5],
|
||||
rightTriple = values[6],
|
||||
rightLong = values[7],
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
data class State(
|
||||
val leftSingle: StemAction = StemAction.NONE,
|
||||
val leftDouble: StemAction = StemAction.NONE,
|
||||
val leftTriple: StemAction = StemAction.NONE,
|
||||
val leftLong: StemAction = StemAction.NONE,
|
||||
val rightSingle: StemAction = StemAction.NONE,
|
||||
val rightDouble: StemAction = StemAction.NONE,
|
||||
val rightTriple: StemAction = StemAction.NONE,
|
||||
val rightLong: StemAction = StemAction.NONE,
|
||||
)
|
||||
|
||||
fun setLeftSingle(action: StemAction) = launch {
|
||||
stemActionSettings.leftSingle.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.rightSingle)
|
||||
}
|
||||
|
||||
fun setLeftDouble(action: StemAction) = launch {
|
||||
stemActionSettings.leftDouble.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.rightDouble)
|
||||
}
|
||||
|
||||
fun setLeftTriple(action: StemAction) = launch {
|
||||
stemActionSettings.leftTriple.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.rightTriple)
|
||||
}
|
||||
|
||||
fun setLeftLong(action: StemAction) = launch {
|
||||
stemActionSettings.leftLong.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.rightLong)
|
||||
}
|
||||
|
||||
fun setRightSingle(action: StemAction) = launch {
|
||||
stemActionSettings.rightSingle.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.leftSingle)
|
||||
}
|
||||
|
||||
fun setRightDouble(action: StemAction) = launch {
|
||||
stemActionSettings.rightDouble.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.leftDouble)
|
||||
}
|
||||
|
||||
fun setRightTriple(action: StemAction) = launch {
|
||||
stemActionSettings.rightTriple.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.leftTriple)
|
||||
}
|
||||
|
||||
fun setRightLong(action: StemAction) = launch {
|
||||
stemActionSettings.rightLong.update { action }
|
||||
applyCrossSideEffect(action, stemActionSettings.leftLong)
|
||||
}
|
||||
|
||||
private suspend fun applyCrossSideEffect(
|
||||
selected: StemAction,
|
||||
otherSide: eu.darken.capod.common.datastore.DataStoreValue<StemAction>,
|
||||
) {
|
||||
when (selected) {
|
||||
StemAction.NONE -> otherSide.update { StemAction.NONE }
|
||||
StemAction.NO_ACTION -> {} // No side-effect
|
||||
else -> {
|
||||
if (otherSide.flow.first() == StemAction.NONE) {
|
||||
otherSide.update { StemAction.NO_ACTION }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAll() = launch { stemActionSettings.resetAll() }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.glance.GlanceId
|
||||
@@ -10,7 +11,6 @@ import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.annotation.Keep
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -22,8 +22,8 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
|
||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
@@ -34,7 +34,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface WidgetEntryPoint {
|
||||
fun podMonitor(): PodMonitor
|
||||
fun deviceMonitor(): DeviceMonitor
|
||||
fun upgradeRepo(): UpgradeRepo
|
||||
fun widgetSettings(): WidgetSettings
|
||||
fun deviceProfilesRepo(): DeviceProfilesRepo
|
||||
@@ -53,7 +53,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||
initialIsPro = ep.upgradeRepo().isPro()
|
||||
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
cachedDevice = initialProfileId?.let { ep.podMonitor().getDeviceForProfile(it) }
|
||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||
provideContent {
|
||||
@@ -73,7 +73,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
provideContent {
|
||||
// Composable reads — must be outside try-catch
|
||||
val devices by ep.podMonitor().devices.collectAsState(initial = emptyList())
|
||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||
val widthDp = LocalSize.current.width
|
||||
@@ -86,8 +86,8 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||
|
||||
val liveDevice = devices.firstOrNull { it.meta.profile?.id == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.meta.profile?.id == profileId }
|
||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
||||
|
||||
val profileLabel = profileId?.let { pid ->
|
||||
profiles.firstOrNull { it.id == pid }?.label
|
||||
@@ -139,7 +139,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
var n = 2
|
||||
while (70 * n - 30 < size) {
|
||||
++n
|
||||
}
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.pods.core.toBatteryOrNull
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.pods.core.toBatteryOrNull
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -24,10 +24,10 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
@@ -77,7 +77,7 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -413,7 +413,7 @@ private fun ProfileSelectionItem(
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
val modelText = when (profile.model) {
|
||||
PodDevice.Model.UNKNOWN -> stringResource(R.string.pods_unknown_label)
|
||||
PodModel.UNKNOWN -> stringResource(R.string.pods_unknown_label)
|
||||
else -> profile.model.label
|
||||
}
|
||||
Text(
|
||||
@@ -456,7 +456,9 @@ private fun WidgetConfigPreview(
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasTransparency) {
|
||||
@@ -691,8 +693,8 @@ private fun HexColorInput(
|
||||
@Composable
|
||||
private fun WidgetConfigurationScreenPreview() = PreviewWrapper {
|
||||
val profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||
)
|
||||
WidgetConfigurationScreen(
|
||||
state = WidgetConfigurationViewModel.State(
|
||||
@@ -720,7 +722,7 @@ private fun WidgetConfigurationScreenPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun WidgetConfigurationScreenCustomPreview() = PreviewWrapper {
|
||||
val profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
)
|
||||
WidgetConfigurationScreen(
|
||||
state = WidgetConfigurationViewModel.State(
|
||||
@@ -753,7 +755,7 @@ private fun WidgetConfigurationScreenCustomPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun WidgetConfigurationScreenNonProPreview() = PreviewWrapper {
|
||||
val profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
)
|
||||
WidgetConfigurationScreen(
|
||||
state = WidgetConfigurationViewModel.State(
|
||||
|
||||
@@ -4,15 +4,10 @@ import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.toBatteryFloat
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||
|
||||
object WidgetRenderStateMapper {
|
||||
|
||||
@@ -39,7 +34,7 @@ object WidgetRenderStateMapper {
|
||||
secondaryText = context.getString(R.string.upgrade_capod_description),
|
||||
)
|
||||
|
||||
device is DualPodDevice -> WidgetRenderState.DualPod(
|
||||
device != null && device.hasDualPods -> WidgetRenderState.DualPod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
@@ -47,32 +42,32 @@ object WidgetRenderStateMapper {
|
||||
isWide = isWide,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
leftIcon = device.leftPodIcon,
|
||||
leftPercent = device.batteryLeftPodPercent.toBatteryFloat(),
|
||||
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
|
||||
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
|
||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
||||
leftCharging = device.isLeftPodCharging == true,
|
||||
leftInEar = device.isLeftInEar == true,
|
||||
rightIcon = device.rightPodIcon,
|
||||
rightPercent = device.batteryRightPodPercent.toBatteryFloat(),
|
||||
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
|
||||
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
|
||||
caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case,
|
||||
casePercent = (device as? HasCase)?.batteryCasePercent.toBatteryFloat(),
|
||||
caseCharging = device is HasCase && device.isCaseCharging,
|
||||
rightPercent = device.batteryRight.toBatteryFloat(),
|
||||
rightCharging = device.isRightPodCharging == true,
|
||||
rightInEar = device.isRightInEar == true,
|
||||
caseIcon = device.caseIcon,
|
||||
casePercent = device.batteryCase.toBatteryFloat(),
|
||||
caseCharging = device.isCaseCharging == true,
|
||||
)
|
||||
|
||||
device is SinglePodDevice -> WidgetRenderState.SinglePod(
|
||||
device != null && device.model != PodModel.UNKNOWN -> WidgetRenderState.SinglePod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
headsetIcon = device.iconRes,
|
||||
percent = device.batteryHeadsetPercent.toBatteryFloat(),
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
|
||||
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
|
||||
worn = device is HasEarDetection && device.isBeingWorn,
|
||||
percent = device.batteryHeadset.toBatteryFloat(),
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||
charging = device.isHeadsetBeingCharged == true,
|
||||
worn = device.isBeingWorn == true,
|
||||
)
|
||||
|
||||
device is PodDevice -> WidgetRenderState.Message(
|
||||
device != null -> WidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.monitor.core.aap.AapLifecycleManager
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.cache.DeviceStateCache
|
||||
import eu.darken.capod.monitor.core.cache.toCachedState
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Single merge point: combines BLE scan data ([eu.darken.capod.monitor.core.ble.BlePodMonitor]) with AAP connection data
|
||||
* ([AapConnectionManager]) and cached device state ([eu.darken.capod.monitor.core.cache.DeviceStateCache]) into unified [PodDevice] objects.
|
||||
*
|
||||
* Includes cached-only devices for profiles that have cached state but no live BLE data.
|
||||
* Persists live device state to [eu.darken.capod.monitor.core.cache.DeviceStateCache] as a side effect.
|
||||
* ViewModels should observe [devices] instead of accessing BlePodMonitor directly.
|
||||
*/
|
||||
@Singleton
|
||||
class DeviceMonitor @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val blePodMonitor: BlePodMonitor,
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val deviceStateCache: DeviceStateCache,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val aapLifecycleManager: AapLifecycleManager,
|
||||
) {
|
||||
init {
|
||||
aapLifecycleManager.start()
|
||||
}
|
||||
|
||||
val devices: Flow<List<PodDevice>> = combine(
|
||||
blePodMonitor.devices,
|
||||
aapManager.allStates,
|
||||
deviceStateCache.cachedStates,
|
||||
profilesRepo.profiles,
|
||||
) { pods, aapStates, cachedStates, profiles ->
|
||||
// Live devices — BLE + AAP + cached fallback for missing fields
|
||||
val liveDevices = pods.map { pod ->
|
||||
val profile = pod.meta.profile
|
||||
PodDevice(
|
||||
profileId = profile?.id,
|
||||
label = profile?.label,
|
||||
ble = pod,
|
||||
aap = aapStates.forProfile(profile),
|
||||
cached = profile?.id?.let { cachedStates[it] },
|
||||
profileAddress = profile?.address,
|
||||
profileModel = profile?.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
)
|
||||
}
|
||||
|
||||
// Non-live devices — profiles with no live BLE detection. Synthesize from cache
|
||||
// and/or AAP, whichever is available:
|
||||
// - cache only → "cached-only" case (PR #484 fixed AAP attach here)
|
||||
// - AAP only → cold-start case: AAP connected but no battery message
|
||||
// received yet, so no cache write has happened
|
||||
// - cache + AAP → mid-session BLE staleness with AAP still alive
|
||||
val liveProfileIds = liveDevices.mapNotNull { it.profileId }.toSet()
|
||||
val nonLiveDevices = profiles
|
||||
.filter { it.id !in liveProfileIds }
|
||||
.mapNotNull { profile ->
|
||||
val cached = cachedStates[profile.id]
|
||||
val aap = aapStates.forProfile(profile)
|
||||
if (cached == null && aap == null) return@mapNotNull null
|
||||
PodDevice(
|
||||
profileId = profile.id,
|
||||
label = profile.label,
|
||||
ble = null,
|
||||
aap = aap,
|
||||
cached = cached,
|
||||
profileAddress = profile.address,
|
||||
profileModel = profile.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
)
|
||||
}
|
||||
|
||||
// De-dupe: if a synthesized non-live device with active AAP shares its model with
|
||||
// an anonymous (profileId == null) BLE pod, the anonymous pod is most likely the
|
||||
// same physical AirPods — the IRK match failed (e.g. wrong identity key, or keys
|
||||
// haven't propagated yet). Hide the anonymous BLE pod to avoid two cards for the
|
||||
// same device. Best-effort: model match only — BLE addresses are RPAs and can't
|
||||
// be matched directly.
|
||||
val nonLiveAapModels = nonLiveDevices
|
||||
.filter { it.aap != null }
|
||||
.map { it.model }
|
||||
.filter { it != PodModel.UNKNOWN }
|
||||
.toSet()
|
||||
val dedupedLiveDevices = if (nonLiveAapModels.isEmpty()) {
|
||||
liveDevices
|
||||
} else {
|
||||
val seenAnonymousModels = mutableSetOf<PodModel>()
|
||||
liveDevices.filter { device ->
|
||||
val isAnonymous = device.profileId == null
|
||||
val matchesNonLive = device.model in nonLiveAapModels
|
||||
// Hide at most one anonymous BLE pod per non-live AAP model so a second
|
||||
// physical device of the same model isn't accidentally hidden.
|
||||
!(isAnonymous && matchesNonLive && seenAnonymousModels.add(device.model))
|
||||
}
|
||||
}
|
||||
|
||||
dedupedLiveDevices + nonLiveDevices
|
||||
}
|
||||
.onEach { devices -> persistLiveDevices(devices) }
|
||||
.replayingShare(appScope)
|
||||
|
||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||
for (device in devices) {
|
||||
val profileId = device.profileId ?: continue
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.toCachedState(existing) ?: continue
|
||||
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
|
||||
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
|
||||
|
||||
val liveDevice = devices.firstOrNull()?.firstOrNull { it.profileId == profileId }
|
||||
if (liveDevice != null) {
|
||||
log(TAG) { "Found live device for profile $profileId" }
|
||||
return liveDevice
|
||||
}
|
||||
|
||||
val profile = profilesRepo.profiles.firstOrNull()?.firstOrNull { it.id == profileId }
|
||||
val cached = deviceStateCache.load(profileId)
|
||||
val aap = profile?.let { aapManager.allStates.value.forProfile(it) }
|
||||
|
||||
if (cached == null && aap == null) {
|
||||
log(TAG) { "No device found for profile $profileId" }
|
||||
return null
|
||||
}
|
||||
|
||||
log(TAG) { "Found fallback device for profile $profileId (cached=${cached != null}, aap=${aap != null})" }
|
||||
return PodDevice(
|
||||
profileId = profileId,
|
||||
label = profile?.label,
|
||||
ble = null,
|
||||
aap = aap,
|
||||
cached = cached,
|
||||
profileAddress = profile?.address,
|
||||
profileModel = profile?.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for "find the AAP state that belongs to this profile".
|
||||
* AAP is keyed by the bonded BR/EDR address, which lives on [DeviceProfile.address].
|
||||
* Used by both the live and cached-only branches of [devices] so they cannot drift apart.
|
||||
*/
|
||||
private fun Map<BluetoothAddress, AapPodState>.forProfile(profile: DeviceProfile?): AapPodState? =
|
||||
profile?.address?.let { this[it] }
|
||||
|
||||
/**
|
||||
* Derives the stable badge key state from the profile's stored IRK/ENC. Used so the badge
|
||||
* icons don't evaporate every time the BLE scanner misses a scan batch.
|
||||
*/
|
||||
private fun DeviceProfile?.toBleKeyState(): BleKeyState {
|
||||
val apple = this as? AppleDeviceProfile ?: return BleKeyState.NONE
|
||||
if (apple.identityKey == null) return BleKeyState.NONE
|
||||
return if (apple.encryptionKey != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("DeviceMonitor")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import android.icu.text.RelativeDateTimeFormatter
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
fun DeviceMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
|
||||
.map { devices -> devices.filter { it.profileId != null } }
|
||||
|
||||
fun DeviceMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
|
||||
|
||||
fun PodDevice.lastSeenFormatted(now: Instant): String {
|
||||
val lastAt = seenLastAt ?: return ""
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(lastAt, now)
|
||||
return if (duration > Duration.ofMinutes(1)) {
|
||||
formatter.format(
|
||||
duration.toMinutes().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
||||
)
|
||||
} else {
|
||||
formatter.format(
|
||||
duration.seconds.toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.SECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun PodDevice.firstSeenFormatted(now: Instant): String {
|
||||
val firstAt = seenFirstAt ?: return ""
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(firstAt, now)
|
||||
return formatter.format(
|
||||
duration.toMinutes().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
||||
)
|
||||
}
|
||||
|
||||
fun PodDevice.cachedBatteryFormatted(now: Instant): String {
|
||||
val cachedAt = cachedBatteryAt ?: return ""
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(cachedAt, now)
|
||||
return when {
|
||||
duration > Duration.ofHours(1) -> formatter.format(
|
||||
duration.toHours().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.HOURS
|
||||
)
|
||||
duration > Duration.ofMinutes(1) -> formatter.format(
|
||||
duration.toMinutes().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
||||
)
|
||||
else -> formatter.format(
|
||||
duration.seconds.toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.SECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Unified device facade combining BLE scan data and AAP connection data.
|
||||
* All properties are dynamically resolved from the best available source.
|
||||
* Consumers don't need to know whether data came from BLE or AAP.
|
||||
*/
|
||||
@Stable
|
||||
data class PodDevice(
|
||||
val profileId: String?,
|
||||
val label: String? = null,
|
||||
internal val ble: BlePodSnapshot?,
|
||||
internal val aap: AapPodState?,
|
||||
internal val cached: CachedDeviceState? = null,
|
||||
/** Bonded BR/EDR address from the profile — current source of truth, preferred over cache snapshot. */
|
||||
internal val profileAddress: BluetoothAddress? = null,
|
||||
/** Pod model from the profile — current source of truth, preferred over cache snapshot. */
|
||||
internal val profileModel: PodModel? = null,
|
||||
/**
|
||||
* Key state derived from the profile's stored IRK/ENC. Used as the source of truth for the
|
||||
* badge icons when the device is live — per-scan IRK matching would otherwise evaporate
|
||||
* every time the BLE scanner misses the next advertisement batch.
|
||||
*/
|
||||
internal val profileKeyState: BleKeyState = BleKeyState.NONE,
|
||||
) {
|
||||
val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN
|
||||
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
|
||||
val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: profileAddress ?: cached?.address
|
||||
/** BLE scan address (RPA, rotates). */
|
||||
val bleAddress: BluetoothAddress? get() = ble?.address
|
||||
val identifier: BlePodSnapshot.Id? get() = ble?.identifier
|
||||
|
||||
/** True when at least one live data source (BLE or AAP) is present. */
|
||||
val isLive: Boolean get() = ble != null || aap != null
|
||||
|
||||
// Capabilities from Model.Features
|
||||
val hasCase: Boolean get() = model.features.hasCase
|
||||
val hasDualPods: Boolean get() = model.features.hasDualPods
|
||||
val hasEarDetection: Boolean get() = model.features.hasEarDetection
|
||||
val hasAncControl: Boolean get() = model.features.hasAncControl
|
||||
val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone
|
||||
|
||||
// Signal / timing
|
||||
val seenLastAt: Instant?
|
||||
get() = listOfNotNull(ble?.seenLastAt, aap?.lastMessageAt, cached?.lastSeenAt).maxOrNull()
|
||||
val seenFirstAt: Instant? get() = ble?.seenFirstAt
|
||||
val signalQuality: Float
|
||||
get() {
|
||||
val bleQuality = ble?.signalQuality ?: 0f
|
||||
return (bleQuality + computeAapBoost(Instant.now())).coerceAtMost(1f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality value for the signal bar display. Prefers live BLE RSSI; when BLE is absent
|
||||
* (common for a device that's actively connected to us via classic BT — we stop receiving
|
||||
* its BLE advertisements), falls back to a full-bars constant as long as AAP is READY.
|
||||
*
|
||||
* Note: we don't try to graduate this off AAP message age. The AirPods only push messages
|
||||
* on state changes (battery %, ear detection, setting toggles, user taps). A user quietly
|
||||
* listening to music can go minutes without any AAP traffic, which is still a perfectly
|
||||
* healthy connection. A quiet channel is not a weak channel.
|
||||
*/
|
||||
val rssiQuality: Float
|
||||
get() {
|
||||
ble?.rssiQuality?.let { return it }
|
||||
val state = aap ?: return 0f
|
||||
return if (state.connectionState == AapPodState.ConnectionState.READY) 1.0f else 0f
|
||||
}
|
||||
|
||||
val rssi: Int get() = ble?.rssi ?: 0
|
||||
|
||||
/** Snapshot of connection-related state for badges and bars. */
|
||||
val connectionState: ConnectionState
|
||||
get() = ConnectionState(
|
||||
hasBleData = ble != null,
|
||||
bleKeyState = bleKeyState,
|
||||
isAapConnected = isAapConnected,
|
||||
rssiQuality = rssiQuality,
|
||||
)
|
||||
|
||||
internal fun computeAapBoost(now: Instant): Float {
|
||||
val state = aap ?: return 0f
|
||||
if (state.connectionState != AapPodState.ConnectionState.READY) return 0.05f
|
||||
val lastMessage = state.lastMessageAt ?: return 0.05f
|
||||
val ageSeconds = Duration.between(lastMessage, now).seconds
|
||||
return when {
|
||||
ageSeconds < 0 -> 0.05f // future timestamp (defensive)
|
||||
ageSeconds < 10 -> 0.15f // fresh
|
||||
ageSeconds < 30 -> 0.10f // warm
|
||||
else -> 0.05f // stale
|
||||
}
|
||||
}
|
||||
|
||||
// Battery — AAP preferred, BLE fallback, then cached
|
||||
val batteryLeft: Float?
|
||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
|
||||
|
||||
val batteryRight: Float?
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
|
||||
|
||||
val batteryCase: Float?
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
|
||||
|
||||
val batteryHeadset: Float?
|
||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
|
||||
|
||||
/** True when at least one displayed battery value was filled from cache (not live). */
|
||||
val isBatteryCached: Boolean
|
||||
get() {
|
||||
if (cached == null) return false
|
||||
val usedLeft = aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null && cached.left != null
|
||||
val usedRight = aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null && cached.right != null
|
||||
val usedCase = aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null && cached.case != null
|
||||
val usedHeadset = aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null && cached.headset != null
|
||||
return usedLeft || usedRight || usedCase || usedHeadset
|
||||
}
|
||||
|
||||
/** Oldest per-slot timestamp among battery values that fell through to cache. Null if all live. */
|
||||
val cachedBatteryAt: Instant?
|
||||
get() {
|
||||
if (cached == null) return null
|
||||
return listOfNotNull(
|
||||
cached.left?.updatedAt.takeIf { aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null },
|
||||
cached.right?.updatedAt.takeIf { aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null },
|
||||
cached.case?.updatedAt.takeIf { aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null },
|
||||
cached.headset?.updatedAt.takeIf { aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null },
|
||||
).minOrNull()
|
||||
}
|
||||
|
||||
// Charging — AAP preferred, BLE fallback, then cached
|
||||
val isLeftPodCharging: Boolean?
|
||||
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging
|
||||
|
||||
val isRightPodCharging: Boolean?
|
||||
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging
|
||||
|
||||
val isCaseCharging: Boolean?
|
||||
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging
|
||||
|
||||
val isHeadsetBeingCharged: Boolean?
|
||||
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
|
||||
|
||||
// Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback.
|
||||
private val resolvedPrimaryPod: DualBlePodSnapshot.Pod?
|
||||
get() = aap?.aapPrimaryPod?.pod?.let { aapPod ->
|
||||
when (aapPod) {
|
||||
AapSetting.PrimaryPod.Pod.LEFT -> DualBlePodSnapshot.Pod.LEFT
|
||||
AapSetting.PrimaryPod.Pod.RIGHT -> DualBlePodSnapshot.Pod.RIGHT
|
||||
}
|
||||
} ?: (ble as? DualApplePods)?.primaryPod
|
||||
|
||||
// Ear detection — AAP preferred (lower latency), BLE fallback.
|
||||
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
|
||||
val isLeftInEar: Boolean?
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
}
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isLeftPodInEar
|
||||
}
|
||||
|
||||
val isRightInEar: Boolean?
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
}
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isRightPodInEar
|
||||
}
|
||||
|
||||
val isBeingWorn: Boolean?
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
if (earDetection != null) {
|
||||
return earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
&& earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
}
|
||||
return (ble as? HasEarDetection)?.isBeingWorn
|
||||
}
|
||||
|
||||
val isEitherPodInEar: Boolean?
|
||||
get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar
|
||||
|
||||
val caseLidState: DualApplePods.LidState?
|
||||
get() = (ble as? DualApplePods)?.caseLidState
|
||||
|
||||
// Microphone — AAP preferred (from primary pod identity), BLE fallback
|
||||
val isLeftPodMicrophone: Boolean?
|
||||
get() {
|
||||
aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.LEFT }
|
||||
return (ble as? HasDualMicrophone)?.isLeftPodMicrophone
|
||||
}
|
||||
|
||||
val isRightPodMicrophone: Boolean?
|
||||
get() {
|
||||
aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.RIGHT }
|
||||
return (ble as? HasDualMicrophone)?.isRightPodMicrophone
|
||||
}
|
||||
|
||||
// Icons / labels
|
||||
val iconRes: Int get() = ble?.iconRes ?: model.iconRes
|
||||
|
||||
val leftPodIcon: Int
|
||||
get() = (ble as? DualBlePodSnapshot)?.leftPodIcon
|
||||
?: model.leftPodIconRes
|
||||
?: R.drawable.device_airpods_gen1_left
|
||||
|
||||
val rightPodIcon: Int
|
||||
get() = (ble as? DualBlePodSnapshot)?.rightPodIcon
|
||||
?: model.rightPodIconRes
|
||||
?: R.drawable.device_airpods_gen1_right
|
||||
|
||||
val caseIcon: Int
|
||||
get() = (ble as? HasCase)?.caseIcon
|
||||
?: model.caseIconRes
|
||||
?: R.drawable.device_airpods_gen1_case
|
||||
|
||||
fun getLabel(context: Context): String = ble?.getLabel(context) ?: model.label
|
||||
|
||||
// Debug
|
||||
val rawDataHex: List<String> get() = ble?.rawDataHex ?: emptyList()
|
||||
|
||||
// AAP controls
|
||||
val ancMode: AapSetting.AncMode?
|
||||
get() = aap?.setting()
|
||||
|
||||
val pendingAncMode: AapSetting.AncMode.Value?
|
||||
get() = aap?.pendingAncMode
|
||||
|
||||
val conversationalAwareness: AapSetting.ConversationalAwareness?
|
||||
get() = aap?.setting()
|
||||
|
||||
val toneVolume: AapSetting.ToneVolume?
|
||||
get() = aap?.setting()
|
||||
|
||||
val personalizedVolume: AapSetting.PersonalizedVolume?
|
||||
get() = aap?.setting()
|
||||
|
||||
val volumeSwipe: AapSetting.VolumeSwipe?
|
||||
get() = aap?.setting()
|
||||
|
||||
val ncWithOneAirPod: AapSetting.NcWithOneAirPod?
|
||||
get() = aap?.setting()
|
||||
|
||||
val adaptiveAudioNoise: AapSetting.AdaptiveAudioNoise?
|
||||
get() = aap?.setting()
|
||||
|
||||
val pressSpeed: AapSetting.PressSpeed?
|
||||
get() = aap?.setting()
|
||||
|
||||
val pressHoldDuration: AapSetting.PressHoldDuration?
|
||||
get() = aap?.setting()
|
||||
|
||||
val volumeSwipeLength: AapSetting.VolumeSwipeLength?
|
||||
get() = aap?.setting()
|
||||
|
||||
val endCallMuteMic: AapSetting.EndCallMuteMic?
|
||||
get() = aap?.setting()
|
||||
|
||||
val microphoneMode: AapSetting.MicrophoneMode?
|
||||
get() = aap?.setting()
|
||||
|
||||
val earDetectionEnabled: AapSetting.EarDetectionEnabled?
|
||||
get() = aap?.setting()
|
||||
|
||||
val listeningModeCycle: AapSetting.ListeningModeCycle?
|
||||
get() = aap?.setting()
|
||||
|
||||
val allowOffOption: AapSetting.AllowOffOption?
|
||||
get() = aap?.setting()
|
||||
|
||||
val stemConfig: AapSetting.StemConfig?
|
||||
get() = aap?.setting()
|
||||
|
||||
val sleepDetection: AapSetting.SleepDetection?
|
||||
get() = aap?.setting()
|
||||
|
||||
val connectedDevices: AapSetting.ConnectedDevices?
|
||||
get() = aap?.setting()
|
||||
|
||||
val audioSource: AapSetting.AudioSource?
|
||||
get() = aap?.setting()
|
||||
|
||||
val eqBands: AapSetting.EqBands?
|
||||
get() = aap?.setting()
|
||||
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
get() = aap?.deviceInfo ?: cached?.deviceInfo
|
||||
|
||||
val isAapConnected: Boolean get() = aap != null
|
||||
|
||||
val isAapReady: Boolean get() = aap?.connectionState == AapPodState.ConnectionState.READY
|
||||
|
||||
/**
|
||||
* Badge key state for the overview card. Profile-stored keys are the source of truth while
|
||||
* the device is live: a profile that has an IRK (and optionally ENC) keeps showing those
|
||||
* icons whether the current scan happens to include a fresh BLE advertisement or not. For
|
||||
* anonymous BLE pods (no profile), we still derive the state from the live scan result.
|
||||
*/
|
||||
val bleKeyState: BleKeyState
|
||||
get() {
|
||||
if (!isLive) return BleKeyState.NONE
|
||||
if (profileKeyState != BleKeyState.NONE) return profileKeyState
|
||||
val applePod = ble as? ApplePods ?: return BleKeyState.NONE
|
||||
if (!applePod.meta.isIRKMatch) return BleKeyState.NONE
|
||||
return if (applePod.payload.private != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY
|
||||
}
|
||||
}
|
||||
|
||||
enum class BleKeyState { NONE, IRK_ONLY, IRK_AND_ENCRYPTED }
|
||||
|
||||
data class ConnectionState(
|
||||
val hasBleData: Boolean,
|
||||
val bleKeyState: BleKeyState,
|
||||
val isAapConnected: Boolean,
|
||||
val rssiQuality: Float,
|
||||
)
|
||||
@@ -1,84 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PodDeviceCache @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@SerializationCapod private val json: Json,
|
||||
) {
|
||||
private val cacheDir by lazy {
|
||||
File(context.cacheDir, "device_cache").apply { mkdirs() }
|
||||
}
|
||||
private val lock = Mutex()
|
||||
|
||||
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
|
||||
|
||||
suspend fun load(id: ProfileId): BleScanResult? = withContext(dispatcherProvider.IO) {
|
||||
log(TAG, VERBOSE) { "load(id=$id)" }
|
||||
val cacheFile = id.toCacheFile()
|
||||
lock.withLock {
|
||||
if (!cacheFile.exists()) return@withLock null
|
||||
try {
|
||||
val raw = cacheFile.readText()
|
||||
json.decodeFromString<BleScanResult>(raw)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" }
|
||||
cacheFile.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveAll(data: Map<ProfileId, BleScanResult>) {
|
||||
log(TAG, VERBOSE) { "saveAll(): ${data.size} entries" }
|
||||
lock.withLock {
|
||||
data.forEach { (id, device) ->
|
||||
log(TAG, VERBOSE) { "save(id=$id, device=$device)" }
|
||||
val cacheFile = id.toCacheFile()
|
||||
try {
|
||||
val encoded = json.encodeToString(BleScanResult.serializer(), device)
|
||||
cacheFile.writeText(encoded)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" }
|
||||
cacheFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(id: ProfileId) {
|
||||
log(TAG, VERBOSE) { "delete(): profileId=$id" }
|
||||
lock.withLock {
|
||||
val cacheFile = id.toCacheFile()
|
||||
try {
|
||||
cacheFile.delete()
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to delete profile for $id: ${e.asLog()}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "PodMonitor", "Cache")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.collections.firstOrNull
|
||||
|
||||
fun PodMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
|
||||
.map { devices -> devices.filter { it.meta.profile != null } }
|
||||
|
||||
fun PodMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
|
||||
@@ -1,9 +0,0 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import dagger.Reusable
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class PodSorter @Inject constructor(
|
||||
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Singleton
|
||||
class AapAutoConnect @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val blePodMonitor: BlePodMonitor,
|
||||
) {
|
||||
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
||||
fun monitor(): Flow<Unit> = merge(
|
||||
initialConnect(),
|
||||
reconnectOnDisconnect(),
|
||||
correctModelOnDeviceInfo(),
|
||||
)
|
||||
|
||||
private fun initialConnect(): Flow<Unit> = combine(
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices,
|
||||
) { profiles, connectedDevices ->
|
||||
val connectedAddresses = connectedDevices.map { it.address }.toSet()
|
||||
profiles.filter { it.address in connectedAddresses }
|
||||
}
|
||||
.mapLatest { profiles ->
|
||||
val bondedDevices = bluetoothManager.bondedDevices().first()
|
||||
|
||||
coroutineScope {
|
||||
for (profile in profiles) {
|
||||
launch { connectWithRetries(profile, bondedDevices) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "initialConnect" }
|
||||
|
||||
private suspend fun connectWithRetries(
|
||||
profile: eu.darken.capod.profiles.core.DeviceProfile,
|
||||
bondedDevices: Set<eu.darken.capod.common.bluetooth.BluetoothDevice2>,
|
||||
) {
|
||||
val address = profile.address ?: return
|
||||
val bonded = bondedDevices.firstOrNull { it.address == address } ?: return
|
||||
|
||||
val currentState = aapManager.allStates.value[address]
|
||||
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG, VERBOSE) { "AAP already connected/connecting to $address" }
|
||||
return
|
||||
}
|
||||
|
||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||
try {
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
log(TAG) { "AAP connected to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||
|
||||
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
||||
delay(delayMs)
|
||||
|
||||
// Bail out if classic BT disconnected
|
||||
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
||||
if (address !in currentConnected) {
|
||||
log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
val retryState = aapManager.allStates.value[address]
|
||||
if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "AAP initial retry: $address already reconnected, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
||||
break
|
||||
} catch (retryException: Exception) {
|
||||
log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnectOnDisconnect(): Flow<Unit> = aapManager.disconnectEvents
|
||||
.onEach { address ->
|
||||
if (!activeReconnects.add(address)) {
|
||||
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
try {
|
||||
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
||||
delay(delayMs)
|
||||
|
||||
// Check if still profiled
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (profile == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still bonded
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still classically connected
|
||||
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
||||
if (address !in currentConnected) {
|
||||
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still visible in BLE
|
||||
val bleDevices = blePodMonitor.devices.first()
|
||||
if (bleDevices.none { it.meta?.profile?.address == address }) {
|
||||
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if already reconnected (e.g., by initialConnect)
|
||||
val currentState = aapManager.allStates.value[address]
|
||||
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "AAP reconnect: $address already reconnected" }
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
log(TAG) { "AAP reconnected to $address" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
activeReconnects.remove(address)
|
||||
}
|
||||
}
|
||||
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
|
||||
.setupCommonEventHandlers(TAG) { "reconnect" }
|
||||
|
||||
private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
|
||||
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
|
||||
.distinctUntilChanged()
|
||||
.map { modelNumbers ->
|
||||
// Clean up tracking for addresses that disconnected
|
||||
processedModelCorrections.retainAll(modelNumbers.keys)
|
||||
|
||||
for ((address, modelNumber) in modelNumbers) {
|
||||
if (modelNumber == null) continue
|
||||
if (address in processedModelCorrections) continue
|
||||
processedModelCorrections.add(address)
|
||||
|
||||
try {
|
||||
val detectedModel = PodModel.fromModelNumber(modelNumber) ?: continue
|
||||
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.firstOrNull { it.address == address }
|
||||
?: continue
|
||||
|
||||
if (profile.model == detectedModel) {
|
||||
log(TAG, VERBOSE) { "AAP model confirmed for $address: $detectedModel" }
|
||||
continue
|
||||
}
|
||||
|
||||
log(TAG) { "AAP model mismatch for $address: profile=${profile.model}, detected=$detectedModel" }
|
||||
|
||||
// Allow key exchange (AapKeyPersister) to complete before disconnecting
|
||||
delay(2.seconds)
|
||||
|
||||
aapManager.disconnect(address)
|
||||
profilesRepo.updateProfile(profile.copy(model = detectedModel))
|
||||
log(TAG) { "AAP model corrected for $address: ${profile.model} -> $detectedModel" }
|
||||
|
||||
// Reconnect explicitly
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded != null) {
|
||||
aapManager.connect(address, bonded.internal, detectedModel)
|
||||
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP model correction failed for $address: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "correctModel" }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "AapAutoConnect")
|
||||
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
||||
private val CONNECT_TIMEOUT = 5.seconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persists IRK/ENC keys from AAP key exchange to [AppleDeviceProfile],
|
||||
* enabling BLE encrypted battery decryption with 1% granularity.
|
||||
*/
|
||||
@Singleton
|
||||
class AapKeyPersister @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
fun monitor(): Flow<Unit> = aapManager.keysReceived
|
||||
.onEach { (address, keys) ->
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.firstOrNull { it.address == address }
|
||||
|
||||
if (profile == null) {
|
||||
log(TAG) { "No profile found for $address, skipping key persistence" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
val updated = profile.copy(
|
||||
identityKey = keys.irk ?: profile.identityKey,
|
||||
encryptionKey = keys.encKey ?: profile.encryptionKey,
|
||||
)
|
||||
|
||||
if (updated != profile) {
|
||||
profilesRepo.updateProfile(updated)
|
||||
log(TAG) { "Persisted keys for $address (IRK=${keys.irk != null}, ENC=${keys.encKey != null})" }
|
||||
}
|
||||
}
|
||||
.map { }
|
||||
.setupCommonEventHandlers(TAG) { "keyPersister" }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "AapKeyPersister")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Launches AAP auto-connect and key persistence in [appScope],
|
||||
* independent of [eu.darken.capod.monitor.core.worker.MonitorService] lifecycle.
|
||||
*
|
||||
* Call [start] from [eu.darken.capod.monitor.core.DeviceMonitor] init to activate.
|
||||
*/
|
||||
@Singleton
|
||||
class AapLifecycleManager @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val aapAutoConnect: AapAutoConnect,
|
||||
private val aapKeyPersister: AapKeyPersister,
|
||||
private val stemConfigSender: StemConfigSender,
|
||||
private val stemPressReaction: StemPressReaction,
|
||||
) {
|
||||
fun start() {
|
||||
log(TAG) { "start()" }
|
||||
merge(
|
||||
aapAutoConnect.monitor(),
|
||||
aapKeyPersister.monitor(),
|
||||
stemConfigSender.monitor(),
|
||||
stemPressReaction.monitor(),
|
||||
)
|
||||
.catch { e -> log(TAG, WARN) { "AAP lifecycle error: ${e.asLog()}" } }
|
||||
.setupCommonEventHandlers(TAG) { "aapActive" }
|
||||
.launchIn(appScope)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "AapLifecycleManager")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionSettings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class StemConfigSender @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val stemActionSettings: StemActionSettings,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
fun monitor(): Flow<Unit> = combine(
|
||||
aapManager.allStates,
|
||||
stemActionMask(),
|
||||
) { states, mask ->
|
||||
states.entries
|
||||
.filter { (_, s) -> s.connectionState == AapPodState.ConnectionState.READY }
|
||||
.mapNotNull { (address, _) ->
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.firstOrNull { it.address == address }
|
||||
if (profile != null && profile.model.features.hasStemConfig) {
|
||||
address to mask
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { commands ->
|
||||
for ((address, mask) in commands) {
|
||||
try {
|
||||
aapManager.sendCommand(address, AapCommand.SetStemConfig(mask))
|
||||
log(TAG) { "Sent stem config 0x${"%02X".format(mask)} to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "StemConfig send failed for $address: $e" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.map { }
|
||||
.setupCommonEventHandlers(TAG) { "stemConfig" }
|
||||
|
||||
private fun stemActionMask(): Flow<Int> = combine(
|
||||
stemActionSettings.leftSingle.flow,
|
||||
stemActionSettings.leftDouble.flow,
|
||||
stemActionSettings.leftTriple.flow,
|
||||
stemActionSettings.leftLong.flow,
|
||||
stemActionSettings.rightSingle.flow,
|
||||
stemActionSettings.rightDouble.flow,
|
||||
stemActionSettings.rightTriple.flow,
|
||||
stemActionSettings.rightLong.flow,
|
||||
) { values ->
|
||||
var mask = 0
|
||||
if (values[0] != StemAction.NONE || values[4] != StemAction.NONE) mask = mask or 0x01 // single
|
||||
if (values[1] != StemAction.NONE || values[5] != StemAction.NONE) mask = mask or 0x02 // double
|
||||
if (values[2] != StemAction.NONE || values[6] != StemAction.NONE) mask = mask or 0x04 // triple
|
||||
if (values[3] != StemAction.NONE || values[7] != StemAction.NONE) mask = mask or 0x08 // long
|
||||
mask
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "StemConfigSender")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import android.view.KeyEvent
|
||||
import eu.darken.capod.common.MediaControl
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionSettings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class StemPressReaction @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val stemActionSettings: StemActionSettings,
|
||||
private val mediaControl: MediaControl,
|
||||
) {
|
||||
fun monitor(): Flow<Unit> = aapManager.stemPressEvents
|
||||
.onEach { (_, event) ->
|
||||
val action = resolveAction(event)
|
||||
log(TAG) { "Stem ${event.bud} ${event.pressType} -> $action" }
|
||||
executeAction(action)
|
||||
}
|
||||
.map { }
|
||||
.setupCommonEventHandlers(TAG) { "stemReaction" }
|
||||
|
||||
private suspend fun resolveAction(event: StemPressEvent): StemAction {
|
||||
val setting = when (event.bud) {
|
||||
StemPressEvent.Bud.LEFT -> when (event.pressType) {
|
||||
StemPressEvent.PressType.SINGLE -> stemActionSettings.leftSingle
|
||||
StemPressEvent.PressType.DOUBLE -> stemActionSettings.leftDouble
|
||||
StemPressEvent.PressType.TRIPLE -> stemActionSettings.leftTriple
|
||||
StemPressEvent.PressType.LONG -> stemActionSettings.leftLong
|
||||
}
|
||||
StemPressEvent.Bud.RIGHT -> when (event.pressType) {
|
||||
StemPressEvent.PressType.SINGLE -> stemActionSettings.rightSingle
|
||||
StemPressEvent.PressType.DOUBLE -> stemActionSettings.rightDouble
|
||||
StemPressEvent.PressType.TRIPLE -> stemActionSettings.rightTriple
|
||||
StemPressEvent.PressType.LONG -> stemActionSettings.rightLong
|
||||
}
|
||||
}
|
||||
return setting.flow.first()
|
||||
}
|
||||
|
||||
private suspend fun executeAction(action: StemAction) = when (action) {
|
||||
StemAction.NONE, StemAction.NO_ACTION -> Unit
|
||||
StemAction.PLAY_PAUSE -> mediaControl.sendPlayPause()
|
||||
StemAction.NEXT_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_NEXT)
|
||||
StemAction.PREVIOUS_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
|
||||
StemAction.VOLUME_UP -> mediaControl.adjustVolumeUp()
|
||||
StemAction.VOLUME_DOWN -> mediaControl.adjustVolumeDown()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "StemPressReaction")
|
||||
}
|
||||
}
|
||||
+33
-47
@@ -1,14 +1,14 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
package eu.darken.capod.monitor.core.ble
|
||||
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.bluetooth.BleScanner
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
@@ -16,19 +16,20 @@ import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.PodFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.PodFactory
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.profiles.core.currentProfiles
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -39,22 +40,21 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PodMonitor @Inject constructor(
|
||||
class BlePodMonitor @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val bleScanner: BleScanner,
|
||||
private val podFactory: PodFactory,
|
||||
private val generalSettings: GeneralSettings,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val podDeviceCache: PodDeviceCache,
|
||||
permissionTool: PermissionTool,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
|
||||
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
|
||||
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
||||
private val cacheLock = Mutex()
|
||||
|
||||
val devices: Flow<List<PodDevice>> = combine(
|
||||
val devices: Flow<List<BlePodSnapshot>> = combine(
|
||||
permissionTool.missingScanPermissions,
|
||||
bluetoothManager.isBluetoothEnabled
|
||||
) { missingScanPermissions, isBluetoothEnabled ->
|
||||
@@ -63,10 +63,16 @@ class PodMonitor @Inject constructor(
|
||||
}
|
||||
.flatMapLatest { isReady ->
|
||||
if (!isReady) {
|
||||
log(TAG, WARN) { "Bluetooth is not ready" }
|
||||
log(TAG, Logging.Priority.WARN) { "Bluetooth is not ready" }
|
||||
flowOf(null)
|
||||
} else {
|
||||
createBleScanner()
|
||||
val staleEvictionTicker: Flow<Collection<BleScanResult>> = flow {
|
||||
while (true) {
|
||||
delay(STALE_EVICTION_INTERVAL.toMillis())
|
||||
emit(emptyList())
|
||||
}
|
||||
}
|
||||
merge(createBleScanner(), staleEvictionTicker)
|
||||
}
|
||||
}
|
||||
.map { results -> results?.mapNotNull { podFactory.createPod(it) } }
|
||||
@@ -76,10 +82,13 @@ class PodMonitor @Inject constructor(
|
||||
}
|
||||
.retryWhen { cause, attempt ->
|
||||
if (cause is SecurityException) {
|
||||
log(TAG, WARN) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.WARN
|
||||
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
||||
false
|
||||
} else {
|
||||
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||
delay(3000)
|
||||
true
|
||||
}
|
||||
@@ -87,12 +96,12 @@ class PodMonitor @Inject constructor(
|
||||
.onStart { emit(emptyList()) }
|
||||
.replayingShare(appScope)
|
||||
|
||||
private suspend fun sortPodsToInterest(devices: Collection<PodDevice>): List<PodDevice> {
|
||||
private suspend fun sortPodsToInterest(devices: Collection<BlePodSnapshot>): List<BlePodSnapshot> {
|
||||
val now = Instant.now()
|
||||
val profiles = profilesRepo.currentProfiles() ?: emptyList()
|
||||
|
||||
return devices.sortedWith(
|
||||
compareBy<PodDevice> { device ->
|
||||
compareBy<BlePodSnapshot> { device ->
|
||||
// Use profile position in list as priority (0 = highest priority)
|
||||
device.meta.profile?.let { profile ->
|
||||
profiles.indexOfFirst { it.id == profile.id }.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
||||
@@ -140,7 +149,7 @@ class PodMonitor @Inject constructor(
|
||||
.flatMapLatest { options ->
|
||||
val filters = when {
|
||||
options.showUnfiltered -> {
|
||||
log(TAG, WARN) { "Using unfiltered scan mode" }
|
||||
log(TAG, Logging.Priority.WARN) { "Using unfiltered scan mode" }
|
||||
setOf(ScanFilter.Builder().build())
|
||||
}
|
||||
|
||||
@@ -159,7 +168,7 @@ class PodMonitor @Inject constructor(
|
||||
|
||||
private suspend fun processWithCache(
|
||||
newPods: List<PodFactory.Result>?
|
||||
): Map<PodDevice.Id, PodDevice> = cacheLock.withLock {
|
||||
): Map<BlePodSnapshot.Id, BlePodSnapshot> = cacheLock.withLock {
|
||||
if (newPods == null) {
|
||||
log(TAG) { "Null result, Bluetooth is disabled." }
|
||||
deviceCache.clear()
|
||||
@@ -168,13 +177,13 @@ class PodMonitor @Inject constructor(
|
||||
|
||||
val now = Instant.now()
|
||||
deviceCache.toList().forEach { (key, value) ->
|
||||
if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) {
|
||||
log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
|
||||
if (Duration.between(value.seenLastAt, now) > STALE_DEVICE_TIMEOUT) {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "Removing stale device from cache: $value" }
|
||||
deviceCache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
|
||||
val pods = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
||||
|
||||
pods.putAll(deviceCache)
|
||||
|
||||
@@ -183,35 +192,12 @@ class PodMonitor @Inject constructor(
|
||||
pods[it.identifier] = it
|
||||
}
|
||||
|
||||
newPods
|
||||
.mapNotNull {
|
||||
val profileId = it.device.meta.profile?.id ?: return@mapNotNull null
|
||||
profileId to it.device.scanResult
|
||||
}
|
||||
.toMap()
|
||||
.run { podDeviceCache.saveAll(this) }
|
||||
return pods
|
||||
}
|
||||
|
||||
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
|
||||
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
|
||||
|
||||
val liveDevice = devices.firstOrNull()?.firstOrNull { device ->
|
||||
device.meta.profile?.id == profileId
|
||||
}
|
||||
if (liveDevice != null) {
|
||||
log(TAG) { "Found live device for profile $profileId: $liveDevice" }
|
||||
return liveDevice
|
||||
}
|
||||
|
||||
val cachedDevice = podDeviceCache.load(profileId)?.let {
|
||||
podFactory.createPod(it)?.device
|
||||
}
|
||||
log(TAG) { "Cached device for profile $profileId: $cachedDevice" }
|
||||
return cachedDevice
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "PodMonitor")
|
||||
private val STALE_DEVICE_TIMEOUT = Duration.ofSeconds(20)
|
||||
private val STALE_EVICTION_INTERVAL = Duration.ofSeconds(10)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package eu.darken.capod.monitor.core.cache
|
||||
|
||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
@Serializable
|
||||
data class CachedDeviceState(
|
||||
@SerialName("profileId") val profileId: String,
|
||||
@SerialName("model") val model: PodModel,
|
||||
@SerialName("address") val address: String? = null,
|
||||
@SerialName("left") val left: CachedBatterySlot? = null,
|
||||
@SerialName("right") val right: CachedBatterySlot? = null,
|
||||
@SerialName("case") val case: CachedBatterySlot? = null,
|
||||
@SerialName("headset") val headset: CachedBatterySlot? = null,
|
||||
@SerialName("isLeftCharging") val isLeftCharging: Boolean? = null,
|
||||
@SerialName("isRightCharging") val isRightCharging: Boolean? = null,
|
||||
@SerialName("isCaseCharging") val isCaseCharging: Boolean? = null,
|
||||
@SerialName("isHeadsetCharging") val isHeadsetCharging: Boolean? = null,
|
||||
@SerialName("deviceName") val deviceName: String? = null,
|
||||
@SerialName("serialNumber") val serialNumber: String? = null,
|
||||
@SerialName("firmwareVersion") val firmwareVersion: String? = null,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("lastSeenAt") val lastSeenAt: Instant,
|
||||
) {
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
get() {
|
||||
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
|
||||
return AapDeviceInfo(
|
||||
name = deviceName ?: "",
|
||||
modelNumber = "",
|
||||
manufacturer = "",
|
||||
serialNumber = serialNumber ?: "",
|
||||
firmwareVersion = firmwareVersion ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CachedBatterySlot(
|
||||
@SerialName("percent") val percent: Float,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("updatedAt") val updatedAt: Instant,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package eu.darken.capod.monitor.core.cache
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceStateCache @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@SerializationCapod private val json: Json,
|
||||
) {
|
||||
private val cacheDir by lazy {
|
||||
File(context.filesDir, "device_state_cache").apply { mkdirs() }
|
||||
}
|
||||
private val lock = Mutex()
|
||||
|
||||
private val _cachedStates = MutableStateFlow<Map<ProfileId, CachedDeviceState>>(emptyMap())
|
||||
val cachedStates: StateFlow<Map<ProfileId, CachedDeviceState>> = _cachedStates
|
||||
|
||||
init {
|
||||
appScope.launch { loadAll() }
|
||||
}
|
||||
|
||||
private suspend fun loadAll() = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
val dir = cacheDir
|
||||
if (!dir.exists()) return@withLock
|
||||
|
||||
val loaded = mutableMapOf<ProfileId, CachedDeviceState>()
|
||||
dir.listFiles()?.filter { it.name.startsWith("profile_") && it.name.endsWith(".json") }?.forEach { file ->
|
||||
val profileId = file.name.removePrefix("profile_").removeSuffix(".json")
|
||||
try {
|
||||
val state = json.decodeFromString<CachedDeviceState>(file.readText())
|
||||
loaded[profileId] = state
|
||||
} catch (e: Exception) {
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.ERROR
|
||||
) { "Failed to load cached state from ${file.name}: ${e.asLog()}, deleting" }
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
|
||||
_cachedStates.value = loaded
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
|
||||
|
||||
suspend fun save(id: ProfileId, state: CachedDeviceState) = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "save(id=$id)" }
|
||||
val file = id.toCacheFile()
|
||||
try {
|
||||
file.writeText(json.encodeToString(CachedDeviceState.serializer(), state))
|
||||
_cachedStates.value += (id to state)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, Logging.Priority.ERROR) { "Failed to save state for $id: ${e.asLog()}" }
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun load(id: ProfileId): CachedDeviceState? = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
val cached = _cachedStates.value[id]
|
||||
if (cached != null) return@withContext cached
|
||||
|
||||
val file = id.toCacheFile()
|
||||
if (!file.exists()) return@withContext null
|
||||
try {
|
||||
json.decodeFromString<CachedDeviceState>(file.readText())
|
||||
} catch (e: Exception) {
|
||||
log(TAG, Logging.Priority.ERROR) { "Failed to load state for $id: ${e.asLog()}, deleting" }
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
|
||||
id.toCacheFile().delete()
|
||||
_cachedStates.value -= id
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" }
|
||||
cacheDir.listFiles()?.forEach { it.delete() }
|
||||
_cachedStates.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "DeviceStateCache")
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -14,6 +14,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
@@ -26,10 +27,12 @@ import eu.darken.capod.common.hasApiLevel
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
||||
@@ -53,7 +56,6 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MonitorService : Service() {
|
||||
@@ -63,13 +65,15 @@ class MonitorService : Service() {
|
||||
@Inject lateinit var notificationManager: NotificationManager
|
||||
@Inject lateinit var generalSettings: GeneralSettings
|
||||
@Inject lateinit var permissionTool: PermissionTool
|
||||
@Inject lateinit var podMonitor: PodMonitor
|
||||
@Inject lateinit var blePodMonitor: BlePodMonitor
|
||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||
@Inject lateinit var bluetoothManager: BluetoothManager2
|
||||
@Inject lateinit var playPause: PlayPause
|
||||
@Inject lateinit var autoConnect: AutoConnect
|
||||
@Inject lateinit var popUpReaction: PopUpReaction
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||
|
||||
private val monitorScope = MonitorCoroutineScope()
|
||||
private var monitoringJob: Job? = null
|
||||
@@ -176,8 +180,8 @@ class MonitorService : Service() {
|
||||
return
|
||||
}
|
||||
|
||||
val monitorJob = podMonitor.primaryDevice()
|
||||
.setupCommonEventHandlers(TAG) { "PodMonitor" }
|
||||
val monitorJob = deviceMonitor.primaryDevice()
|
||||
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
||||
.distinctUntilChanged()
|
||||
.throttleLatest(1000)
|
||||
.onEach { currentDevice ->
|
||||
@@ -209,8 +213,9 @@ class MonitorService : Service() {
|
||||
generalSettings.monitorMode.flow,
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices,
|
||||
) { monitorMode, profiles, connectedDevices ->
|
||||
listOf(monitorMode, profiles, connectedDevices)
|
||||
aapConnectionManager.allStates,
|
||||
) { monitorMode, profiles, connectedDevices, aapStates ->
|
||||
listOf(monitorMode, profiles, connectedDevices, aapStates)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,9 +225,13 @@ class MonitorService : Service() {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val profiles = arguments[1] as List<DeviceProfile>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val devices = arguments[2] as Collection<BluetoothDevice2>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val aapStates = arguments[3] as Map<*, *>
|
||||
|
||||
val connectedAddresses = devices.map { it.address }.toSet()
|
||||
val knownAddresses = profiles.mapNotNull { it.address }.toSet()
|
||||
log(TAG) { "Monitor mode: $monitorMode" }
|
||||
@@ -241,7 +250,7 @@ class MonitorService : Service() {
|
||||
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
|
||||
}
|
||||
|
||||
knownAddresses.any { it in connectedAddresses } -> {
|
||||
knownAddresses.any { it in connectedAddresses } || aapStates.isNotEmpty() -> {
|
||||
log(TAG) { "A device is connected, aborting any timeout." }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,10 @@ import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -22,58 +17,61 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
|
||||
fun createContentView(device: PodDevice): RemoteViews = when (device) {
|
||||
is DualPodDevice -> createDualPods(device)
|
||||
is SinglePodDevice -> createSinglePod(device)
|
||||
fun createContentView(device: PodDevice): RemoteViews = when {
|
||||
device.hasDualPods -> createDualPods(device)
|
||||
device.model != PodModel.UNKNOWN -> createSinglePod(device)
|
||||
else -> createUnknownDevice(device)
|
||||
}
|
||||
|
||||
private fun createDualPods(device: DualPodDevice): RemoteViews = RemoteViews(
|
||||
private fun createDualPods(device: PodDevice): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_dual_pods_small
|
||||
).apply {
|
||||
// Left
|
||||
val leftPercent = device.batteryLeftPodPercent
|
||||
val leftPercent = device.batteryLeft
|
||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
||||
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
|
||||
val isLeftPodCharging = device.isLeftPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
|
||||
val isLeftPodInEar = device.isLeftInEar ?: false
|
||||
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
|
||||
|
||||
// Case
|
||||
setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE)
|
||||
(device as? HasCase)?.let { case ->
|
||||
setViewVisibility(R.id.pod_case_charging, if (device.hasCase) View.VISIBLE else View.GONE)
|
||||
if (device.hasCase) {
|
||||
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
|
||||
val casePercent = case.batteryCasePercent
|
||||
val casePercent = device.batteryCase
|
||||
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
|
||||
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
|
||||
setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
// Right
|
||||
val rightPercent = device.batteryRightPodPercent
|
||||
val rightPercent = device.batteryRight
|
||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
||||
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
|
||||
val isRightPodCharging = device.isRightPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
|
||||
val isRightPodInEar = device.isRightInEar ?: false
|
||||
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
private fun createSinglePod(device: SinglePodDevice): RemoteViews = RemoteViews(
|
||||
private fun createSinglePod(device: PodDevice): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_single_pods_small
|
||||
).apply {
|
||||
val headsetPercent = device.batteryHeadsetPercent
|
||||
val headsetPercent = device.batteryHeadset
|
||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
|
||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||
if (device is HasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
|
||||
if (device.hasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
if (device is HasChargeDetectionDual) {
|
||||
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
||||
if (device.isHeadsetBeingCharged != null) {
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,61 +82,64 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.device, device.getLabel(context))
|
||||
}
|
||||
|
||||
fun createBigContentView(device: PodDevice): RemoteViews = when (device) {
|
||||
is DualPodDevice -> createDualPodsBig(device)
|
||||
is SinglePodDevice -> createSinglePodBig(device)
|
||||
fun createBigContentView(device: PodDevice): RemoteViews = when {
|
||||
device.hasDualPods -> createDualPodsBig(device)
|
||||
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
|
||||
else -> createUnknownDeviceBig(device)
|
||||
}
|
||||
|
||||
private fun createDualPodsBig(device: DualPodDevice): RemoteViews = RemoteViews(
|
||||
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_dual_pods_big
|
||||
).apply {
|
||||
// Left
|
||||
val leftPercent = device.batteryLeftPodPercent
|
||||
val leftPercent = device.batteryLeft
|
||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
|
||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
||||
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
|
||||
val isLeftPodCharging = device.isLeftPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
|
||||
val isLeftPodInEar = device.isLeftInEar ?: false
|
||||
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
|
||||
|
||||
// Case
|
||||
setViewVisibility(R.id.pod_case_container, if (device is HasCase) View.VISIBLE else View.GONE)
|
||||
(device as? HasCase)?.let { case ->
|
||||
setViewVisibility(R.id.pod_case_container, if (device.hasCase) View.VISIBLE else View.GONE)
|
||||
if (device.hasCase) {
|
||||
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
|
||||
val casePercent = case.batteryCasePercent
|
||||
val casePercent = device.batteryCase
|
||||
setProgressBar(R.id.pod_case_progress, 100, percentToInt(casePercent), false)
|
||||
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
|
||||
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
|
||||
setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
// Right
|
||||
val rightPercent = device.batteryRightPodPercent
|
||||
val rightPercent = device.batteryRight
|
||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
|
||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
||||
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
|
||||
val isRightPodCharging = device.isRightPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
|
||||
val isRightPodInEar = device.isRightInEar ?: false
|
||||
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
private fun createSinglePodBig(device: SinglePodDevice): RemoteViews = RemoteViews(
|
||||
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_single_pods_big
|
||||
).apply {
|
||||
val headsetPercent = device.batteryHeadsetPercent
|
||||
val headsetPercent = device.batteryHeadset
|
||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
|
||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||
if (device is HasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
|
||||
if (device.hasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
if (device is HasChargeDetectionDual) {
|
||||
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
||||
if (device.isHeadsetBeingCharged != null) {
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,4 +155,4 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
return (percent * 100).roundToInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,9 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -57,7 +53,11 @@ class MonitorNotifications @Inject constructor(
|
||||
setOngoing(true)
|
||||
}
|
||||
|
||||
private fun getBuilder(device: PodDevice?, channelId: String, showHint: Boolean = false): NotificationCompat.Builder {
|
||||
private fun getBuilder(
|
||||
device: PodDevice?,
|
||||
channelId: String,
|
||||
showHint: Boolean = false
|
||||
): NotificationCompat.Builder {
|
||||
if (device == null) {
|
||||
return baseBuilder(channelId).apply {
|
||||
if (showHint) {
|
||||
@@ -75,59 +75,42 @@ class MonitorNotifications @Inject constructor(
|
||||
|
||||
return baseBuilder(channelId).apply {
|
||||
|
||||
// Options here should be mutually exclusive, and are prioritized by their order of importance
|
||||
// Some options are omitted here, as they will conflict with other options
|
||||
// TODO: Implement a settings pane to allow user to customize this
|
||||
val stateText = when {
|
||||
// Pods charging state
|
||||
// This goes first as pods should not be worn if it is still charging
|
||||
device is HasChargeDetection && device.isHeadsetBeingCharged -> {
|
||||
device.isHeadsetBeingCharged == true -> {
|
||||
context.getString(R.string.pods_charging_label)
|
||||
}
|
||||
|
||||
// Pods wear state
|
||||
device is HasEarDetection -> {
|
||||
if (device.isBeingWorn) context.getString(R.string.headset_being_worn_label)
|
||||
device.hasEarDetection -> {
|
||||
if (device.isBeingWorn == true) context.getString(R.string.headset_being_worn_label)
|
||||
else context.getString(R.string.headset_not_being_worn_label)
|
||||
}
|
||||
|
||||
// Case charge state
|
||||
// This is under pods wear state as we don't want it conflicting with it
|
||||
device is HasCase && device.isCaseCharging -> {
|
||||
device.hasCase && device.isCaseCharging == true -> {
|
||||
context.getString(R.string.pods_charging_label)
|
||||
}
|
||||
|
||||
else -> context.getString(R.string.pods_case_unknown_state)
|
||||
}
|
||||
|
||||
val batteryText = when (device) {
|
||||
is DualPodDevice -> {
|
||||
val leftPercent = device.batteryLeftPodPercent
|
||||
val rightPercent = device.batteryRightPodPercent
|
||||
val left = formatBatteryPercent(context, leftPercent)
|
||||
val right = formatBatteryPercent(context, rightPercent)
|
||||
when {
|
||||
device is HasCase -> {
|
||||
val casePercent = device.batteryCasePercent
|
||||
val case = formatBatteryPercent(context, casePercent)
|
||||
"$left $case $right"
|
||||
}
|
||||
|
||||
else -> "$left $right"
|
||||
val batteryText = when {
|
||||
device.hasDualPods -> {
|
||||
val left = formatBatteryPercent(context, device.batteryLeft)
|
||||
val right = formatBatteryPercent(context, device.batteryRight)
|
||||
if (device.hasCase) {
|
||||
val case = formatBatteryPercent(context, device.batteryCase)
|
||||
"$left $case $right"
|
||||
} else {
|
||||
"$left $right"
|
||||
}
|
||||
}
|
||||
|
||||
is SinglePodDevice -> {
|
||||
val headsetPercent = device.batteryHeadsetPercent
|
||||
val headset = formatBatteryPercent(context, headsetPercent)
|
||||
when {
|
||||
device is HasCase -> {
|
||||
val casePercent = device.batteryCasePercent
|
||||
val case = formatBatteryPercent(context, casePercent)
|
||||
"$headset $case"
|
||||
}
|
||||
|
||||
else -> headset
|
||||
device.model != PodModel.UNKNOWN -> {
|
||||
val headset = formatBatteryPercent(context, device.batteryHeadset)
|
||||
if (device.hasCase) {
|
||||
val case = formatBatteryPercent(context, device.batteryCase)
|
||||
"$headset $case"
|
||||
} else {
|
||||
headset
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
interface PodDevice {
|
||||
|
||||
val identifier: Id
|
||||
|
||||
val model: Model
|
||||
|
||||
val address: BluetoothAddress
|
||||
get() = scanResult.address
|
||||
|
||||
val seenLastAt: Instant
|
||||
|
||||
val seenFirstAt: Instant
|
||||
|
||||
val seenCounter: Int
|
||||
|
||||
val scanResult: BleScanResult
|
||||
|
||||
val rssi: Int
|
||||
get() = scanResult.rssi
|
||||
|
||||
val reliability: Float
|
||||
|
||||
val signalQuality: Float
|
||||
get() {
|
||||
/**
|
||||
* This is not correct but it works ¯\_(ツ)_/¯
|
||||
* The range of the RSSI is device specific (ROMs).
|
||||
*/
|
||||
val sqRssi = ((100 - abs(rssi)) / 100f)
|
||||
val sqReliability = max(BASE_CONFIDENCE, reliability)
|
||||
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
|
||||
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
|
||||
return (sqRssi + sqReliability + sqAge) / 2f
|
||||
}
|
||||
|
||||
val rawData: Map<Int, ByteArray>
|
||||
get() = scanResult.manufacturerSpecificData
|
||||
|
||||
val rawDataHex: List<String>
|
||||
get() = rawData.entries.map { entry ->
|
||||
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
val profile: DeviceProfile?
|
||||
}
|
||||
|
||||
val meta: Meta
|
||||
|
||||
fun getLabel(context: Context): String = model.label
|
||||
|
||||
@get:DrawableRes
|
||||
val iconRes: Int
|
||||
get() = model.iconRes
|
||||
|
||||
@JvmInline
|
||||
value class Id(private val id: UUID = UUID.randomUUID())
|
||||
|
||||
@Serializable
|
||||
enum class Model(
|
||||
val label: String,
|
||||
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
|
||||
) {
|
||||
@SerialName("airpods.gen1") AIRPODS_GEN1(
|
||||
label = "AirPods (Gen 1)",
|
||||
iconRes = R.drawable.device_airpods_gen1_both,
|
||||
),
|
||||
@SerialName("airpods.gen2") AIRPODS_GEN2(
|
||||
"AirPods (Gen 2)",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
),
|
||||
@SerialName("airpods.gen3") AIRPODS_GEN3(
|
||||
"AirPods (Gen 3)",
|
||||
R.drawable.device_airpods_gen3_both,
|
||||
),
|
||||
@SerialName("airpods.gen4") AIRPODS_GEN4(
|
||||
"AirPods (Gen 4)",
|
||||
R.drawable.device_airpods_gen3_both,
|
||||
),
|
||||
@SerialName("airpods.gen4.anc") AIRPODS_GEN4_ANC(
|
||||
"AirPods (Gen 4 ANC)",
|
||||
R.drawable.device_airpods_gen4anc_both,
|
||||
),
|
||||
@SerialName("airpods.pro") AIRPODS_PRO(
|
||||
"AirPods Pro",
|
||||
R.drawable.device_airpods_pro2_both
|
||||
),
|
||||
@SerialName("airpods.pro2") AIRPODS_PRO2(
|
||||
"AirPods Pro 2",
|
||||
R.drawable.device_airpods_pro2_both
|
||||
),
|
||||
@SerialName("airpods.pro2.usbc") AIRPODS_PRO2_USBC(
|
||||
"AirPods Pro 2 USB-C",
|
||||
R.drawable.device_airpods_pro2_both
|
||||
),
|
||||
@SerialName("airpods.pro3") AIRPODS_PRO3(
|
||||
"AirPods Pro 3",
|
||||
R.drawable.device_airpods_pro2_both
|
||||
),
|
||||
@SerialName("airpods.max") AIRPODS_MAX(
|
||||
"AirPods Max",
|
||||
R.drawable.device_airpods_max
|
||||
),
|
||||
@SerialName("airpods.max.usbc") AIRPODS_MAX_USBC(
|
||||
"AirPods Max USB-C",
|
||||
R.drawable.device_airpods_max
|
||||
),
|
||||
@SerialName("beats.flex") BEATS_FLEX(
|
||||
"Beats Flex",
|
||||
R.drawable.device_beats_earbuds,
|
||||
),
|
||||
@SerialName("beats.solo.3") BEATS_SOLO_3(
|
||||
"Beats Solo 3",
|
||||
R.drawable.device_beats_headphones,
|
||||
),
|
||||
@SerialName("beats.studio.3") BEATS_STUDIO_3(
|
||||
"Beats Studio 3",
|
||||
R.drawable.device_beats_studio3,
|
||||
),
|
||||
@SerialName("beats.x") BEATS_X(
|
||||
"Beats X",
|
||||
R.drawable.device_beats_x,
|
||||
),
|
||||
@SerialName("beats.powerbeats.3") POWERBEATS_3(
|
||||
"Power Beats 3",
|
||||
R.drawable.device_powerbeats_3,
|
||||
),
|
||||
@SerialName("beats.powerbeats.4") POWERBEATS_4(
|
||||
"Power Beats 4",
|
||||
R.drawable.device_powerbeats_4,
|
||||
),
|
||||
@SerialName("beats.powerbeats.pro") POWERBEATS_PRO(
|
||||
"Power Beats Pro",
|
||||
R.drawable.device_powerbeats_pro_both,
|
||||
),
|
||||
@SerialName("beats.powerbeats.pro2") POWERBEATS_PRO2(
|
||||
"Power Beats Pro 2",
|
||||
R.drawable.device_powerbeats_pro2_both,
|
||||
),
|
||||
@SerialName("beats.fit.pro") BEATS_FIT_PRO(
|
||||
"Beats Fit Pro",
|
||||
R.drawable.device_beats_fitpro_both,
|
||||
),
|
||||
@SerialName("fakes.tws.i99999") FAKE_AIRPODS_GEN1(
|
||||
"AirPods (Gen 1)? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
),
|
||||
@SerialName("fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
|
||||
"AirPods (Gen 2)? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
),
|
||||
@SerialName("fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
|
||||
"AirPods (Gen 3)? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_gen3_both,
|
||||
),
|
||||
@SerialName("fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
|
||||
"AirPods Pro? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
),
|
||||
@SerialName("fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
|
||||
"AirPods Pro2? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
),
|
||||
@SerialName("unknown") UNKNOWN(
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BASE_CONFIDENCE = 0.0f
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface SinglePodDevice : PodDevice {
|
||||
|
||||
val batteryHeadsetPercent: Float?
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen2
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen3
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4Anc
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsMaxUsbc
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2Usbc
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro3
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsFitPro
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsFlex
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsSolo3
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsStudio3
|
||||
import eu.darken.capod.pods.core.apple.beats.BeatsX
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeats3
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeats4
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro
|
||||
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro2
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen1
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen2
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro
|
||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
abstract class AppleFactoryModule {
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro3(factory: AirPodsPro3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeatsPro2(factory: PowerBeatsPro2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class PodModel(
|
||||
val label: String,
|
||||
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
|
||||
val features: Features = Features(),
|
||||
val modelNumbers: Set<String> = emptySet(),
|
||||
@DrawableRes val leftPodIconRes: Int? = null,
|
||||
@DrawableRes val rightPodIconRes: Int? = null,
|
||||
@DrawableRes val caseIconRes: Int? = null,
|
||||
) {
|
||||
@SerialName("airpods.gen1")
|
||||
AIRPODS_GEN1(
|
||||
"AirPods (Gen 1)",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
),
|
||||
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
caseIconRes = R.drawable.device_airpods_gen1_case,
|
||||
),
|
||||
|
||||
@SerialName("airpods.gen2")
|
||||
AIRPODS_GEN2(
|
||||
"AirPods (Gen 2)",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
),
|
||||
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
caseIconRes = R.drawable.device_airpods_gen1_case,
|
||||
),
|
||||
|
||||
@SerialName("airpods.gen3")
|
||||
AIRPODS_GEN3(
|
||||
"AirPods (Gen 3)",
|
||||
R.drawable.device_airpods_gen3_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
),
|
||||
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||
caseIconRes = R.drawable.device_airpods_gen3_case,
|
||||
),
|
||||
|
||||
@SerialName("airpods.gen4")
|
||||
AIRPODS_GEN4(
|
||||
"AirPods (Gen 4)",
|
||||
R.drawable.device_airpods_gen3_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasSleepDetection = true,
|
||||
),
|
||||
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||
caseIconRes = R.drawable.device_airpods_gen3_case,
|
||||
),
|
||||
|
||||
@SerialName("airpods.gen4.anc")
|
||||
AIRPODS_GEN4_ANC(
|
||||
"AirPods (Gen 4 ANC)",
|
||||
R.drawable.device_airpods_gen4anc_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasAdaptiveAnc = true,
|
||||
hasConversationAwareness = true,
|
||||
hasNcOneAirpod = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasPersonalizedVolume = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasAdaptiveAudioNoise = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasListeningModeCycle = true,
|
||||
hasAllowOffOption = true,
|
||||
hasStemConfig = true,
|
||||
hasSleepDetection = true,
|
||||
needsInitExt = true,
|
||||
),
|
||||
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
|
||||
caseIconRes = R.drawable.device_airpods_gen4anc_case,
|
||||
),
|
||||
|
||||
@SerialName("airpods.pro")
|
||||
AIRPODS_PRO(
|
||||
"AirPods Pro",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasNcOneAirpod = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasListeningModeCycle = true,
|
||||
hasAllowOffOption = true,
|
||||
),
|
||||
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
caseIconRes = R.drawable.device_airpods_pro2_case,
|
||||
),
|
||||
|
||||
@SerialName("airpods.pro2")
|
||||
AIRPODS_PRO2(
|
||||
"AirPods Pro 2",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasAdaptiveAnc = true,
|
||||
hasConversationAwareness = true,
|
||||
hasNcOneAirpod = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasVolumeSwipe = true,
|
||||
hasVolumeSwipeLength = true,
|
||||
hasPersonalizedVolume = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasAdaptiveAudioNoise = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasListeningModeCycle = true,
|
||||
hasAllowOffOption = true,
|
||||
hasStemConfig = true,
|
||||
hasSleepDetection = true,
|
||||
needsInitExt = true,
|
||||
),
|
||||
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
caseIconRes = R.drawable.device_airpods_pro2_case,
|
||||
),
|
||||
|
||||
@SerialName("airpods.pro2.usbc")
|
||||
AIRPODS_PRO2_USBC(
|
||||
"AirPods Pro 2 USB-C",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasAdaptiveAnc = true,
|
||||
hasConversationAwareness = true,
|
||||
hasNcOneAirpod = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasVolumeSwipe = true,
|
||||
hasVolumeSwipeLength = true,
|
||||
hasPersonalizedVolume = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasAdaptiveAudioNoise = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasListeningModeCycle = true,
|
||||
hasAllowOffOption = true,
|
||||
hasStemConfig = true,
|
||||
hasSleepDetection = true,
|
||||
needsInitExt = true,
|
||||
),
|
||||
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
caseIconRes = R.drawable.device_airpods_pro2_case,
|
||||
),
|
||||
|
||||
@SerialName("airpods.pro3")
|
||||
AIRPODS_PRO3(
|
||||
"AirPods Pro 3",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasAdaptiveAnc = true,
|
||||
hasConversationAwareness = true,
|
||||
hasNcOneAirpod = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasVolumeSwipe = true,
|
||||
hasVolumeSwipeLength = true,
|
||||
hasPersonalizedVolume = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasAdaptiveAudioNoise = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasListeningModeCycle = true,
|
||||
hasAllowOffOption = true,
|
||||
hasStemConfig = true,
|
||||
hasSleepDetection = true,
|
||||
needsInitExt = true,
|
||||
),
|
||||
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
caseIconRes = R.drawable.device_airpods_pro2_case,
|
||||
),
|
||||
|
||||
@SerialName("airpods.max")
|
||||
AIRPODS_MAX(
|
||||
"AirPods Max",
|
||||
R.drawable.device_airpods_max,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2096"), // headphones
|
||||
),
|
||||
|
||||
@SerialName("airpods.max.usbc")
|
||||
AIRPODS_MAX_USBC(
|
||||
"AirPods Max USB-C",
|
||||
R.drawable.device_airpods_max,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A3184"), // headphones
|
||||
),
|
||||
|
||||
@SerialName("beats.flex")
|
||||
BEATS_FLEX(
|
||||
"Beats Flex",
|
||||
R.drawable.device_beats_earbuds,
|
||||
modelNumbers = setOf("A2295"),
|
||||
),
|
||||
|
||||
@SerialName("beats.solo.3")
|
||||
BEATS_SOLO_3(
|
||||
"Beats Solo 3",
|
||||
R.drawable.device_beats_headphones,
|
||||
modelNumbers = setOf("A1796"), // headphones
|
||||
),
|
||||
|
||||
@SerialName("beats.solo.pro")
|
||||
BEATS_SOLO_PRO(
|
||||
"Beats Solo Pro",
|
||||
R.drawable.device_beats_headphones,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A1881"), // headphones
|
||||
),
|
||||
|
||||
@SerialName("beats.solo.4")
|
||||
BEATS_SOLO_4(
|
||||
"Beats Solo 4",
|
||||
R.drawable.device_beats_headphones,
|
||||
modelNumbers = setOf("A3140"), // headphones
|
||||
),
|
||||
|
||||
@SerialName("beats.solo.buds")
|
||||
BEATS_SOLO_BUDS(
|
||||
"Beats Solo Buds",
|
||||
R.drawable.device_beats_earbuds,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
),
|
||||
modelNumbers = setOf("A3150", "A3151", "A3153"), // L/R earbuds + case
|
||||
),
|
||||
|
||||
@SerialName("beats.studio.3")
|
||||
BEATS_STUDIO_3(
|
||||
"Beats Studio 3",
|
||||
R.drawable.device_beats_studio3,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A1914"), // headphones
|
||||
),
|
||||
|
||||
@SerialName("beats.studio.buds")
|
||||
BEATS_STUDIO_BUDS(
|
||||
"Beats Studio Buds",
|
||||
R.drawable.device_beats_earbuds,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A2512", "A2513", "A2514"), // L/R earbuds + case
|
||||
),
|
||||
|
||||
@SerialName("beats.studio.buds.plus")
|
||||
BEATS_STUDIO_BUDS_PLUS(
|
||||
"Beats Studio Buds+",
|
||||
R.drawable.device_beats_earbuds,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A2871", "A2872", "A2952"), // L/R earbuds + case
|
||||
),
|
||||
|
||||
@SerialName("beats.studio.pro")
|
||||
BEATS_STUDIO_PRO(
|
||||
"Beats Studio Pro",
|
||||
R.drawable.device_beats_headphones,
|
||||
Features(hasAncControl = true),
|
||||
modelNumbers = setOf("A2924"), // headphones
|
||||
),
|
||||
|
||||
@SerialName("beats.x")
|
||||
BEATS_X(
|
||||
"Beats X",
|
||||
R.drawable.device_beats_x,
|
||||
modelNumbers = setOf("A1763"),
|
||||
),
|
||||
|
||||
@SerialName("beats.powerbeats.3")
|
||||
POWERBEATS_3(
|
||||
"Power Beats 3",
|
||||
R.drawable.device_powerbeats_3,
|
||||
modelNumbers = setOf("A1747"),
|
||||
),
|
||||
|
||||
@SerialName("beats.powerbeats.4")
|
||||
POWERBEATS_4(
|
||||
"Power Beats 4",
|
||||
R.drawable.device_powerbeats_4,
|
||||
modelNumbers = setOf("A2015"),
|
||||
),
|
||||
|
||||
@SerialName("beats.powerbeats.pro")
|
||||
POWERBEATS_PRO(
|
||||
"Power Beats Pro",
|
||||
R.drawable.device_powerbeats_pro_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
|
||||
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
|
||||
rightPodIconRes = R.drawable.device_powerbeats_pro_right,
|
||||
caseIconRes = R.drawable.device_powerbeats_pro_case,
|
||||
),
|
||||
|
||||
@SerialName("beats.powerbeats.pro2")
|
||||
POWERBEATS_PRO2(
|
||||
"Power Beats Pro 2",
|
||||
R.drawable.device_powerbeats_pro2_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case
|
||||
leftPodIconRes = R.drawable.device_powerbeats_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_powerbeats_pro2_right,
|
||||
caseIconRes = R.drawable.device_powerbeats_pro2_case,
|
||||
),
|
||||
|
||||
@SerialName("beats.fit.pro")
|
||||
BEATS_FIT_PRO(
|
||||
"Beats Fit Pro",
|
||||
R.drawable.device_beats_fitpro_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
|
||||
leftPodIconRes = R.drawable.device_beats_fitpro_left,
|
||||
rightPodIconRes = R.drawable.device_beats_fitpro_right,
|
||||
caseIconRes = R.drawable.device_beats_fitpro_case,
|
||||
),
|
||||
|
||||
@SerialName("fakes.tws.i99999")
|
||||
FAKE_AIRPODS_GEN1(
|
||||
"AirPods (Gen 1)? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
caseIconRes = R.drawable.device_airpods_gen1_case,
|
||||
),
|
||||
|
||||
@SerialName("fakes.generic.airpods.gen2")
|
||||
FAKE_AIRPODS_GEN2(
|
||||
"AirPods (Gen 2)? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_gen1_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
caseIconRes = R.drawable.device_airpods_gen1_case,
|
||||
),
|
||||
|
||||
@SerialName("fakes.generic.airpods.gen3")
|
||||
FAKE_AIRPODS_GEN3(
|
||||
"AirPods (Gen 3)? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_gen3_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||
caseIconRes = R.drawable.device_airpods_gen3_case,
|
||||
),
|
||||
|
||||
@SerialName("fakes.varunr.airpodspro")
|
||||
FAKE_AIRPODS_PRO(
|
||||
"AirPods Pro? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
caseIconRes = R.drawable.device_airpods_pro2_case,
|
||||
),
|
||||
|
||||
@SerialName("fakes.generic.airpods.pro2")
|
||||
FAKE_AIRPODS_PRO2(
|
||||
"AirPods Pro2? \uD83C\uDFAD",
|
||||
R.drawable.device_airpods_pro2_both,
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
caseIconRes = R.drawable.device_airpods_pro2_case,
|
||||
),
|
||||
|
||||
@SerialName("unknown")
|
||||
UNKNOWN("Unknown"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromModelNumber(modelNumber: String): PodModel? {
|
||||
val normalized = modelNumber.trim().uppercase()
|
||||
if (normalized.isBlank()) return null
|
||||
return entries.firstOrNull { entry ->
|
||||
entry != UNKNOWN && !entry.name.startsWith("FAKE_") && normalized in entry.modelNumbers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Features(
|
||||
// Physical form
|
||||
val hasDualPods: Boolean = false,
|
||||
val hasCase: Boolean = false,
|
||||
val hasEarDetection: Boolean = false,
|
||||
// ANC
|
||||
val hasAncControl: Boolean = false,
|
||||
val hasAdaptiveAnc: Boolean = false,
|
||||
// AAP settings
|
||||
val hasConversationAwareness: Boolean = false,
|
||||
val hasNcOneAirpod: Boolean = false,
|
||||
val hasPressSpeed: Boolean = false,
|
||||
val hasPressHoldDuration: Boolean = false,
|
||||
val hasVolumeSwipe: Boolean = false,
|
||||
val hasVolumeSwipeLength: Boolean = false,
|
||||
val hasPersonalizedVolume: Boolean = false,
|
||||
val hasToneVolume: Boolean = false,
|
||||
val hasEndCallMuteMic: Boolean = false,
|
||||
val hasAdaptiveAudioNoise: Boolean = false,
|
||||
val hasMicrophoneMode: Boolean = false,
|
||||
val hasEarDetectionToggle: Boolean = false,
|
||||
val hasListeningModeCycle: Boolean = false,
|
||||
val hasAllowOffOption: Boolean = false,
|
||||
val hasStemConfig: Boolean = false,
|
||||
val hasSleepDetection: Boolean = false,
|
||||
// Protocol
|
||||
val needsInitExt: Boolean = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package eu.darken.capod.pods.core.apple.aap
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlin.reflect.KClass
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CharacterCodingException
|
||||
import java.nio.charset.CodingErrorAction
|
||||
|
||||
/**
|
||||
* Manages a single AAP L2CAP connection to a device.
|
||||
* Internal — not exposed outside [AapConnectionManager].
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
internal class AapConnection(
|
||||
private val device: BluetoothDevice,
|
||||
private val profile: AapDeviceProfile,
|
||||
private val socketFactory: L2capSocketFactory,
|
||||
private val psm: Int = 0x1001,
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow(AapPodState())
|
||||
val state: StateFlow<AapPodState> = _state.asStateFlow()
|
||||
|
||||
private val _keysReceived = MutableSharedFlow<KeyExchangeResult>(extraBufferCapacity = 1)
|
||||
val keysReceived: SharedFlow<KeyExchangeResult> = _keysReceived.asSharedFlow()
|
||||
|
||||
private val _stemPressEvents = MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var readerJob: Job? = null
|
||||
private val writeMutex = Mutex()
|
||||
private val framer = AapFramer()
|
||||
|
||||
private var pendingAncMode: AapSetting.AncMode.Value? = null
|
||||
private var ancDebounceJob: Job? = null
|
||||
private var connectionScope: CoroutineScope? = null
|
||||
private var lastAncCommandSentAt: Long = 0L
|
||||
private var lastCommandedAncMode: AapSetting.AncMode.Value? = null
|
||||
private var ancResendJob: Job? = null
|
||||
|
||||
/**
|
||||
* Opens the L2CAP socket, sends the handshake, and launches the read loop.
|
||||
* Returns after the handshake is sent — the read loop runs in [scope] independently.
|
||||
*/
|
||||
suspend fun connect(scope: CoroutineScope) = withContext(Dispatchers.IO) {
|
||||
if (_state.value.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
throw IllegalStateException("connect() called in state ${_state.value.connectionState}")
|
||||
}
|
||||
|
||||
connectionScope = scope
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
|
||||
|
||||
try {
|
||||
val sock = socketFactory.createSocket(device, psm)
|
||||
sock.connect()
|
||||
socket = sock
|
||||
log(TAG) { "Connected to ${device.address}" }
|
||||
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING)
|
||||
|
||||
// Send handshake
|
||||
val handshake = profile.encodeHandshake()
|
||||
sock.outputStream.write(handshake)
|
||||
sock.outputStream.flush()
|
||||
log(TAG) { "Handshake sent" }
|
||||
|
||||
// Send notification enable packets — tells device to push battery/settings updates
|
||||
for (packet in profile.encodeNotificationEnable()) {
|
||||
sock.outputStream.write(packet)
|
||||
sock.outputStream.flush()
|
||||
}
|
||||
log(TAG) { "Notification enable sent" }
|
||||
|
||||
// Send InitExt for models that need it (Pro 2/3/USB-C, AP4 ANC)
|
||||
profile.encodeInitExt()?.let { initExt ->
|
||||
sock.outputStream.write(initExt)
|
||||
sock.outputStream.flush()
|
||||
log(TAG) { "InitExt sent" }
|
||||
}
|
||||
|
||||
// Request private keys (IRK + ENC) for BLE encrypted battery
|
||||
profile.encodePrivateKeyRequest()?.let { keyReq ->
|
||||
sock.outputStream.write(keyReq)
|
||||
sock.outputStream.flush()
|
||||
log(TAG) { "Private key request sent" }
|
||||
}
|
||||
|
||||
// Launch read loop in the provided scope — connect() returns immediately
|
||||
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Connection failed: $e" }
|
||||
cleanupSocket()
|
||||
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() = withContext(Dispatchers.IO) {
|
||||
log(TAG) { "Disconnecting" }
|
||||
readerJob?.cancel()
|
||||
readerJob = null
|
||||
ancDebounceJob?.cancel()
|
||||
ancDebounceJob = null
|
||||
ancResendJob?.cancel()
|
||||
ancResendJob = null
|
||||
pendingAncMode = null
|
||||
lastCommandedAncMode = null
|
||||
connectionScope = null
|
||||
cleanupSocket()
|
||||
framer.reset()
|
||||
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
|
||||
}
|
||||
|
||||
suspend fun send(command: AapCommand) {
|
||||
val currentState = _state.value
|
||||
if (currentState.connectionState != AapPodState.ConnectionState.READY) {
|
||||
throw IllegalStateException("Cannot send command in state ${currentState.connectionState}")
|
||||
}
|
||||
|
||||
if (command is AapCommand.SetAncMode) {
|
||||
lastCommandedAncMode = command.mode
|
||||
ancResendJob?.cancel()
|
||||
ancResendJob = null
|
||||
val earDetection = currentState.setting<AapSetting.EarDetection>()
|
||||
if (earDetection != null && !earDetection.isEitherPodInEar) {
|
||||
log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" }
|
||||
pendingAncMode = command.mode
|
||||
_state.value = currentState.copy(pendingAncMode = command.mode)
|
||||
return
|
||||
}
|
||||
pendingAncMode = null
|
||||
// Optimistically update UI — don't wait for device echo
|
||||
val currentAnc = currentState.setting<AapSetting.AncMode>()
|
||||
if (currentAnc != null) {
|
||||
_state.value = currentState
|
||||
.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = command.mode))
|
||||
.copy(pendingAncMode = null, lastMessageAt = Instant.now())
|
||||
} else {
|
||||
_state.value = currentState.copy(pendingAncMode = null)
|
||||
}
|
||||
}
|
||||
|
||||
val preSendDeviceInfo = currentState.deviceInfo
|
||||
applyOptimisticUpdate(currentState, command)
|
||||
try {
|
||||
sendRaw(command)
|
||||
} catch (e: Exception) {
|
||||
// Rollback is scoped to rename — the name is the one user-visible writable field
|
||||
// that has no device echo to correct an incorrect optimistic update, so a failed
|
||||
// send would otherwise leave the UI permanently lying about the device name.
|
||||
if (command is AapCommand.SetDeviceName && preSendDeviceInfo != null) {
|
||||
_state.value = _state.value.copy(deviceInfo = preSendDeviceInfo)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically update state for non-ANC settings so the UI reflects the change immediately.
|
||||
* ANC is handled separately in [send] due to ear-detection gating.
|
||||
* If the setting hasn't been reported by the device yet, skip (UI wouldn't show the toggle).
|
||||
*/
|
||||
private fun applyOptimisticUpdate(baseState: AapPodState, command: AapCommand) {
|
||||
val updated: Pair<KClass<out AapSetting>, AapSetting> = when (command) {
|
||||
is AapCommand.SetAncMode -> return // Handled in send()
|
||||
is AapCommand.SetConversationalAwareness -> {
|
||||
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return
|
||||
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetNcWithOneAirPod -> {
|
||||
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return
|
||||
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetVolumeSwipe -> {
|
||||
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return
|
||||
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetPersonalizedVolume -> {
|
||||
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return
|
||||
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetToneVolume -> {
|
||||
baseState.setting<AapSetting.ToneVolume>() ?: return
|
||||
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
|
||||
}
|
||||
is AapCommand.SetAdaptiveAudioNoise -> {
|
||||
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return
|
||||
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
|
||||
}
|
||||
is AapCommand.SetPressSpeed -> {
|
||||
baseState.setting<AapSetting.PressSpeed>() ?: return
|
||||
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
|
||||
}
|
||||
is AapCommand.SetPressHoldDuration -> {
|
||||
baseState.setting<AapSetting.PressHoldDuration>() ?: return
|
||||
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
|
||||
}
|
||||
is AapCommand.SetVolumeSwipeLength -> {
|
||||
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return
|
||||
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
|
||||
}
|
||||
is AapCommand.SetEndCallMuteMic -> {
|
||||
baseState.setting<AapSetting.EndCallMuteMic>() ?: return
|
||||
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic = command.muteMic, endCall = command.endCall)
|
||||
}
|
||||
is AapCommand.SetMicrophoneMode -> {
|
||||
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode)
|
||||
}
|
||||
is AapCommand.SetEarDetectionEnabled -> {
|
||||
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetListeningModeCycle -> {
|
||||
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask)
|
||||
}
|
||||
is AapCommand.SetAllowOffOption -> {
|
||||
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetStemConfig -> {
|
||||
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask)
|
||||
}
|
||||
is AapCommand.SetSleepDetection -> {
|
||||
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetDeviceName -> {
|
||||
val currentInfo = baseState.deviceInfo ?: return
|
||||
_state.value = baseState
|
||||
.copy(deviceInfo = currentInfo.copy(name = command.name), lastMessageAt = Instant.now())
|
||||
return
|
||||
}
|
||||
}
|
||||
_state.value = baseState.withSetting(updated.first, updated.second).copy(lastMessageAt = Instant.now())
|
||||
}
|
||||
|
||||
private suspend fun sendRaw(command: AapCommand) {
|
||||
val bytes = profile.encodeCommand(command)
|
||||
writeMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val sock = socket ?: throw IOException("Socket is null")
|
||||
sock.outputStream.write(bytes)
|
||||
sock.outputStream.flush()
|
||||
if (command is AapCommand.SetAncMode) lastAncCommandSentAt = System.currentTimeMillis()
|
||||
log(TAG) { "Sent command: $command (${bytes.size} bytes)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readLoop(sock: BluetoothSocket) = withContext(Dispatchers.IO) {
|
||||
val buf = ByteArray(2048)
|
||||
var handshakeResponseReceived = false
|
||||
|
||||
try {
|
||||
while (isActive) {
|
||||
val len = sock.inputStream.read(buf)
|
||||
if (len == -1) {
|
||||
log(TAG) { "Stream closed by remote" }
|
||||
break
|
||||
}
|
||||
|
||||
// L2CAP SEQPACKET: each read() returns exactly one complete message
|
||||
val raw = buf.copyOfRange(0, len)
|
||||
val message = AapMessage.Companion.parse(raw)
|
||||
if (message != null) {
|
||||
processMessage(message)
|
||||
if (!handshakeResponseReceived && message.commandType != 0x0009) {
|
||||
handshakeResponseReceived = true
|
||||
}
|
||||
}
|
||||
|
||||
// Transition to READY after processing first batch of messages
|
||||
if (handshakeResponseReceived && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
|
||||
log(TAG) { "Connection READY" }
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
if (isActive) log(TAG, ERROR) { "Read error: $e" }
|
||||
} finally {
|
||||
ancDebounceJob?.cancel()
|
||||
ancResendJob?.cancel()
|
||||
pendingAncMode = null
|
||||
lastCommandedAncMode = null
|
||||
cleanupSocket()
|
||||
_state.value = _state.value.copy(
|
||||
connectionState = AapPodState.ConnectionState.DISCONNECTED,
|
||||
pendingAncMode = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processMessage(message: AapMessage) {
|
||||
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
|
||||
|
||||
// Issue #173: diagnostic dump of the 0x1D INFORMATION packet so testers with engraved AirPods
|
||||
// can share a debug recording that reveals where (or whether) the engraving message lives.
|
||||
// Runs unconditionally for 0x1D — not gated on decodeDeviceInfo success, since an engraving-
|
||||
// shaped packet may not match the strict production decoder's expectations.
|
||||
if (message.commandType == 0x001D) {
|
||||
val segments = describeDeviceInfoSegments(message.payload)
|
||||
log(TAG, INFO) { "DeviceInfoDump #173: payload=${message.payload.size} bytes, segments=${segments.size}" }
|
||||
segments.forEach { seg ->
|
||||
val label = when (seg.index) {
|
||||
0 -> "name"
|
||||
1 -> "modelNumber"
|
||||
2 -> "manufacturer"
|
||||
3 -> "serialNumber"
|
||||
4 -> "firmwareVersion"
|
||||
else -> "unknown"
|
||||
}
|
||||
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
|
||||
log(TAG, INFO) { "DeviceInfoDump #173: [${seg.index}] off=${seg.offset} len=${seg.length} ($label) $rendered hex=${seg.hex}" }
|
||||
}
|
||||
}
|
||||
|
||||
// Try stem press event (transient — emitted via SharedFlow, not stored in state)
|
||||
profile.decodeStemPress(message)?.let { event ->
|
||||
_stemPressEvents.tryEmit(event)
|
||||
log(TAG) { "Stem press: ${event.pressType} ${event.bud}" }
|
||||
return
|
||||
}
|
||||
|
||||
// Try battery
|
||||
profile.decodeBattery(message)?.let { batteries ->
|
||||
// Filter DISCONNECTED entries (e.g. case reports 0% DISCONNECTED when pods are removed).
|
||||
// Merge with existing state so previously-known values are preserved for absent slots.
|
||||
val valid = batteries.filterValues { it.charging != AapPodState.ChargingState.DISCONNECTED }
|
||||
_state.value = _state.value.copy(
|
||||
batteries = _state.value.batteries + valid,
|
||||
lastMessageAt = Instant.now(),
|
||||
)
|
||||
log(TAG) { "Battery update: ${batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" }
|
||||
return
|
||||
}
|
||||
|
||||
// Try private key response
|
||||
profile.decodePrivateKeyResponse(message)?.let { keys ->
|
||||
log(TAG) { "Private keys received: IRK=${keys.irk != null}, ENC=${keys.encKey != null}" }
|
||||
_state.value = _state.value.copy(lastMessageAt = Instant.now())
|
||||
_keysReceived.tryEmit(keys)
|
||||
return
|
||||
}
|
||||
|
||||
// Try device info
|
||||
profile.decodeDeviceInfo(message)?.let { info ->
|
||||
_state.value = _state.value.copy(deviceInfo = info, lastMessageAt = Instant.now())
|
||||
log(TAG) { "Device info: ${info.name} (${info.modelNumber})" }
|
||||
return
|
||||
}
|
||||
|
||||
// Try setting update (merge into existing state)
|
||||
profile.decodeSetting(message)?.let { (key, value) ->
|
||||
// Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions).
|
||||
// Skip debounce for: first ANC mode (initial setup), echoes after our own command.
|
||||
if (value is AapSetting.AncMode) {
|
||||
val isFirstAncMode = _state.value.setting<AapSetting.AncMode>() == null
|
||||
val sinceLastCommand = System.currentTimeMillis() - lastAncCommandSentAt
|
||||
if (isFirstAncMode || sinceLastCommand <= 3000L) {
|
||||
ancDebounceJob?.cancel()
|
||||
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
|
||||
log(TAG) { "Setting: ${key.simpleName} = $value" }
|
||||
|
||||
// After our command, firmware may cycle through modes before settling.
|
||||
// Schedule a verification: if settled mode != commanded mode, re-send once.
|
||||
lastCommandedAncMode?.let { commanded ->
|
||||
ancResendJob?.cancel()
|
||||
ancResendJob = connectionScope?.launch {
|
||||
delay(1000L)
|
||||
val current = _state.value.setting<AapSetting.AncMode>()?.current
|
||||
if (current != null && current != commanded) {
|
||||
log(TAG) { "ANC mode diverged: commanded=$commanded settled=$current, re-sending" }
|
||||
lastCommandedAncMode = null
|
||||
sendRaw(AapCommand.SetAncMode(commanded))
|
||||
} else {
|
||||
lastCommandedAncMode = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ancDebounceJob?.cancel()
|
||||
ancDebounceJob = connectionScope?.launch {
|
||||
delay(1500L)
|
||||
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
|
||||
log(TAG) { "Setting (debounced): ${key.simpleName} = $value" }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Detect ear detection role swap — clear stale PrimaryPod until 0x0008 refreshes it
|
||||
val clearPrimaryPod = value is AapSetting.EarDetection && run {
|
||||
val prev = _state.value.setting<AapSetting.EarDetection>()
|
||||
prev != null && prev.primaryPod == value.secondaryPod && prev.secondaryPod == value.primaryPod
|
||||
}
|
||||
|
||||
var newState = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
|
||||
if (clearPrimaryPod) {
|
||||
newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class)
|
||||
}
|
||||
_state.value = newState
|
||||
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""}" }
|
||||
|
||||
// Flush queued ANC command when a pod goes in ear
|
||||
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
|
||||
pendingAncMode?.let { mode ->
|
||||
pendingAncMode = null
|
||||
// Optimistic update — show target mode immediately, don't wait for device echo
|
||||
val currentAnc = _state.value.setting<AapSetting.AncMode>()
|
||||
if (currentAnc != null) {
|
||||
_state.value = _state.value
|
||||
.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = mode))
|
||||
.copy(pendingAncMode = null, lastMessageAt = Instant.now())
|
||||
} else {
|
||||
_state.value = _state.value.copy(pendingAncMode = null)
|
||||
}
|
||||
log(TAG) { "Pod in ear, sending queued ANC mode: $mode" }
|
||||
connectionScope?.launch { sendRaw(AapCommand.SetAncMode(mode)) }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log(TAG) { "Unhandled message: cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B" }
|
||||
}
|
||||
|
||||
private fun cleanupSocket() {
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
socket = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("AapConnection")
|
||||
|
||||
/**
|
||||
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for the
|
||||
* issue #173 engraving discovery logging. Not part of the production decode path — the
|
||||
* production parser [eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile]
|
||||
* intentionally stays ASCII-only to preserve existing wire-format assumptions.
|
||||
*
|
||||
* Skips binary header bytes until the first printable ASCII byte (mirroring the production
|
||||
* decoder's start condition — all known captures begin the real data with the device name,
|
||||
* which is always ASCII like "AirPods Pro"), then splits on NUL bytes. Each non-empty chunk
|
||||
* becomes one [DeviceInfoSegment]. UTF-8 decode is attempted strictly; if a chunk contains
|
||||
* invalid UTF-8 (e.g. the post-serials encrypted blob), [DeviceInfoSegment.utf8] is null
|
||||
* and the caller falls back to [DeviceInfoSegment.hex].
|
||||
*
|
||||
* Offsets are relative to the supplied payload (post message-header), matching what the
|
||||
* caller holds in [AapMessage.payload].
|
||||
*/
|
||||
internal fun describeDeviceInfoSegments(payload: ByteArray): List<DeviceInfoSegment> {
|
||||
var start = 0
|
||||
while (start < payload.size) {
|
||||
val b = payload[start].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) break
|
||||
start++
|
||||
}
|
||||
if (start >= payload.size) return emptyList()
|
||||
|
||||
val segments = mutableListOf<DeviceInfoSegment>()
|
||||
var segIndex = 0
|
||||
var i = start
|
||||
while (i < payload.size) {
|
||||
while (i < payload.size && payload[i] == 0x00.toByte()) i++
|
||||
if (i >= payload.size) break
|
||||
|
||||
val segStart = i
|
||||
while (i < payload.size && payload[i] != 0x00.toByte()) i++
|
||||
val segBytes = payload.copyOfRange(segStart, i)
|
||||
|
||||
val utf8: String? = try {
|
||||
Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(segBytes))
|
||||
.toString()
|
||||
} catch (_: CharacterCodingException) {
|
||||
null
|
||||
}
|
||||
val hex = segBytes.joinToString("") { "%02X".format(it) }
|
||||
|
||||
segments.add(
|
||||
DeviceInfoSegment(
|
||||
index = segIndex,
|
||||
offset = segStart,
|
||||
length = segBytes.size,
|
||||
utf8 = utf8,
|
||||
hex = hex,
|
||||
)
|
||||
)
|
||||
segIndex++
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
internal data class DeviceInfoSegment(
|
||||
val index: Int,
|
||||
val offset: Int,
|
||||
val length: Int,
|
||||
val utf8: String?,
|
||||
val hex: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package eu.darken.capod.pods.core.apple.aap
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Singleton managing all AAP L2CAP connections.
|
||||
* Keyed by [BluetoothAddress] (stable across scan gaps and app restarts for bonded devices).
|
||||
* Connections are owned internally — consumers interact via [sendCommand] and observe via [allStates].
|
||||
*/
|
||||
@Singleton
|
||||
class AapConnectionManager @Inject constructor(
|
||||
private val socketFactory: L2capSocketFactory,
|
||||
@AppScope private val scope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private val TAG = logTag("AapConnectionMgr")
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val connections = mutableMapOf<BluetoothAddress, AapConnection>()
|
||||
private val connectionJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||
private val intentionalDisconnects = mutableSetOf<BluetoothAddress>()
|
||||
|
||||
private val _allStates = MutableStateFlow<Map<BluetoothAddress, AapPodState>>(emptyMap())
|
||||
val allStates: StateFlow<Map<BluetoothAddress, AapPodState>> = _allStates.asStateFlow()
|
||||
|
||||
private val _disconnectEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val disconnectEvents: SharedFlow<BluetoothAddress> = _disconnectEvents.asSharedFlow()
|
||||
|
||||
/** Emits when a connection receives private keys (IRK/ENC) from the device. */
|
||||
private val _keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
|
||||
val keysReceived: SharedFlow<Pair<BluetoothAddress, KeyExchangeResult>> = _keysReceived.asSharedFlow()
|
||||
|
||||
/** Emits transient stem press events from any connected device. */
|
||||
private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32)
|
||||
val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _stemPressEvents.asSharedFlow()
|
||||
|
||||
fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] }
|
||||
|
||||
suspend fun connect(
|
||||
address: BluetoothAddress,
|
||||
device: BluetoothDevice,
|
||||
model: PodModel,
|
||||
) = mutex.withLock {
|
||||
if (connections.containsKey(address)) {
|
||||
log(TAG) { "Already connected to $address" }
|
||||
return
|
||||
}
|
||||
|
||||
// Clear stale intentional-disconnect flag from previous connection lifecycle
|
||||
intentionalDisconnects.remove(address)
|
||||
|
||||
val profile = AapDeviceProfile.Companion.forModel(model)
|
||||
val connection = AapConnection(device, profile, socketFactory)
|
||||
connections[address] = connection
|
||||
|
||||
try {
|
||||
connection.connect(scope)
|
||||
} catch (e: Exception) {
|
||||
connections.remove(address)
|
||||
throw e
|
||||
}
|
||||
|
||||
// Collect connection state and propagate live updates to allStates.
|
||||
// The coroutine cancels itself after handling DISCONNECTED,
|
||||
// which also cancels the child key-forwarding coroutine.
|
||||
connectionJobs[address] = scope.launch {
|
||||
// Forward private keys from this connection (child coroutine)
|
||||
launch {
|
||||
connection.keysReceived.collect { keys ->
|
||||
_keysReceived.tryEmit(address to keys)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward stem press events from this connection (child coroutine)
|
||||
launch {
|
||||
connection.stemPressEvents.collect { event ->
|
||||
_stemPressEvents.tryEmit(address to event)
|
||||
}
|
||||
}
|
||||
|
||||
connection.state.collect { podState ->
|
||||
if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "Connection to $address disconnected" }
|
||||
val wasIntentional = mutex.withLock {
|
||||
connections.remove(address)
|
||||
connectionJobs.remove(address)
|
||||
connection.disconnect()
|
||||
_allStates.update { it - address }
|
||||
val intentional = address in intentionalDisconnects
|
||||
intentionalDisconnects.remove(address)
|
||||
intentional
|
||||
}
|
||||
|
||||
if (!wasIntentional) {
|
||||
_disconnectEvents.tryEmit(address)
|
||||
}
|
||||
|
||||
// End this collector coroutine (also cancels child key-forwarding coroutine)
|
||||
cancel()
|
||||
} else {
|
||||
_allStates.update { it + (address to podState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect(address: BluetoothAddress) = mutex.withLock {
|
||||
intentionalDisconnects.add(address)
|
||||
val connection = connections.remove(address) ?: run {
|
||||
intentionalDisconnects.remove(address)
|
||||
return
|
||||
}
|
||||
connectionJobs.remove(address)?.cancel()
|
||||
connection.disconnect()
|
||||
_allStates.update { it - address }
|
||||
}
|
||||
|
||||
suspend fun sendCommand(address: BluetoothAddress, command: AapCommand) {
|
||||
val connection = mutex.withLock { connections[address] }
|
||||
?: throw IllegalStateException("No connection for $address")
|
||||
connection.send(command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package eu.darken.capod.pods.core.apple.aap
|
||||
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Pure data representing the current state of an AAP connection. No connection handle.
|
||||
*/
|
||||
data class AapPodState(
|
||||
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
|
||||
val deviceInfo: AapDeviceInfo? = null,
|
||||
val settings: Map<KClass<out AapSetting>, AapSetting> = emptyMap(),
|
||||
val batteries: Map<BatteryType, Battery> = emptyMap(),
|
||||
val lastMessageAt: Instant? = null,
|
||||
val pendingAncMode: AapSetting.AncMode.Value? = null,
|
||||
) {
|
||||
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
|
||||
|
||||
fun withSetting(key: KClass<out AapSetting>, value: AapSetting): AapPodState =
|
||||
copy(settings = settings + (key to value))
|
||||
|
||||
// Ear detection — from AAP command 0x06
|
||||
val aapEarDetection: AapSetting.EarDetection?
|
||||
get() = setting<AapSetting.EarDetection>()
|
||||
|
||||
// Primary pod identity — from AAP command 0x08
|
||||
val aapPrimaryPod: AapSetting.PrimaryPod?
|
||||
get() = setting<AapSetting.PrimaryPod>()
|
||||
|
||||
val isEitherPodInEar: Boolean?
|
||||
get() = aapEarDetection?.isEitherPodInEar
|
||||
|
||||
// Battery — from AAP command 0x04, 1% granularity
|
||||
val batteryLeft: Float? get() = batteries[BatteryType.LEFT]?.percent
|
||||
val batteryRight: Float? get() = batteries[BatteryType.RIGHT]?.percent
|
||||
val batteryCase: Float? get() = batteries[BatteryType.CASE]?.percent
|
||||
val batteryHeadset: Float? get() = batteries[BatteryType.SINGLE]?.percent
|
||||
|
||||
// Charging state from AAP battery
|
||||
val isLeftCharging: Boolean?
|
||||
get() = batteries[BatteryType.LEFT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
val isRightCharging: Boolean?
|
||||
get() = batteries[BatteryType.RIGHT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
val isCaseCharging: Boolean?
|
||||
get() = batteries[BatteryType.CASE]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
val isHeadsetCharging: Boolean?
|
||||
get() = batteries[BatteryType.SINGLE]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
|
||||
enum class ConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
HANDSHAKING,
|
||||
READY,
|
||||
}
|
||||
|
||||
/** Battery entry from an AAP battery notification (command 0x04). */
|
||||
data class Battery(
|
||||
val type: BatteryType,
|
||||
val percent: Float,
|
||||
val charging: ChargingState,
|
||||
)
|
||||
|
||||
enum class BatteryType(val wireValue: Int) {
|
||||
SINGLE(0x01),
|
||||
RIGHT(0x02),
|
||||
LEFT(0x04),
|
||||
CASE(0x08),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): BatteryType? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChargingState(val wireValue: Int) {
|
||||
// Pro 1 sends 0x00 transiently when a pod is first removed from the case (reverts to NOT_CHARGING within seconds).
|
||||
// Pro 2 USB-C skips this and reports NOT_CHARGING immediately. Likely a firmware timing difference.
|
||||
UNDEFINED(0x00),
|
||||
CHARGING(0x01),
|
||||
NOT_CHARGING(0x02),
|
||||
DISCONNECTED(0x04),
|
||||
// Observed on Pro 3 and Pro 2 USB-C in charging case. Apple's "Optimized Battery Charging" feature.
|
||||
// Not observed on Pro 1 (firmware 51.9.6) — may require newer firmware or hardware.
|
||||
CHARGING_OPTIMIZED(0x05),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): ChargingState = entries.firstOrNull { it.wireValue == value } ?: UNDEFINED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Outbound commands to change device settings. Pure domain — no wire protocol bytes.
|
||||
* The [AapDeviceProfile] encodes these into the device-specific wire format.
|
||||
*/
|
||||
sealed class AapCommand {
|
||||
data class SetAncMode(val mode: AapSetting.AncMode.Value) : AapCommand()
|
||||
data class SetConversationalAwareness(val enabled: Boolean) : AapCommand()
|
||||
data class SetPressSpeed(val value: AapSetting.PressSpeed.Value) : AapCommand()
|
||||
data class SetPressHoldDuration(val value: AapSetting.PressHoldDuration.Value) : AapCommand()
|
||||
data class SetNcWithOneAirPod(val enabled: Boolean) : AapCommand()
|
||||
data class SetToneVolume(val level: Int) : AapCommand()
|
||||
data class SetVolumeSwipeLength(val value: AapSetting.VolumeSwipeLength.Value) : AapCommand()
|
||||
data class SetEndCallMuteMic(val muteMic: AapSetting.EndCallMuteMic.MuteMicMode, val endCall: AapSetting.EndCallMuteMic.EndCallMode) : AapCommand()
|
||||
data class SetVolumeSwipe(val enabled: Boolean) : AapCommand()
|
||||
data class SetPersonalizedVolume(val enabled: Boolean) : AapCommand()
|
||||
data class SetAdaptiveAudioNoise(val level: Int) : AapCommand()
|
||||
data class SetMicrophoneMode(val mode: AapSetting.MicrophoneMode.Mode) : AapCommand()
|
||||
data class SetEarDetectionEnabled(val enabled: Boolean) : AapCommand()
|
||||
data class SetListeningModeCycle(val modeMask: Int) : AapCommand()
|
||||
data class SetAllowOffOption(val enabled: Boolean) : AapCommand()
|
||||
data class SetStemConfig(val claimedPressMask: Int) : AapCommand()
|
||||
data class SetSleepDetection(val enabled: Boolean) : AapCommand()
|
||||
data class SetDeviceName(val name: String) : AapCommand()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Device identity parsed from the AAP handshake response (message type 0x1D).
|
||||
*/
|
||||
data class AapDeviceInfo(
|
||||
val name: String,
|
||||
val modelNumber: String,
|
||||
val manufacturer: String,
|
||||
val serialNumber: String,
|
||||
val firmwareVersion: String,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Per-model mapping between AAP wire format and domain types.
|
||||
* All wire protocol bytes are encapsulated here — sealed classes [AapSetting] and [AapCommand]
|
||||
* contain zero protocol knowledge.
|
||||
*
|
||||
* If Apple changes a setting ID or value encoding for a new model, only the profile changes.
|
||||
*/
|
||||
interface AapDeviceProfile {
|
||||
|
||||
/**
|
||||
* Decode a single message into a setting update.
|
||||
* Returns null if the message is not a recognized setting.
|
||||
* Callers merge the returned pair into existing state (incremental update).
|
||||
*/
|
||||
fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>?
|
||||
|
||||
/**
|
||||
* Decode a battery notification (command 0x04).
|
||||
* Returns null if the message is not a battery update.
|
||||
*/
|
||||
fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>?
|
||||
|
||||
/**
|
||||
* Decode a device info message (typically command type 0x001D).
|
||||
*/
|
||||
fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo?
|
||||
|
||||
/**
|
||||
* Encode a domain command into wire bytes ready to send.
|
||||
* @throws UnsupportedOperationException if the command is not supported by this profile.
|
||||
*/
|
||||
fun encodeCommand(command: AapCommand): ByteArray
|
||||
|
||||
/**
|
||||
* Encode the handshake message that initiates the AAP session.
|
||||
*/
|
||||
fun encodeHandshake(): ByteArray
|
||||
|
||||
/**
|
||||
* Encode notification enable packets. These tell the device to push
|
||||
* battery, settings, and other event notifications.
|
||||
* Sent after the handshake, before the read loop starts.
|
||||
*/
|
||||
fun encodeNotificationEnable(): List<ByteArray>
|
||||
|
||||
/**
|
||||
* Encode the extended init packet (0x4D) for models that require it
|
||||
* (e.g., Pro 2/3/USB-C, AirPods 4 ANC). Returns null if not needed.
|
||||
*/
|
||||
fun encodeInitExt(): ByteArray?
|
||||
|
||||
/**
|
||||
* Encode a private key request (command 0x30).
|
||||
* Returns null if the model doesn't support key exchange.
|
||||
*/
|
||||
fun encodePrivateKeyRequest(): ByteArray?
|
||||
|
||||
/**
|
||||
* Decode a private key response (command 0x31).
|
||||
* Returns null if the message is not a key response.
|
||||
*/
|
||||
fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult?
|
||||
|
||||
/**
|
||||
* Decode a stem press event (command 0x19).
|
||||
* Returns null if the message is not a stem press event.
|
||||
*/
|
||||
fun decodeStemPress(message: AapMessage): StemPressEvent?
|
||||
|
||||
companion object {
|
||||
fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* A parsed AAP protocol message.
|
||||
*/
|
||||
data class AapMessage(
|
||||
val raw: ByteArray,
|
||||
val commandType: Int,
|
||||
val payload: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AapMessage) return false
|
||||
return raw.contentEquals(other.raw)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = raw.contentHashCode()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse a complete AAP message from raw bytes.
|
||||
* AAP messages have the format: [4-byte header] [2-byte command type] [payload...]
|
||||
* Minimum message size is 6 bytes (header + command type with no payload).
|
||||
*/
|
||||
fun parse(raw: ByteArray): AapMessage? {
|
||||
if (raw.size < 6) return null
|
||||
val commandType = (raw[4].toInt() and 0xFF) or ((raw[5].toInt() and 0xFF) shl 8)
|
||||
val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0)
|
||||
return AapMessage(raw = raw.copyOf(), commandType = commandType, payload = payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulates bytes from a stream and emits complete [AapMessage] objects.
|
||||
*
|
||||
* Raw L2CAP reads can return partial or multiple messages in a single read.
|
||||
* The framer buffers partial data and splits multi-message reads.
|
||||
*
|
||||
* AAP message framing: first 4 bytes are header, bytes 2-3 (little-endian)
|
||||
* indicate total message length (excluding the first 4 header bytes).
|
||||
*/
|
||||
class AapFramer {
|
||||
|
||||
private val buffer = mutableListOf<Byte>()
|
||||
|
||||
/**
|
||||
* Feed raw bytes from a socket read. Returns any complete messages found.
|
||||
*/
|
||||
fun consume(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size): List<AapMessage> {
|
||||
for (i in offset until offset + length) {
|
||||
buffer.add(bytes[i])
|
||||
}
|
||||
|
||||
val messages = mutableListOf<AapMessage>()
|
||||
|
||||
while (buffer.size >= 4) {
|
||||
// Bytes 2-3 (little-endian) = payload length after the 4-byte header
|
||||
val payloadLength = (buffer[2].toInt() and 0xFF) or ((buffer[3].toInt() and 0xFF) shl 8)
|
||||
val totalLength = 4 + payloadLength
|
||||
|
||||
if (buffer.size < totalLength) break // Need more data
|
||||
|
||||
val messageBytes = ByteArray(totalLength)
|
||||
for (i in 0 until totalLength) {
|
||||
messageBytes[i] = buffer[i]
|
||||
}
|
||||
buffer.subList(0, totalLength).clear()
|
||||
|
||||
AapMessage.parse(messageBytes)?.let { messages.add(it) }
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
buffer.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Device-reported settings. Pure domain — no wire protocol bytes.
|
||||
* Each subclass represents a capability with its current state and supported values.
|
||||
* The [AapDeviceProfile] handles all wire ↔ domain translation.
|
||||
*/
|
||||
sealed class AapSetting {
|
||||
|
||||
data class AncMode(
|
||||
val current: Value,
|
||||
val supported: List<Value>,
|
||||
) : AapSetting() {
|
||||
enum class Value {
|
||||
OFF, ON, TRANSPARENCY, ADAPTIVE,
|
||||
}
|
||||
}
|
||||
|
||||
data class ConversationalAwareness(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class PressSpeed(
|
||||
val value: Value,
|
||||
) : AapSetting() {
|
||||
enum class Value(val wireValue: Int) {
|
||||
DEFAULT(0x00), SLOWER(0x01), SLOWEST(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PressHoldDuration(
|
||||
val value: Value,
|
||||
) : AapSetting() {
|
||||
enum class Value(val wireValue: Int) {
|
||||
DEFAULT(0x00), SHORTER(0x01), SHORTEST(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NcWithOneAirPod(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class ToneVolume(
|
||||
val level: Int,
|
||||
) : AapSetting()
|
||||
|
||||
data class VolumeSwipeLength(
|
||||
val value: Value,
|
||||
) : AapSetting() {
|
||||
enum class Value(val wireValue: Int) {
|
||||
DEFAULT(0x00), LONGER(0x01), LONGEST(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class EndCallMuteMic(
|
||||
val muteMic: MuteMicMode,
|
||||
val endCall: EndCallMode,
|
||||
) : AapSetting() {
|
||||
enum class MuteMicMode(val wireValue: Int) {
|
||||
SINGLE_PRESS(0x23), DOUBLE_PRESS(0x22);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): MuteMicMode? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
enum class EndCallMode(val wireValue: Int) {
|
||||
DOUBLE_PRESS(0x02), SINGLE_PRESS(0x03);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): EndCallMode? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class VolumeSwipe(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class PersonalizedVolume(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class AdaptiveAudioNoise(
|
||||
val level: Int,
|
||||
) : AapSetting()
|
||||
|
||||
/** Push-only from device — reports speaking detection state (command 0x4B). */
|
||||
data class ConversationalAwarenessState(
|
||||
val speaking: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class MicrophoneMode(
|
||||
val mode: Mode,
|
||||
) : AapSetting() {
|
||||
enum class Mode(val wireValue: Int) {
|
||||
AUTO(0x00), ALWAYS_RIGHT(0x01), ALWAYS_LEFT(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): Mode? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class EarDetectionEnabled(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class ListeningModeCycle(
|
||||
val modeMask: Int,
|
||||
) : AapSetting() {
|
||||
val includesOff: Boolean get() = (modeMask and 0x01) != 0
|
||||
val includesAnc: Boolean get() = (modeMask and 0x02) != 0
|
||||
val includesTransparency: Boolean get() = (modeMask and 0x04) != 0
|
||||
val includesAdaptive: Boolean get() = (modeMask and 0x08) != 0
|
||||
}
|
||||
|
||||
data class AllowOffOption(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class StemConfig(
|
||||
val claimedPressMask: Int,
|
||||
) : AapSetting() {
|
||||
val claimsSinglePress: Boolean get() = (claimedPressMask and 0x01) != 0
|
||||
val claimsDoublePress: Boolean get() = (claimedPressMask and 0x02) != 0
|
||||
val claimsTriplePress: Boolean get() = (claimedPressMask and 0x04) != 0
|
||||
val claimsLongPress: Boolean get() = (claimedPressMask and 0x08) != 0
|
||||
}
|
||||
|
||||
data class SleepDetection(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class InCaseTone(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class ConnectedDevices(
|
||||
val devices: List<ConnectedDevice>,
|
||||
) : AapSetting() {
|
||||
data class ConnectedDevice(val mac: String, val type: Int)
|
||||
}
|
||||
|
||||
data class AudioSource(
|
||||
val sourceMac: String?,
|
||||
val type: AudioSourceType,
|
||||
) : AapSetting() {
|
||||
enum class AudioSourceType { NONE, CALL, MEDIA }
|
||||
}
|
||||
|
||||
data class EqBands(
|
||||
val sets: List<List<Float>>,
|
||||
) : AapSetting()
|
||||
|
||||
/** Per-pod placement reported by the device (command 0x06). */
|
||||
data class EarDetection(
|
||||
val primaryPod: PodPlacement,
|
||||
val secondaryPod: PodPlacement,
|
||||
) : AapSetting() {
|
||||
enum class PodPlacement {
|
||||
IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED,
|
||||
}
|
||||
|
||||
val isEitherPodInEar: Boolean
|
||||
get() = primaryPod == PodPlacement.IN_EAR || secondaryPod == PodPlacement.IN_EAR
|
||||
}
|
||||
|
||||
/** Which physical pod currently holds the microphone (command 0x08). */
|
||||
data class PrimaryPod(
|
||||
val pod: Pod,
|
||||
) : AapSetting() {
|
||||
enum class Pod { LEFT, RIGHT }
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Default AAP device profile covering the known protocol from MagicPodsCore + LibrePods research.
|
||||
* Handles the common wire format used by all Apple/Beats devices over L2CAP PSM 0x1001.
|
||||
*
|
||||
* Model-specific behavior (supported ANC modes, InitExt, feature gating) is driven by
|
||||
* [PodModel.features] — no subclassing needed.
|
||||
*/
|
||||
class DefaultAapDeviceProfile(
|
||||
private val model: PodModel = PodModel.UNKNOWN,
|
||||
) : AapDeviceProfile {
|
||||
|
||||
companion object {
|
||||
// AAP command types (bytes 4-5 of the message, little-endian)
|
||||
const val CMD_SETTINGS = 0x0009
|
||||
const val CMD_BATTERY = 0x0004
|
||||
const val CMD_DEVICE_INFO = 0x001D
|
||||
const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031
|
||||
const val CMD_EAR_DETECTION = 0x0006
|
||||
const val CMD_PRIMARY_POD = 0x0008
|
||||
const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B
|
||||
|
||||
// Setting IDs (first byte of settings command payload)
|
||||
const val SETTING_ANC_MODE = 0x0D
|
||||
const val SETTING_PRESS_SPEED = 0x17
|
||||
const val SETTING_PRESS_HOLD_DURATION = 0x18
|
||||
const val SETTING_NC_ONE_AIRPOD = 0x1B
|
||||
const val SETTING_TONE_VOLUME = 0x1F
|
||||
const val SETTING_VOLUME_SWIPE_LENGTH = 0x23
|
||||
const val SETTING_END_CALL_MUTE_MIC = 0x24
|
||||
const val SETTING_VOLUME_SWIPE = 0x25
|
||||
const val SETTING_PERSONALIZED_VOLUME = 0x26
|
||||
const val SETTING_CONVERSATIONAL_AWARENESS = 0x28
|
||||
const val SETTING_ADAPTIVE_AUDIO_NOISE = 0x2E
|
||||
const val SETTING_MICROPHONE_MODE = 0x01
|
||||
const val SETTING_EAR_DETECTION_ENABLED = 0x0A
|
||||
const val SETTING_LISTENING_MODE_CYCLE = 0x1A
|
||||
// Kept decoded internally (never exposed in UI): the device pushes 0x31 frames and dropping the
|
||||
// decode would fall through to "Unhandled message" logging and prevent lastMessageAt refresh,
|
||||
// which AAP freshness / boost logic in PodDevice.computeAapBoost depends on.
|
||||
// Originally labeled "Charging Sounds" but the real case tones are controlled over ATT, not here;
|
||||
// the actual effect of this setting is unknown, so we don't expose or write it.
|
||||
const val SETTING_IN_CASE_TONE = 0x31
|
||||
const val SETTING_ALLOW_OFF_OPTION = 0x34
|
||||
const val SETTING_SLEEP_DETECTION = 0x35
|
||||
const val SETTING_STEM_CONFIG = 0x39
|
||||
|
||||
// Command types for non-settings messages
|
||||
const val CMD_RENAME = 0x001E
|
||||
const val CMD_STEM_PRESS = 0x0019
|
||||
const val CMD_CONNECTED_DEVICES = 0x002E
|
||||
const val CMD_AUDIO_SOURCE = 0x000E
|
||||
const val CMD_EQ_DATA = 0x0053
|
||||
|
||||
// ANC mode wire values
|
||||
const val ANC_WIRE_OFF = 0x01
|
||||
const val ANC_WIRE_ON = 0x02
|
||||
const val ANC_WIRE_TRANSPARENCY = 0x03
|
||||
const val ANC_WIRE_ADAPTIVE = 0x04
|
||||
}
|
||||
|
||||
private val supportedAncModes: List<AapSetting.AncMode.Value> by lazy {
|
||||
val features = model.features
|
||||
when {
|
||||
!features.hasAncControl -> emptyList()
|
||||
features.hasAdaptiveAnc -> listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE)
|
||||
else -> listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeHandshake(): ByteArray = byteArrayOf(
|
||||
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
|
||||
override fun encodeNotificationEnable(): List<ByteArray> = listOf(
|
||||
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff.toByte(), 0xff.toByte(), 0xef.toByte(), 0xff.toByte()),
|
||||
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte()),
|
||||
)
|
||||
|
||||
override fun encodeInitExt(): ByteArray? {
|
||||
if (!model.features.needsInitExt) return null
|
||||
return byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x4d, 0x00, 0x0e, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
)
|
||||
}
|
||||
|
||||
override fun encodeCommand(command: AapCommand): ByteArray = when (command) {
|
||||
is AapCommand.SetAncMode -> buildSettingsMessage(SETTING_ANC_MODE, encodeAncMode(command.mode))
|
||||
is AapCommand.SetConversationalAwareness -> buildSettingsMessage(SETTING_CONVERSATIONAL_AWARENESS, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetPressSpeed -> buildSettingsMessage(SETTING_PRESS_SPEED, command.value.wireValue)
|
||||
is AapCommand.SetPressHoldDuration -> buildSettingsMessage(SETTING_PRESS_HOLD_DURATION, command.value.wireValue)
|
||||
is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(SETTING_NC_ONE_AIRPOD, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetToneVolume -> buildSettingsMessage(SETTING_TONE_VOLUME, command.level.coerceIn(0x0F, 0x64))
|
||||
is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(SETTING_VOLUME_SWIPE_LENGTH, command.value.wireValue)
|
||||
is AapCommand.SetVolumeSwipe -> buildSettingsMessage(SETTING_VOLUME_SWIPE, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(SETTING_PERSONALIZED_VOLUME, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(SETTING_ADAPTIVE_AUDIO_NOISE, command.level.coerceIn(0, 100))
|
||||
is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall)
|
||||
is AapCommand.SetMicrophoneMode -> buildSettingsMessage(SETTING_MICROPHONE_MODE, command.mode.wireValue)
|
||||
is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(SETTING_EAR_DETECTION_ENABLED, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetListeningModeCycle -> buildSettingsMessage(SETTING_LISTENING_MODE_CYCLE, command.modeMask and 0x0F)
|
||||
is AapCommand.SetAllowOffOption -> buildSettingsMessage(SETTING_ALLOW_OFF_OPTION, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetStemConfig -> buildSettingsMessage(SETTING_STEM_CONFIG, command.claimedPressMask and 0x0F)
|
||||
is AapCommand.SetSleepDetection -> buildSettingsMessage(SETTING_SLEEP_DETECTION, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetDeviceName -> buildRenameMessage(command.name)
|
||||
}
|
||||
|
||||
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
|
||||
// Primary pod identity (push-only, fires on mic/primary swap)
|
||||
if (message.commandType == CMD_PRIMARY_POD) {
|
||||
if (message.payload.size < 4) return null
|
||||
val podId = message.payload[0].toInt() and 0xFF
|
||||
// Validate known fixed bytes: [podId] 00 [00|01] [00|01]
|
||||
// Pro 3: byte[2]=0x01 always. Pro 1: byte[2]=0x00 on initial, 0x01 on swap.
|
||||
if ((message.payload[1].toInt() and 0xFF) != 0x00) return null
|
||||
val byte2 = message.payload[2].toInt() and 0xFF
|
||||
if (byte2 != 0x00 && byte2 != 0x01) return null
|
||||
val pod = when (podId) {
|
||||
0x01 -> AapSetting.PrimaryPod.Pod.LEFT
|
||||
0x02 -> AapSetting.PrimaryPod.Pod.RIGHT
|
||||
else -> return null
|
||||
}
|
||||
return AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(pod)
|
||||
}
|
||||
|
||||
// Ear detection is a separate command type (push-only from device)
|
||||
if (message.commandType == CMD_EAR_DETECTION) {
|
||||
if (message.payload.size < 2) return null
|
||||
return AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||
primaryPod = decodePodPlacement(message.payload[0].toInt() and 0xFF),
|
||||
secondaryPod = decodePodPlacement(message.payload[1].toInt() and 0xFF),
|
||||
)
|
||||
}
|
||||
|
||||
// Connected devices list (push-only from device)
|
||||
if (message.commandType == CMD_CONNECTED_DEVICES) {
|
||||
if (message.payload.size < 3) return null
|
||||
val count = message.payload[2].toInt() and 0xFF
|
||||
val devices = mutableListOf<AapSetting.ConnectedDevices.ConnectedDevice>()
|
||||
var offset = 3
|
||||
for (i in 0 until count) {
|
||||
if (offset + 8 > message.payload.size) break
|
||||
val mac = (0 until 6).map { "%02X".format(message.payload[offset + it]) }.joinToString(":")
|
||||
val type = message.payload[offset + 6].toInt() and 0xFF
|
||||
devices.add(AapSetting.ConnectedDevices.ConnectedDevice(mac, type))
|
||||
offset += 8
|
||||
}
|
||||
return AapSetting.ConnectedDevices::class to AapSetting.ConnectedDevices(devices)
|
||||
}
|
||||
|
||||
// Audio source tracking (push-only from device)
|
||||
if (message.commandType == CMD_AUDIO_SOURCE) {
|
||||
if (message.payload.size < 7) return null
|
||||
val mac = (0 until 6).map { "%02X".format(message.payload[it]) }.joinToString(":")
|
||||
val typeValue = message.payload[6].toInt() and 0xFF
|
||||
val type = when (typeValue) {
|
||||
0x01 -> AapSetting.AudioSource.AudioSourceType.CALL
|
||||
0x02 -> AapSetting.AudioSource.AudioSourceType.MEDIA
|
||||
else -> AapSetting.AudioSource.AudioSourceType.NONE
|
||||
}
|
||||
return AapSetting.AudioSource::class to AapSetting.AudioSource(mac, type)
|
||||
}
|
||||
|
||||
// EQ data (push-only from device)
|
||||
if (message.commandType == CMD_EQ_DATA) {
|
||||
if (message.payload.size < 6 + 128) return null
|
||||
val sets = mutableListOf<List<Float>>()
|
||||
var offset = 6 // skip header
|
||||
for (s in 0 until 4) {
|
||||
val bands = mutableListOf<Float>()
|
||||
for (b in 0 until 8) {
|
||||
val bits = (message.payload[offset].toInt() and 0xFF) or
|
||||
((message.payload[offset + 1].toInt() and 0xFF) shl 8) or
|
||||
((message.payload[offset + 2].toInt() and 0xFF) shl 16) or
|
||||
((message.payload[offset + 3].toInt() and 0xFF) shl 24)
|
||||
bands.add(Float.fromBits(bits))
|
||||
offset += 4
|
||||
}
|
||||
sets.add(bands)
|
||||
}
|
||||
return AapSetting.EqBands::class to AapSetting.EqBands(sets)
|
||||
}
|
||||
|
||||
// Conversation Awareness State is a separate command type (push-only)
|
||||
if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) {
|
||||
if (message.payload.isEmpty()) return null
|
||||
val value = message.payload[0].toInt() and 0xFF
|
||||
val speaking = value == 0x01
|
||||
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
|
||||
}
|
||||
|
||||
if (message.commandType != CMD_SETTINGS) return null
|
||||
if (message.payload.size < 2) return null
|
||||
|
||||
val settingId = message.payload[0].toInt() and 0xFF
|
||||
val value = message.payload[1].toInt() and 0xFF
|
||||
|
||||
return when (settingId) {
|
||||
SETTING_ANC_MODE -> {
|
||||
val mode = decodeAncMode(value) ?: return null
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(current = mode, supported = supportedAncModes)
|
||||
}
|
||||
SETTING_CONVERSATIONAL_AWARENESS -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled)
|
||||
}
|
||||
SETTING_PRESS_SPEED -> {
|
||||
val speed = AapSetting.PressSpeed.Value.fromWire(value) ?: return null
|
||||
AapSetting.PressSpeed::class to AapSetting.PressSpeed(speed)
|
||||
}
|
||||
SETTING_PRESS_HOLD_DURATION -> {
|
||||
val duration = AapSetting.PressHoldDuration.Value.fromWire(value) ?: return null
|
||||
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(duration)
|
||||
}
|
||||
SETTING_NC_ONE_AIRPOD -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled)
|
||||
}
|
||||
SETTING_TONE_VOLUME -> {
|
||||
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = value)
|
||||
}
|
||||
SETTING_VOLUME_SWIPE_LENGTH -> {
|
||||
val length = AapSetting.VolumeSwipeLength.Value.fromWire(value) ?: return null
|
||||
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(length)
|
||||
}
|
||||
SETTING_END_CALL_MUTE_MIC -> {
|
||||
decodeEndCallMuteMic(message.payload)
|
||||
}
|
||||
SETTING_VOLUME_SWIPE -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled)
|
||||
}
|
||||
SETTING_PERSONALIZED_VOLUME -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled)
|
||||
}
|
||||
SETTING_ADAPTIVE_AUDIO_NOISE -> {
|
||||
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = value)
|
||||
}
|
||||
SETTING_MICROPHONE_MODE -> {
|
||||
val mode = AapSetting.MicrophoneMode.Mode.fromWire(value) ?: return null
|
||||
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode)
|
||||
}
|
||||
SETTING_EAR_DETECTION_ENABLED -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled)
|
||||
}
|
||||
SETTING_LISTENING_MODE_CYCLE -> {
|
||||
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = value)
|
||||
}
|
||||
SETTING_ALLOW_OFF_OPTION -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled)
|
||||
}
|
||||
SETTING_STEM_CONFIG -> {
|
||||
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = value)
|
||||
}
|
||||
SETTING_SLEEP_DETECTION -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled)
|
||||
}
|
||||
SETTING_IN_CASE_TONE -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>? {
|
||||
if (message.commandType != CMD_BATTERY) return null
|
||||
val payload = message.payload
|
||||
if (payload.isEmpty()) return null
|
||||
|
||||
val count = payload[0].toInt() and 0xFF
|
||||
if (count == 0) return emptyMap()
|
||||
|
||||
// Each battery entry is 5 bytes starting at offset 1
|
||||
val result = mutableMapOf<AapPodState.BatteryType, AapPodState.Battery>()
|
||||
var offset = 1
|
||||
for (i in 0 until count) {
|
||||
if (offset + 5 > payload.size) break
|
||||
|
||||
val type = AapPodState.BatteryType.fromWire(payload[offset].toInt() and 0xFF) ?: run {
|
||||
offset += 5
|
||||
continue
|
||||
}
|
||||
// payload[offset+1] is unknown/reserved
|
||||
val percent = payload[offset + 2].toInt() and 0xFF
|
||||
val charging = AapPodState.ChargingState.fromWire(payload[offset + 3].toInt() and 0xFF)
|
||||
// payload[offset+4] is unknown/reserved
|
||||
offset += 5
|
||||
|
||||
// Values above 100 are not valid battery percentages.
|
||||
// Known cases: 127 (0x7F) = fake reading after case close, 255 (0xFF) = disconnected.
|
||||
if (percent > 100) continue
|
||||
|
||||
result[type] = AapPodState.Battery(
|
||||
type = type,
|
||||
percent = percent / 100f,
|
||||
charging = charging,
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun encodePrivateKeyRequest(): ByteArray = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x30, 0x00, 0x05, 0x00
|
||||
)
|
||||
|
||||
override fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? {
|
||||
if (message.commandType != CMD_PRIVATE_KEYS_RESPONSE) return null
|
||||
val payload = message.payload
|
||||
if (payload.isEmpty()) return null
|
||||
|
||||
val keyCount = payload[0].toInt() and 0xFF
|
||||
var irk: ByteArray? = null
|
||||
var encKey: ByteArray? = null
|
||||
var offset = 1
|
||||
|
||||
for (i in 0 until keyCount) {
|
||||
// Each entry: keyType(1), unknown(1), keyLength(1), unknown(1), keyData(keyLength)
|
||||
if (offset + 4 > payload.size) break
|
||||
val keyType = payload[offset].toInt() and 0xFF
|
||||
// offset+1 is unknown
|
||||
val keyLength = payload[offset + 2].toInt() and 0xFF
|
||||
// offset+3 is unknown
|
||||
offset += 4
|
||||
|
||||
if (offset + keyLength > payload.size) break
|
||||
val keyData = payload.copyOfRange(offset, offset + keyLength)
|
||||
offset += keyLength
|
||||
|
||||
when (keyType) {
|
||||
0x01 -> if (keyLength == 16) irk = keyData // IRK
|
||||
0x04 -> if (keyLength == 16) encKey = keyData // ENC
|
||||
}
|
||||
}
|
||||
|
||||
return if (irk != null || encKey != null) KeyExchangeResult(irk, encKey) else null
|
||||
}
|
||||
|
||||
override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? {
|
||||
if (message.commandType != CMD_DEVICE_INFO) return null
|
||||
if (message.payload.size < 10) return null
|
||||
|
||||
// Device info payload contains null-terminated ASCII strings
|
||||
// Format: [length prefix] [strings...]
|
||||
val strings = parseNullTerminatedStrings(message.payload)
|
||||
if (strings.size < 4) return null
|
||||
|
||||
return AapDeviceInfo(
|
||||
name = strings.getOrElse(0) { "" },
|
||||
modelNumber = strings.getOrElse(1) { "" },
|
||||
manufacturer = strings.getOrElse(2) { "" },
|
||||
serialNumber = strings.getOrElse(3) { "" },
|
||||
firmwareVersion = strings.getOrElse(4) { "" },
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodePodPlacement(wireValue: Int): AapSetting.EarDetection.PodPlacement = when (wireValue) {
|
||||
0x00 -> AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
0x01 -> AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
0x02 -> AapSetting.EarDetection.PodPlacement.IN_CASE
|
||||
else -> AapSetting.EarDetection.PodPlacement.DISCONNECTED
|
||||
}
|
||||
|
||||
protected fun encodeAncMode(mode: AapSetting.AncMode.Value): Int = when (mode) {
|
||||
AapSetting.AncMode.Value.OFF -> ANC_WIRE_OFF
|
||||
AapSetting.AncMode.Value.ON -> ANC_WIRE_ON
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> ANC_WIRE_TRANSPARENCY
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> ANC_WIRE_ADAPTIVE
|
||||
}
|
||||
|
||||
protected fun decodeAncMode(wireValue: Int): AapSetting.AncMode.Value? = when (wireValue) {
|
||||
ANC_WIRE_OFF -> AapSetting.AncMode.Value.OFF
|
||||
ANC_WIRE_ON -> AapSetting.AncMode.Value.ON
|
||||
ANC_WIRE_TRANSPARENCY -> AapSetting.AncMode.Value.TRANSPARENCY
|
||||
ANC_WIRE_ADAPTIVE -> AapSetting.AncMode.Value.ADAPTIVE
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Standard Apple boolean encoding: 0x01=on, 0x02=off. */
|
||||
private fun encodeAppleBool(enabled: Boolean): Int = if (enabled) 0x01 else 0x02
|
||||
|
||||
/** Decode Apple boolean: 0x01=true, 0x02=false, anything else=null (unknown). */
|
||||
private fun decodeAppleBool(wireValue: Int): Boolean? = when (wireValue) {
|
||||
0x01 -> true
|
||||
0x02 -> false
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun buildSettingsMessage(settingId: Int, value: Int): ByteArray = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x09, 0x00,
|
||||
settingId.toByte(), value.toByte(),
|
||||
0x00, 0x00, 0x00,
|
||||
)
|
||||
|
||||
/** EndCallMuteMic uses a special 2-byte format: [0x24] [0x21] [muteMic] [endCall] [0x00] */
|
||||
private fun buildEndCallMuteMicMessage(muteMic: AapSetting.EndCallMuteMic.MuteMicMode, endCall: AapSetting.EndCallMuteMic.EndCallMode): ByteArray = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x09, 0x00,
|
||||
SETTING_END_CALL_MUTE_MIC.toByte(), 0x21,
|
||||
muteMic.wireValue.toByte(), endCall.wireValue.toByte(),
|
||||
0x00,
|
||||
)
|
||||
|
||||
private fun decodeEndCallMuteMic(payload: ByteArray): Pair<KClass<out AapSetting>, AapSetting>? {
|
||||
if (payload.size < 4) return null
|
||||
val subType = payload[1].toInt() and 0xFF
|
||||
return when (subType) {
|
||||
0x21 -> {
|
||||
// Standard format: byte 2 = muteMic, byte 3 = endCall
|
||||
val muteMic = AapSetting.EndCallMuteMic.MuteMicMode.fromWire(payload[2].toInt() and 0xFF) ?: return null
|
||||
val endCall = AapSetting.EndCallMuteMic.EndCallMode.fromWire(payload[3].toInt() and 0xFF) ?: return null
|
||||
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
|
||||
}
|
||||
0x00, 0x20 -> {
|
||||
// Compact response format: byte 2 is a combined mode value.
|
||||
// 0x00: observed on Pro 1 (fw 51.9.6, 2026-04-02) and Pro 2 USB-C (fw 81.x, 2026-04-02) and Pro 3.
|
||||
// 0x20: observed on Pro 1 (fw 51.9.6, 2026-03-31). Same device can send either subtype across sessions.
|
||||
val combined = payload[2].toInt() and 0xFF
|
||||
val (muteMic, endCall) = when (combined) {
|
||||
0x02 -> AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
|
||||
0x03 -> AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
else -> return null
|
||||
}
|
||||
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun decodeStemPress(message: AapMessage): StemPressEvent? {
|
||||
if (message.commandType != CMD_STEM_PRESS) return null
|
||||
if (message.payload.size < 2) return null
|
||||
val pressType = StemPressEvent.PressType.fromWire(message.payload[0].toInt() and 0xFF) ?: return null
|
||||
val bud = StemPressEvent.Bud.fromWire(message.payload[1].toInt() and 0xFF) ?: return null
|
||||
return StemPressEvent(pressType, bud)
|
||||
}
|
||||
|
||||
private fun buildRenameMessage(name: String): ByteArray {
|
||||
// Uses the opcode 0x1A format from the LibrePods AAP docs + Linux implementation.
|
||||
// Verified end-to-end on AirPods Pro 2 USB-C (firmware 81.2675...): the device accepts
|
||||
// the rename, persists it, and echoes the new name back via the next 0x001D device info
|
||||
// message on reconnect.
|
||||
//
|
||||
// Note: the LibrePods Android code (AACPManager.createRenamePacket) uses a DIFFERENT
|
||||
// format with opcode 0x1E and a trailing NUL, but on-device testing shows the device
|
||||
// silently ignores that variant. The Linux / documented format is the one that works.
|
||||
//
|
||||
// Scope: this only changes the AirPods firmware's self-reported name (what 0x001D
|
||||
// returns). It does NOT update the phone's Bluetooth alias — Android's system Bluetooth
|
||||
// settings read from the bond database, which is separate and would require
|
||||
// BluetoothDevice.setAlias(). That's intentionally out of scope here.
|
||||
val nameBytes = name.toByteArray(Charsets.UTF_8)
|
||||
require(nameBytes.size <= 127) { "Device name too long: ${nameBytes.size} bytes (max 127)" }
|
||||
return byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x1A, 0x00, 0x01,
|
||||
nameBytes.size.toByte(), 0x00,
|
||||
) + nameBytes
|
||||
}
|
||||
|
||||
private fun parseNullTerminatedStrings(data: ByteArray): List<String> {
|
||||
val strings = mutableListOf<String>()
|
||||
var start = 0
|
||||
|
||||
// Find runs of printable ASCII (0x20..0x7E) separated by null bytes.
|
||||
// Header bytes and non-ASCII bytes are skipped.
|
||||
var i = 0
|
||||
while (i < data.size) {
|
||||
val b = data[i].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) {
|
||||
start = i
|
||||
while (i < data.size && data[i] != 0x00.toByte()) i++
|
||||
strings.add(String(data, start, i - start, Charsets.US_ASCII))
|
||||
}
|
||||
i++
|
||||
}
|
||||
return strings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Result of parsing an AAP private key response (command 0x31).
|
||||
* Contains the Identity Resolving Key (IRK) for BLE RPA verification
|
||||
* and the Encryption Key (ENC) for BLE encrypted battery decryption.
|
||||
*/
|
||||
data class KeyExchangeResult(
|
||||
val irk: ByteArray?,
|
||||
val encKey: ByteArray?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is KeyExchangeResult) return false
|
||||
return irk.contentEquals(other.irk) && encKey.contentEquals(other.encKey)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = irk?.contentHashCode() ?: 0
|
||||
result = 31 * result + (encKey?.contentHashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray?.contentEquals(other: ByteArray?): Boolean = when {
|
||||
this == null && other == null -> true
|
||||
this != null && other != null -> this.contentEquals(other)
|
||||
else -> false
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
data class StemPressEvent(
|
||||
val pressType: PressType,
|
||||
val bud: Bud,
|
||||
) {
|
||||
enum class PressType(val wireValue: Int) {
|
||||
SINGLE(0x05), DOUBLE(0x06), TRIPLE(0x07), LONG(0x08);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): PressType? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
enum class Bud(val wireValue: Int) {
|
||||
LEFT(0x01), RIGHT(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): Bud? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
@@ -6,13 +6,15 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.misc.UnknownAppleSnapshotBle
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.RPAChecker
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.profiles.core.currentProfiles
|
||||
@@ -27,7 +29,7 @@ class AppleFactory @Inject constructor(
|
||||
private val proximityPairingDecoder: ProximityPairing.Decoder,
|
||||
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
|
||||
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory>,
|
||||
private val unknownAppleFactory: UnknownAppleDevice.Factory,
|
||||
private val unknownAppleFactory: UnknownAppleSnapshotBle.Factory,
|
||||
private val rpaChecker: RPAChecker,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
@@ -59,7 +61,7 @@ class AppleFactory @Inject constructor(
|
||||
return proximityMessage
|
||||
}
|
||||
|
||||
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
|
||||
suspend fun create(scanResult: BleScanResult): BlePodSnapshot? = lock.withLock {
|
||||
val proximityMessage = getMessage(scanResult) ?: return@withLock null
|
||||
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory
|
||||
|
||||
@@ -98,7 +100,7 @@ class AppleFactory @Inject constructor(
|
||||
meta = ApplePods.AppleMeta(),
|
||||
)
|
||||
profile = profiles
|
||||
.filter { it.model == PodDevice.Model.UNKNOWN || it.model == tempDevice.model }
|
||||
.filter { it.model == PodModel.UNKNOWN || it.model == tempDevice.model }
|
||||
.firstOrNull { it.minimumSignalQuality <= tempDevice.signalQuality }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen1
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen2
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen3
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen4
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen4Anc
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsMax
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsMaxUsbc
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro2
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro2Usbc
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro3
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsFitPro
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsFlex
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsSolo3
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsSolo4
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsSoloBuds
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsSoloPro
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsStudio3
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsStudioBuds
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsStudioBudsPlus
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsStudioPro
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsX
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.PowerBeats3
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.PowerBeats4
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.PowerBeatsPro
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.beats.PowerBeatsPro2
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsGen1
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsGen2
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsGen3
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsPro
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsPro2
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
abstract class AppleFactoryModule {
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsPro3(factory: AirPodsPro3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun powerBeatsPro2(factory: PowerBeatsPro2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsSoloPro(factory: BeatsSoloPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsSolo4(factory: BeatsSolo4.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsSoloBuds(factory: BeatsSoloBuds.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsStudioBuds(factory: BeatsStudioBuds.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsStudioBudsPlus(factory: BeatsStudioBudsPlus.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun beatsStudioPro(factory: BeatsStudioPro.Factory): ApplePodsFactory
|
||||
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory
|
||||
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.math.max
|
||||
|
||||
interface BlePodSnapshot {
|
||||
|
||||
val identifier: Id
|
||||
|
||||
val model: PodModel
|
||||
|
||||
val address: BluetoothAddress
|
||||
get() = scanResult.address
|
||||
|
||||
val seenLastAt: Instant
|
||||
|
||||
val seenFirstAt: Instant
|
||||
|
||||
val seenCounter: Int
|
||||
|
||||
val scanResult: BleScanResult
|
||||
|
||||
val rssi: Int
|
||||
get() = scanResult.rssi
|
||||
|
||||
val reliability: Float
|
||||
|
||||
/**
|
||||
* Pure RSSI quality for display bars — linear map of -100..-30 dBm → 0..1.
|
||||
* Does not include reliability/age. Use for SignalIndicator only, not for
|
||||
* device matching or sorting (that's signalQuality's job).
|
||||
*/
|
||||
val rssiQuality: Float
|
||||
get() = ((rssi + 100) / 70f).coerceIn(0f, 1f)
|
||||
|
||||
/**
|
||||
* Composite quality for profile matching and sorting. Weighted blend of
|
||||
* RSSI strength, detection reliability, and observation age.
|
||||
* Used by AppleFactory (minimumSignalQuality filter) and TroubleShooter
|
||||
* (closest-device selection). Not used for display bars.
|
||||
*/
|
||||
val signalQuality: Float
|
||||
get() {
|
||||
val sqRssi = ((rssi + 100) / 70f).coerceIn(0f, 1f)
|
||||
val sqReliability = max(BASE_CONFIDENCE, reliability)
|
||||
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
|
||||
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
|
||||
return (sqRssi + sqReliability + sqAge) / 2f
|
||||
}
|
||||
|
||||
val rawData: Map<Int, ByteArray>
|
||||
get() = scanResult.manufacturerSpecificData
|
||||
|
||||
val rawDataHex: List<String>
|
||||
get() = rawData.entries.map { entry ->
|
||||
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
val profile: DeviceProfile?
|
||||
}
|
||||
|
||||
val meta: Meta
|
||||
|
||||
fun getLabel(context: Context): String = model.label
|
||||
|
||||
@get:DrawableRes
|
||||
val iconRes: Int
|
||||
get() = model.iconRes
|
||||
|
||||
@JvmInline
|
||||
value class Id(private val id: UUID = UUID.randomUUID())
|
||||
|
||||
companion object {
|
||||
const val BASE_CONFIDENCE = 0.0f
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
|
||||
interface DualPodDevice : PodDevice {
|
||||
interface DualBlePodSnapshot : BlePodSnapshot {
|
||||
|
||||
enum class Pod {
|
||||
LEFT,
|
||||
+3
-8
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
import android.content.Context
|
||||
import android.icu.text.RelativeDateTimeFormatter
|
||||
@@ -18,11 +18,6 @@ fun formatBatteryPercent(context: Context, percent: Float?): String =
|
||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
fun PodDevice.getSignalQuality(context: Context): String {
|
||||
val multiplier = 100 * signalQuality
|
||||
return "${multiplier.roundToInt()}%"
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun getBatteryDrawable(percent: Float?): Int = when {
|
||||
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
||||
@@ -36,7 +31,7 @@ fun getBatteryDrawable(percent: Float?): Int = when {
|
||||
else -> R.drawable.ic_baseline_battery_0_bar_24
|
||||
}
|
||||
|
||||
fun PodDevice.lastSeenFormatted(now: Instant): String {
|
||||
fun BlePodSnapshot.lastSeenFormatted(now: Instant): String {
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(seenLastAt, now)
|
||||
return if (duration > Duration.ofMinutes(1)) {
|
||||
@@ -54,7 +49,7 @@ fun PodDevice.lastSeenFormatted(now: Instant): String {
|
||||
}
|
||||
}
|
||||
|
||||
fun PodDevice.firstSeenFormatted(now: Instant): String {
|
||||
fun BlePodSnapshot.firstSeenFormatted(now: Instant): String {
|
||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||
val duration = Duration.between(seenFirstAt, now)
|
||||
return formatter.format(
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
@@ -6,7 +6,6 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.apple.AppleFactory
|
||||
import eu.darken.capod.pods.core.unknown.UnknownDeviceFactory
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -36,7 +35,7 @@ class PodFactory @Inject constructor(
|
||||
|
||||
data class Result(
|
||||
val scanResult: BleScanResult,
|
||||
val device: PodDevice
|
||||
val device: BlePodSnapshot
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.darken.capod.pods.core.apple.ble
|
||||
|
||||
interface SingleBlePodSnapshot : BlePodSnapshot {
|
||||
|
||||
val batteryHeadsetPercent: Float?
|
||||
}
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
|
||||
interface ApplePods : PodDevice {
|
||||
interface ApplePods : BlePodSnapshot {
|
||||
|
||||
val payload: ProximityPayload
|
||||
|
||||
@@ -59,7 +59,7 @@ interface ApplePods : PodDevice {
|
||||
data class AppleMeta(
|
||||
val isIRKMatch: Boolean = false,
|
||||
override val profile: AppleDeviceProfile? = null,
|
||||
) : PodDevice.Meta
|
||||
) : BlePodSnapshot.Meta
|
||||
|
||||
override val meta: AppleMeta
|
||||
}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.pods.core.apple.history.KnownDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||
|
||||
interface ApplePodsFactory {
|
||||
fun isResponsible(message: ProximityMessage): Boolean
|
||||
+4
-8
@@ -1,17 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.DualPodDevice.Pod
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot.Pod
|
||||
|
||||
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
|
||||
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot, HasEarDetectionDual, HasCase,
|
||||
HasDualMicrophone, HasAppleColor {
|
||||
|
||||
val primaryPod: Pod
|
||||
+3
-4
@@ -1,8 +1,7 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
import android.R
|
||||
import android.content.Context
|
||||
import eu.darken.capod.pods.core.HasPodStyle
|
||||
import eu.darken.capod.R
|
||||
|
||||
interface HasAppleColor : ApplePods, HasPodStyle {
|
||||
|
||||
@@ -29,7 +28,7 @@ interface HasAppleColor : ApplePods, HasPodStyle {
|
||||
|
||||
override fun getLabel(context: Context): String = this.name
|
||||
|
||||
override fun getColor(context: Context): Int = android.R.color.white
|
||||
override fun getColor(context: Context): Int = R.color.white
|
||||
|
||||
override val identifier: String
|
||||
get() = name
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
interface HasChargeDetection {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
interface HasChargeDetectionDual : HasChargeDetection {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
interface HasDualMicrophone {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
interface HasEarDetection {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
interface HasEarDetectionDual : HasEarDetection {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorRes
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package eu.darken.capod.pods.core
|
||||
package eu.darken.capod.pods.core.apple.ble.devices
|
||||
|
||||
import android.content.Context
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user