mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+12
-11
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"mcp__ide__getDiagnostics",
|
"mcp__ide__getDiagnostics",
|
||||||
"WebSearch"
|
"WebSearch"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
},
|
},
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"android-translation@claude-code-cafe": true,
|
"android-translation@claude-code-cafe": true,
|
||||||
"debugbadger@claude-code-cafe": true,
|
"debugbadger@claude-code-cafe": true,
|
||||||
"jvm-tools@claude-code-cafe": true,
|
"jvm-tools@claude-code-cafe": true,
|
||||||
"frontend-design@claude-plugins-official": true,
|
"frontend-design@claude-plugins-official": true,
|
||||||
"kotlin-lsp@claude-plugins-official": true
|
"kotlin-lsp@claude-plugins-official": true,
|
||||||
|
"support-investigator@claude-code-cafe": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-vital:
|
lint-vital:
|
||||||
name: Lint vitals
|
name: Lint vitals
|
||||||
@@ -19,6 +22,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -37,6 +42,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -54,6 +61,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -66,5 +75,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Validate metadata lengths
|
- name: Validate metadata lengths
|
||||||
run: bash fastlane/check_metadata_length.sh
|
run: bash fastlane/check_metadata_length.sh
|
||||||
@@ -8,10 +8,15 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validation:
|
validation:
|
||||||
name: "Validation"
|
name: "Validation"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
|
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-github:
|
release-github:
|
||||||
name: Create GitHub release
|
name: Create GitHub release
|
||||||
@@ -33,6 +36,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
@@ -108,6 +112,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|||||||
@@ -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.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import eu.darken.capod.R
|
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
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.MOCK_NOW
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import eu.darken.capod.common.theming.CapodTheme
|
import eu.darken.capod.common.theming.CapodTheme
|
||||||
@@ -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.overview.OverviewViewModel
|
||||||
import eu.darken.capod.main.ui.settings.SettingsScreen
|
import eu.darken.capod.main.ui.settings.SettingsScreen
|
||||||
import eu.darken.capod.main.ui.settings.SettingsViewModel
|
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.DeviceManagerScreen
|
||||||
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
||||||
import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationScreen
|
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.core.autoconnect.AutoConnectCondition
|
||||||
import eu.darken.capod.reaction.ui.ReactionSettingsScreen
|
import eu.darken.capod.reaction.ui.ReactionSettingsScreen
|
||||||
import eu.darken.capod.reaction.ui.ReactionSettingsViewModel
|
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"
|
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||||
|
|
||||||
@@ -47,15 +46,15 @@ internal fun DashboardContent() = PreviewWrapper {
|
|||||||
now = MOCK_NOW,
|
now = MOCK_NOW,
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = listOf(
|
devices = listOf(
|
||||||
MockPodDataProvider.airPodsProMixed(),
|
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||||
MockPodDataProvider.airPodsMax(),
|
MockPodDataProvider.singlePodMonitored(),
|
||||||
MockPodDataProvider.unknownDevice(),
|
MockPodDataProvider.unknownMonitored(),
|
||||||
),
|
),
|
||||||
isDebugMode = false,
|
isDebugMode = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(
|
profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||||
),
|
),
|
||||||
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
|
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
|
||||||
showUnmatchedDevices = false,
|
showUnmatchedDevices = false,
|
||||||
@@ -73,9 +72,9 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
|||||||
DeviceManagerScreen(
|
DeviceManagerScreen(
|
||||||
state = DeviceManagerViewModel.State(
|
state = DeviceManagerViewModel.State(
|
||||||
profiles = listOf(
|
profiles = listOf(
|
||||||
MockPodDataProvider.profile("Work AirPods", PodDevice.Model.AIRPODS_PRO2),
|
MockPodDataProvider.profile("Work AirPods", PodModel.AIRPODS_PRO2),
|
||||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||||
MockPodDataProvider.profile("Gym Beats", PodDevice.Model.POWERBEATS_PRO),
|
MockPodDataProvider.profile("Gym Beats", PodModel.POWERBEATS_PRO),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
@@ -92,11 +91,11 @@ internal fun AddProfileContent() = PreviewWrapper {
|
|||||||
name = "",
|
name = "",
|
||||||
nameError = null,
|
nameError = null,
|
||||||
selectedModel = null,
|
selectedModel = null,
|
||||||
availableModels = PodDevice.Model.entries.filter { it != PodDevice.Model.UNKNOWN },
|
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||||
identityKey = null,
|
identityKey = null,
|
||||||
encryptionKey = null,
|
encryptionKey = null,
|
||||||
selectedDevice = null,
|
selectedDevice = null,
|
||||||
bondedDevices = emptyList(),
|
bondedDeviceItems = emptyList(),
|
||||||
minimumSignalQuality = 0.15f,
|
minimumSignalQuality = 0.15f,
|
||||||
canSave = false,
|
canSave = false,
|
||||||
),
|
),
|
||||||
@@ -158,8 +157,8 @@ internal fun ReactionSettingsContent() = PreviewWrapper {
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun WidgetConfigurationContent() = PreviewWrapper {
|
internal fun WidgetConfigurationContent() = PreviewWrapper {
|
||||||
val profiles = listOf(
|
val profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||||
)
|
)
|
||||||
WidgetConfigurationScreen(
|
WidgetConfigurationScreen(
|
||||||
state = WidgetConfigurationViewModel.State(
|
state = WidgetConfigurationViewModel.State(
|
||||||
@@ -201,7 +200,7 @@ internal fun CasePopUpContent() {
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
PopUpCard(
|
PopUpCard(
|
||||||
device = MockPodDataProvider.airPodsProMixed(),
|
device = MockPodDataProvider.dualPodMonitoredMixed(),
|
||||||
onClose = {},
|
onClose = {},
|
||||||
modifier = Modifier.padding(horizontal = 32.dp),
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.systemBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Favorite
|
||||||
import androidx.compose.material.icons.twotone.Palette
|
import androidx.compose.material.icons.twotone.Palette
|
||||||
import androidx.compose.material.icons.twotone.PlayCircle
|
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.material.icons.twotone.Widgets
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -36,6 +35,7 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -111,24 +112,35 @@ fun UpgradeScreen(
|
|||||||
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
|
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
|
||||||
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
|
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
|
||||||
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
|
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),
|
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
|
||||||
)
|
)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateUp) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Box(modifier = Modifier
|
|
||||||
.windowInsetsPadding(WindowInsets.systemBars)
|
|
||||||
.padding(paddingValues)
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(paddingValues)
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Surface(
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
@@ -238,18 +258,6 @@ fun UpgradeScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,11 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.systemBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Palette
|
||||||
import androidx.compose.material.icons.twotone.PlayCircle
|
import androidx.compose.material.icons.twotone.PlayCircle
|
||||||
import androidx.compose.material.icons.twotone.Stars
|
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.material.icons.twotone.Widgets
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -37,8 +36,10 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -125,17 +126,33 @@ fun UpgradeScreen(
|
|||||||
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
|
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
|
||||||
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
|
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
|
||||||
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
|
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),
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(paddingValues)
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Surface(
|
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))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
if (state == null) {
|
if (state == null) {
|
||||||
@@ -231,17 +256,6 @@ fun UpgradeScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
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.flow.throttleLatest
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||||
import eu.darken.capod.monitor.core.PodMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
|
||||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -27,7 +28,7 @@ import javax.inject.Inject
|
|||||||
open class App : Application() {
|
open class App : Application() {
|
||||||
|
|
||||||
@Inject lateinit var autoReporting: AutomaticBugReporter
|
@Inject lateinit var autoReporting: AutomaticBugReporter
|
||||||
@Inject lateinit var podMonitor: PodMonitor
|
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||||
@Inject lateinit var widgetManager: WidgetManager
|
@Inject lateinit var widgetManager: WidgetManager
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||||
@Inject @AppScope lateinit var appScope: CoroutineScope
|
@Inject @AppScope lateinit var appScope: CoroutineScope
|
||||||
@@ -42,7 +43,7 @@ open class App : Application() {
|
|||||||
|
|
||||||
appScope.launch { widgetManager.refreshWidgets() }
|
appScope.launch { widgetManager.refreshWidgets() }
|
||||||
|
|
||||||
podMonitor.devicesWithProfiles()
|
deviceMonitor.devicesWithProfiles()
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.throttleLatest(1000)
|
.throttleLatest(1000)
|
||||||
.onEach {
|
.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" }
|
log(TAG) { "Sending up+down KeyEvent: $keyCode" }
|
||||||
val eventTime = SystemClock.uptimeMillis()
|
val eventTime = SystemClock.uptimeMillis()
|
||||||
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
|
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))
|
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 {
|
companion object {
|
||||||
private val TAG = logTag("MediaControl")
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -304,6 +304,9 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
emit(wrappedDevices)
|
emit(wrappedDevices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var _isNudgeAvailable: Boolean = true
|
||||||
|
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
||||||
|
|
||||||
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
||||||
try {
|
try {
|
||||||
log(TAG) { "Nudging Android connection to $device" }
|
log(TAG) { "Nudging Android connection to $device" }
|
||||||
@@ -317,6 +320,12 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
log(TAG) { "Nudged connection to $device" }
|
log(TAG) { "Nudged connection to $device" }
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} 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)
|
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||||
false
|
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.R
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.pods.core.DualPodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.HasCase
|
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||||
import eu.darken.capod.pods.core.HasChargeDetection
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.SinglePodDevice
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||||
import eu.darken.capod.pods.core.unknown.UnknownDevice
|
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.AppleDeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfile
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -35,8 +42,8 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
// --- Dual pod scenarios ---
|
// --- Dual pod scenarios ---
|
||||||
|
|
||||||
fun airPodsProFullCharge(): DualPodDevice = MockDualPodDevice(
|
fun airPodsProFullCharge(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||||
_model = PodDevice.Model.AIRPODS_PRO2,
|
_model = PodModel.AIRPODS_PRO2,
|
||||||
_label = "Work AirPods",
|
_label = "Work AirPods",
|
||||||
batteryLeftPodPercent = 1.0f,
|
batteryLeftPodPercent = 1.0f,
|
||||||
batteryRightPodPercent = 1.0f,
|
batteryRightPodPercent = 1.0f,
|
||||||
@@ -44,10 +51,11 @@ object MockPodDataProvider {
|
|||||||
leftPodIcon = R.drawable.device_airpods_pro2_left,
|
leftPodIcon = R.drawable.device_airpods_pro2_left,
|
||||||
rightPodIcon = R.drawable.device_airpods_pro2_right,
|
rightPodIcon = R.drawable.device_airpods_pro2_right,
|
||||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||||
|
_address = "AA:BB:CC:DD:EE:FF",
|
||||||
)
|
)
|
||||||
|
|
||||||
fun airPodsProMixed(): DualPodDevice = MockDualPodDevice(
|
fun airPodsProMixed(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||||
_model = PodDevice.Model.AIRPODS_PRO2,
|
_model = PodModel.AIRPODS_PRO2,
|
||||||
_label = "My AirPods Pro",
|
_label = "My AirPods Pro",
|
||||||
batteryLeftPodPercent = 0.80f,
|
batteryLeftPodPercent = 0.80f,
|
||||||
batteryRightPodPercent = 0.45f,
|
batteryRightPodPercent = 0.45f,
|
||||||
@@ -58,8 +66,8 @@ object MockPodDataProvider {
|
|||||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun airPodsProLowBattery(): DualPodDevice = MockDualPodDevice(
|
fun airPodsProLowBattery(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||||
_model = PodDevice.Model.AIRPODS_PRO2,
|
_model = PodModel.AIRPODS_PRO2,
|
||||||
_label = "My AirPods Pro",
|
_label = "My AirPods Pro",
|
||||||
batteryLeftPodPercent = 0.10f,
|
batteryLeftPodPercent = 0.10f,
|
||||||
batteryRightPodPercent = 0.05f,
|
batteryRightPodPercent = 0.05f,
|
||||||
@@ -71,8 +79,8 @@ object MockPodDataProvider {
|
|||||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun airPodsProInCase(): DualPodDevice = MockDualPodDevice(
|
fun airPodsProInCase(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||||
_model = PodDevice.Model.AIRPODS_PRO2,
|
_model = PodModel.AIRPODS_PRO2,
|
||||||
_label = "My AirPods Pro",
|
_label = "My AirPods Pro",
|
||||||
batteryLeftPodPercent = null,
|
batteryLeftPodPercent = null,
|
||||||
batteryRightPodPercent = null,
|
batteryRightPodPercent = null,
|
||||||
@@ -82,8 +90,8 @@ object MockPodDataProvider {
|
|||||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun airPodsGen1Wearing(): DualPodDevice = MockDualPodDevice(
|
fun airPodsGen1Wearing(): DualBlePodSnapshot = MockDualBlePodSnapshot(
|
||||||
_model = PodDevice.Model.AIRPODS_GEN1,
|
_model = PodModel.AIRPODS_GEN1,
|
||||||
_label = "Old AirPods",
|
_label = "Old AirPods",
|
||||||
batteryLeftPodPercent = 0.70f,
|
batteryLeftPodPercent = 0.70f,
|
||||||
batteryRightPodPercent = 0.65f,
|
batteryRightPodPercent = 0.65f,
|
||||||
@@ -96,8 +104,8 @@ object MockPodDataProvider {
|
|||||||
_caseIcon = R.drawable.device_airpods_gen1_case,
|
_caseIcon = R.drawable.device_airpods_gen1_case,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun powerBeatsPro(): DualPodDevice = MockDualPodDeviceNoCase(
|
fun powerBeatsPro(): DualBlePodSnapshot = MockDualBlePodSnapshotNoCase(
|
||||||
_model = PodDevice.Model.POWERBEATS_PRO,
|
_model = PodModel.POWERBEATS_PRO,
|
||||||
_label = "PowerBeats Pro",
|
_label = "PowerBeats Pro",
|
||||||
batteryLeftPodPercent = 0.55f,
|
batteryLeftPodPercent = 0.55f,
|
||||||
batteryRightPodPercent = 0.60f,
|
batteryRightPodPercent = 0.60f,
|
||||||
@@ -107,39 +115,159 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
// --- Single pod scenarios ---
|
// --- Single pod scenarios ---
|
||||||
|
|
||||||
fun airPodsMax(): SinglePodDevice = MockSinglePodDevice(
|
fun airPodsMax(): SingleBlePodSnapshot = MockSingleBlePodSnapshot(
|
||||||
_model = PodDevice.Model.AIRPODS_MAX,
|
_model = PodModel.AIRPODS_MAX,
|
||||||
_label = "AirPods Max",
|
_label = "AirPods Max",
|
||||||
batteryHeadsetPercent = 0.85f,
|
batteryHeadsetPercent = 0.85f,
|
||||||
_isBeingWorn = true,
|
_isBeingWorn = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun airPodsMaxCharging(): SinglePodDevice = MockSinglePodDevice(
|
fun airPodsMaxCharging(): SingleBlePodSnapshot = MockSingleAppleBlePodSnapshot(
|
||||||
_model = PodDevice.Model.AIRPODS_MAX,
|
_model = PodModel.AIRPODS_MAX,
|
||||||
_label = "AirPods Max",
|
_label = "AirPods Max",
|
||||||
batteryHeadsetPercent = 0.40f,
|
batteryHeadsetPercent = 0.40f,
|
||||||
_isHeadsetBeingCharged = true,
|
_isHeadsetBeingCharged = true,
|
||||||
|
_isIRKMatch = true,
|
||||||
|
_hasPrivatePayload = true,
|
||||||
|
_address = "11:22:33:44:55:66",
|
||||||
)
|
)
|
||||||
|
|
||||||
fun beatsSolo3(): SinglePodDevice = MockSinglePodDevice(
|
fun beatsSolo3(): SingleBlePodSnapshot = MockSingleBlePodSnapshot(
|
||||||
_model = PodDevice.Model.BEATS_SOLO_3,
|
_model = PodModel.BEATS_SOLO_3,
|
||||||
_label = "Beats Solo 3",
|
_label = "Beats Solo 3",
|
||||||
batteryHeadsetPercent = 0.70f,
|
batteryHeadsetPercent = 0.70f,
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Unknown device ---
|
// --- Unknown device ---
|
||||||
|
|
||||||
fun unknownDevice(): PodDevice = UnknownDevice(
|
fun unknownDevice(): BlePodSnapshot = UnknownSnapshotBle(
|
||||||
scanResult = dummyScanResult(rssi = -70),
|
scanResult = dummyScanResult(rssi = -70),
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Profile helpers ---
|
// --- Profile helpers ---
|
||||||
|
|
||||||
fun profile(label: String, model: PodDevice.Model): AppleDeviceProfile = AppleDeviceProfile(
|
fun profile(label: String, model: PodModel): AppleDeviceProfile = AppleDeviceProfile(
|
||||||
label = label,
|
label = label,
|
||||||
model = model,
|
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 ---
|
// --- UpgradeInfo ---
|
||||||
|
|
||||||
fun fossInfo(isPro: Boolean = false): UpgradeRepo.Info = MockUpgradeInfo(
|
fun fossInfo(isPro: Boolean = false): UpgradeRepo.Info = MockUpgradeInfo(
|
||||||
@@ -160,8 +288,8 @@ private data class MockUpgradeInfo(
|
|||||||
override val error: Throwable? = null,
|
override val error: Throwable? = null,
|
||||||
) : UpgradeRepo.Info
|
) : UpgradeRepo.Info
|
||||||
|
|
||||||
private class MockDualPodDevice(
|
private class MockDualBlePodSnapshot(
|
||||||
private val _model: PodDevice.Model,
|
private val _model: PodModel,
|
||||||
private val _label: String,
|
private val _label: String,
|
||||||
override val batteryLeftPodPercent: Float?,
|
override val batteryLeftPodPercent: Float?,
|
||||||
override val batteryRightPodPercent: Float?,
|
override val batteryRightPodPercent: Float?,
|
||||||
@@ -176,10 +304,11 @@ private class MockDualPodDevice(
|
|||||||
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
|
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
|
||||||
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
|
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
|
||||||
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
|
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
|
||||||
|
private val _address: String? = null,
|
||||||
rssi: Int = -50,
|
rssi: Int = -50,
|
||||||
) : DualPodDevice, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
|
) : DualBlePodSnapshot, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
|
||||||
override val identifier: PodDevice.Id = PodDevice.Id()
|
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
|
||||||
override val model: PodDevice.Model = _model
|
override val model: PodModel = _model
|
||||||
override val seenLastAt: Instant = MOCK_NOW
|
override val seenLastAt: Instant = MOCK_NOW
|
||||||
override val seenFirstAt: Instant = MOCK_NOW
|
override val seenFirstAt: Instant = MOCK_NOW
|
||||||
override val seenCounter: Int = 5
|
override val seenCounter: Int = 5
|
||||||
@@ -187,8 +316,8 @@ private class MockDualPodDevice(
|
|||||||
override val reliability: Float = 1.0f
|
override val reliability: Float = 1.0f
|
||||||
override val signalQuality: Float = 0.75f
|
override val signalQuality: Float = 0.75f
|
||||||
override val iconRes: Int = _model.iconRes
|
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)
|
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model, address = _address)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLabel(context: Context): String = _model.label
|
override fun getLabel(context: Context): String = _model.label
|
||||||
@@ -211,17 +340,17 @@ private class MockDualPodDevice(
|
|||||||
override val isRightPodMicrophone: Boolean = _isRightPodMicrophone
|
override val isRightPodMicrophone: Boolean = _isRightPodMicrophone
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MockDualPodDeviceNoCase(
|
private class MockDualBlePodSnapshotNoCase(
|
||||||
private val _model: PodDevice.Model,
|
private val _model: PodModel,
|
||||||
private val _label: String,
|
private val _label: String,
|
||||||
override val batteryLeftPodPercent: Float?,
|
override val batteryLeftPodPercent: Float?,
|
||||||
override val batteryRightPodPercent: Float?,
|
override val batteryRightPodPercent: Float?,
|
||||||
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
|
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
|
||||||
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
|
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
|
||||||
rssi: Int = -50,
|
rssi: Int = -50,
|
||||||
) : DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual {
|
) : DualBlePodSnapshot, HasChargeDetectionDual, HasEarDetectionDual {
|
||||||
override val identifier: PodDevice.Id = PodDevice.Id()
|
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
|
||||||
override val model: PodDevice.Model = _model
|
override val model: PodModel = _model
|
||||||
override val seenLastAt: Instant = MOCK_NOW
|
override val seenLastAt: Instant = MOCK_NOW
|
||||||
override val seenFirstAt: Instant = MOCK_NOW
|
override val seenFirstAt: Instant = MOCK_NOW
|
||||||
override val seenCounter: Int = 5
|
override val seenCounter: Int = 5
|
||||||
@@ -229,7 +358,7 @@ private class MockDualPodDeviceNoCase(
|
|||||||
override val reliability: Float = 1.0f
|
override val reliability: Float = 1.0f
|
||||||
override val signalQuality: Float = 0.70f
|
override val signalQuality: Float = 0.70f
|
||||||
override val iconRes: Int = _model.iconRes
|
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)
|
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,16 +373,16 @@ private class MockDualPodDeviceNoCase(
|
|||||||
override val isRightPodInEar: Boolean = false
|
override val isRightPodInEar: Boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MockSinglePodDevice(
|
private class MockSingleBlePodSnapshot(
|
||||||
private val _model: PodDevice.Model,
|
private val _model: PodModel,
|
||||||
private val _label: String,
|
private val _label: String,
|
||||||
override val batteryHeadsetPercent: Float?,
|
override val batteryHeadsetPercent: Float?,
|
||||||
private val _isHeadsetBeingCharged: Boolean = false,
|
private val _isHeadsetBeingCharged: Boolean = false,
|
||||||
private val _isBeingWorn: Boolean = false,
|
private val _isBeingWorn: Boolean = false,
|
||||||
rssi: Int = -50,
|
rssi: Int = -50,
|
||||||
) : SinglePodDevice, HasChargeDetection, HasEarDetection {
|
) : SingleBlePodSnapshot, HasChargeDetection, HasEarDetection {
|
||||||
override val identifier: PodDevice.Id = PodDevice.Id()
|
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
|
||||||
override val model: PodDevice.Model = _model
|
override val model: PodModel = _model
|
||||||
override val seenLastAt: Instant = MOCK_NOW
|
override val seenLastAt: Instant = MOCK_NOW
|
||||||
override val seenFirstAt: Instant = MOCK_NOW
|
override val seenFirstAt: Instant = MOCK_NOW
|
||||||
override val seenCounter: Int = 5
|
override val seenCounter: Int = 5
|
||||||
@@ -261,7 +390,7 @@ private class MockSinglePodDevice(
|
|||||||
override val reliability: Float = 1.0f
|
override val reliability: Float = 1.0f
|
||||||
override val signalQuality: Float = 0.80f
|
override val signalQuality: Float = 0.80f
|
||||||
override val iconRes: Int = _model.iconRes
|
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)
|
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,3 +402,102 @@ private class MockSinglePodDevice(
|
|||||||
// HasEarDetection
|
// HasEarDetection
|
||||||
override val isBeingWorn: Boolean = _isBeingWorn
|
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
|
@Singleton
|
||||||
fun audioManager(context: Context): AudioManager =
|
fun audioManager(context: Context): AudioManager =
|
||||||
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ object Nav {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Upgrade : Main
|
data object Upgrade : Main
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeviceSettings(val address: String) : Main
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object StemActionConfig : Main
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface Settings : NavigationDestination {
|
sealed interface Settings : NavigationDestination {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package eu.darken.capod.common.settings
|
package eu.darken.capod.common.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -26,6 +29,7 @@ fun SettingsSliderItem(
|
|||||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||||
steps: Int = 0,
|
steps: Int = 0,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
onValueChangeFinished: (() -> Unit)? = null,
|
||||||
valueLabel: ((Float) -> String)? = null,
|
valueLabel: ((Float) -> String)? = null,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
@@ -35,28 +39,33 @@ fun SettingsSliderItem(
|
|||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
enabled = enabled,
|
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(
|
Row(
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
valueRange = valueRange,
|
|
||||||
steps = steps,
|
|
||||||
enabled = enabled,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 56.dp)
|
.padding(horizontal = 56.dp)
|
||||||
.padding(bottom = 8.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.ThemeColor
|
||||||
import eu.darken.capod.common.theming.ThemeMode
|
import eu.darken.capod.common.theming.ThemeMode
|
||||||
import eu.darken.capod.common.theming.ThemeStyle
|
import eu.darken.capod.common.theming.ThemeStyle
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
|
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
||||||
import kotlinx.serialization.builtins.nullable
|
import kotlinx.serialization.builtins.nullable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -54,7 +54,8 @@ class GeneralSettings @Inject constructor(
|
|||||||
writer = { value -> value },
|
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?>(
|
val oldMainDeviceIdentityKey = dataStore.createValue<IdentityResolvingKey?>(
|
||||||
key = "core.maindevice.identitykey",
|
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,144 @@
|
|||||||
|
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.coroutine.DispatcherProvider
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
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.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,
|
||||||
|
) : 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val state = targetAddress.flatMapLatest { address ->
|
||||||
|
if (address == null) return@flatMapLatest flowOf(State(device = null))
|
||||||
|
combine(updateTicker, deviceMonitor.devices, upgradeRepo.upgradeInfo) { _, devices, upgrade ->
|
||||||
|
State(
|
||||||
|
device = devices.firstOrNull { it.address == address },
|
||||||
|
now = Instant.now(),
|
||||||
|
isPro = upgrade.isPro,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.asLiveState()
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val device: PodDevice?,
|
||||||
|
val now: Instant = Instant.now(),
|
||||||
|
val isPro: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun send(command: AapCommand) {
|
||||||
|
val address = targetAddress.value ?: return
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
aapManager.sendCommand(address, command)
|
||||||
|
log(TAG) { "Sent $command to $address" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG) { "Failed to send $command: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendProGated(command: AapCommand) = launch {
|
||||||
|
if (upgradeRepo.isPro()) {
|
||||||
|
val address = targetAddress.value ?: return@launch
|
||||||
|
try {
|
||||||
|
aapManager.sendCommand(address, command)
|
||||||
|
log(TAG) { "Sent $command to $address" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG) { "Failed to send $command: ${e.message}" }
|
||||||
|
}
|
||||||
|
} 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 setInCaseTone(enabled: Boolean) = sendProGated(AapCommand.SetInCaseTone(enabled))
|
||||||
|
|
||||||
|
fun setDeviceName(name: String) = send(AapCommand.SetDeviceName(name))
|
||||||
|
|
||||||
|
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.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -24,10 +27,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
@@ -37,16 +36,17 @@ import androidx.core.net.toUri
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
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.error.ErrorEventHandler
|
||||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||||
import eu.darken.capod.common.permissions.Permission
|
import eu.darken.capod.common.permissions.Permission
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
|
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
|
||||||
|
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
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.MonitoringActiveCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
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.SinglePodsCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||||
import eu.darken.capod.pods.core.DualPodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.SinglePodDevice
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -129,6 +129,8 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
|||||||
onSettings = { vm.goToSettings() },
|
onSettings = { vm.goToSettings() },
|
||||||
onUpgrade = { vm.onUpgrade() },
|
onUpgrade = { vm.onUpgrade() },
|
||||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||||
|
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||||
|
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +142,8 @@ fun OverviewScreen(
|
|||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
onUpgrade: () -> Unit,
|
onUpgrade: () -> Unit,
|
||||||
onToggleUnmatched: () -> Unit,
|
onToggleUnmatched: () -> Unit,
|
||||||
|
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
||||||
|
onDeviceSettings: (PodDevice) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
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) {
|
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||||
items(
|
items(
|
||||||
items = state.profiledDevices,
|
items = state.visibleProfiledDevices,
|
||||||
key = { it.identifier.hashCode() },
|
key = { it.identifier?.toString() ?: it.hashCode() },
|
||||||
) { device ->
|
) { 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
|
// 5. Monitoring active card
|
||||||
@@ -247,9 +268,15 @@ fun OverviewScreen(
|
|||||||
if (state.showUnmatchedDevices) {
|
if (state.showUnmatchedDevices) {
|
||||||
items(
|
items(
|
||||||
items = state.unmatchedDevices,
|
items = state.unmatchedDevices,
|
||||||
key = { "unmatched_${it.identifier.hashCode()}" },
|
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
|
||||||
) { device ->
|
) { 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
|
@Composable
|
||||||
private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) {
|
private fun PodDeviceCard(
|
||||||
when (device) {
|
device: PodDevice,
|
||||||
is DualPodDevice -> DualPodsCard(device = device, showDebug = showDebug, now = now)
|
showDebug: Boolean,
|
||||||
is SinglePodDevice -> SinglePodsCard(device = device, showDebug = showDebug, now = now)
|
now: Instant,
|
||||||
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
|
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(),
|
now = Instant.now(),
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = listOf(
|
devices = listOf(
|
||||||
MockPodDataProvider.airPodsProMixed(),
|
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||||
MockPodDataProvider.airPodsMax(),
|
MockPodDataProvider.singlePodMonitored(),
|
||||||
MockPodDataProvider.unknownDevice(),
|
MockPodDataProvider.unknownMonitored(),
|
||||||
),
|
),
|
||||||
isDebugMode = false,
|
isDebugMode = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(
|
profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||||
),
|
),
|
||||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||||
showUnmatchedDevices = false,
|
showUnmatchedDevices = false,
|
||||||
@@ -306,7 +347,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
|
|||||||
devices = emptyList(),
|
devices = emptyList(),
|
||||||
isDebugMode = false,
|
isDebugMode = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodDevice.Model.AIRPODS_PRO2)),
|
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||||
showUnmatchedDevices = false,
|
showUnmatchedDevices = false,
|
||||||
),
|
),
|
||||||
@@ -350,7 +391,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
|
|||||||
devices = emptyList(),
|
devices = emptyList(),
|
||||||
isDebugMode = false,
|
isDebugMode = false,
|
||||||
isBluetoothEnabled = 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),
|
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
|
||||||
showUnmatchedDevices = false,
|
showUnmatchedDevices = false,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package eu.darken.capod.main.ui.overview
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
|
import eu.darken.capod.common.datastore.valueBlocking
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
import eu.darken.capod.common.debug.DebugSettings
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.SingleEventFlow
|
import eu.darken.capod.common.flow.SingleEventFlow
|
||||||
import eu.darken.capod.common.flow.combine
|
import eu.darken.capod.common.flow.combine
|
||||||
|
|
||||||
import eu.darken.capod.common.flow.throttleLatest
|
import eu.darken.capod.common.flow.throttleLatest
|
||||||
import eu.darken.capod.common.navigation.Nav
|
import eu.darken.capod.common.navigation.Nav
|
||||||
import eu.darken.capod.common.permissions.Permission
|
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.GeneralSettings
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
import eu.darken.capod.main.core.MonitorMode
|
||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
import eu.darken.capod.monitor.core.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.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.DeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import java.time.Instant
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -33,20 +35,21 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class OverviewViewModel @Inject constructor(
|
class OverviewViewModel @Inject constructor(
|
||||||
dispatcherProvider: DispatcherProvider,
|
dispatcherProvider: DispatcherProvider,
|
||||||
private val monitorControl: MonitorControl,
|
private val monitorControl: MonitorControl,
|
||||||
private val podMonitor: PodMonitor,
|
private val deviceMonitor: DeviceMonitor,
|
||||||
private val permissionTool: PermissionTool,
|
private val permissionTool: PermissionTool,
|
||||||
private val generalSettings: GeneralSettings,
|
private val generalSettings: GeneralSettings,
|
||||||
debugSettings: DebugSettings,
|
debugSettings: DebugSettings,
|
||||||
private val upgradeRepo: UpgradeRepo,
|
private val upgradeRepo: UpgradeRepo,
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
|
private val aapManager: AapConnectionManager,
|
||||||
) : ViewModel4(dispatcherProvider) {
|
) : ViewModel4(dispatcherProvider) {
|
||||||
|
|
||||||
val requestPermissionEvent = SingleEventFlow<Permission>()
|
val requestPermissionEvent = SingleEventFlow<Permission>()
|
||||||
@@ -71,6 +74,7 @@ class OverviewViewModel @Inject constructor(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MonitorMode.ALWAYS -> true
|
MonitorMode.ALWAYS -> true
|
||||||
}
|
}
|
||||||
if (shouldStart) {
|
if (shouldStart) {
|
||||||
@@ -92,7 +96,7 @@ class OverviewViewModel @Inject constructor(
|
|||||||
if (permissions.isNotEmpty()) {
|
if (permissions.isNotEmpty()) {
|
||||||
return@flatMapLatest flowOf(emptyList())
|
return@flatMapLatest flowOf(emptyList())
|
||||||
}
|
}
|
||||||
podMonitor.devices
|
deviceMonitor.devices
|
||||||
}
|
}
|
||||||
.catch { errorEvents.emitBlocking(it) }
|
.catch { errorEvents.emitBlocking(it) }
|
||||||
.throttleLatest(1000)
|
.throttleLatest(1000)
|
||||||
@@ -130,8 +134,11 @@ class OverviewViewModel @Inject constructor(
|
|||||||
val showUnmatchedDevices: Boolean,
|
val showUnmatchedDevices: Boolean,
|
||||||
) {
|
) {
|
||||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||||
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
|
val profiledDevices: List<PodDevice> get() = devices.filter { it.profileId != null }
|
||||||
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == 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) {
|
fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) {
|
||||||
@@ -146,6 +153,11 @@ class OverviewViewModel @Inject constructor(
|
|||||||
navTo(Nav.Main.DeviceManager)
|
navTo(Nav.Main.DeviceManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun goToDeviceSettings(device: PodDevice) {
|
||||||
|
val address = device.address ?: return
|
||||||
|
navTo(Nav.Main.DeviceSettings(address))
|
||||||
|
}
|
||||||
|
|
||||||
fun onUpgrade() {
|
fun onUpgrade() {
|
||||||
navTo(Nav.Main.Upgrade)
|
navTo(Nav.Main.Upgrade)
|
||||||
}
|
}
|
||||||
@@ -158,7 +170,20 @@ class OverviewViewModel @Inject constructor(
|
|||||||
requestPermissionEvent.tryEmit(permission)
|
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 {
|
companion object {
|
||||||
|
private const val FREE_DEVICE_LIMIT = 1
|
||||||
private val TAG = logTag("Overview", "VM")
|
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.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.BatteryChargingFull
|
||||||
import androidx.compose.material.icons.twotone.GridView
|
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.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
@@ -44,30 +45,25 @@ import eu.darken.capod.R
|
|||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import eu.darken.capod.pods.core.DualPodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.HasCase
|
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
import eu.darken.capod.monitor.core.getSignalQuality
|
||||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import eu.darken.capod.pods.core.HasPodStyle
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||||
import eu.darken.capod.pods.core.HasStateDetection
|
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||||
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 java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DualPodsCard(
|
fun DualPodsCard(
|
||||||
device: DualPodDevice,
|
device: PodDevice,
|
||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||||
|
onDeviceSettings: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -89,73 +85,70 @@ fun DualPodsCard(
|
|||||||
Image(
|
Image(
|
||||||
painter = painterResource(device.iconRes),
|
painter = painterResource(device.iconRes),
|
||||||
contentDescription = null,
|
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)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = device.meta.profile?.label ?: "?",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
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)})")
|
|
||||||
}
|
|
||||||
if (device is DualApplePods && showDebug) {
|
|
||||||
append(" [${device.primaryPod.name}]")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = deviceLabel,
|
text = device.label ?: "?",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
val deviceLabel = buildString {
|
||||||
|
append(device.getLabel(context))
|
||||||
|
val podStyle = device.ble as? HasPodStyle
|
||||||
|
if (podStyle != null && showDebug) {
|
||||||
|
append(" (${podStyle.podStyle.getColor(context)})")
|
||||||
|
}
|
||||||
|
val dualApple = device.ble as? DualApplePods
|
||||||
|
if (dualApple != null && showDebug) {
|
||||||
|
append(" [${dualApple.primaryPod.name}]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
SignalBadge(
|
||||||
|
signalText = device.getSignalQuality(context),
|
||||||
|
bleKeyState = device.bleKeyState,
|
||||||
|
isAapConnected = device.isAapConnected,
|
||||||
|
isLive = device.isLive,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SignalBadge(signalText = device.getSignalQuality(context))
|
if (device.address != null && onDeviceSettings != null) {
|
||||||
}
|
IconButton(onClick = onDeviceSettings) {
|
||||||
|
Icon(
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
imageVector = Icons.TwoTone.Tune,
|
||||||
|
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||||
// Timestamps
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
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(16.dp))
|
||||||
|
|
||||||
// Circular battery gauges side by side
|
// Circular battery gauges side by side
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
tonalElevation = 1.dp,
|
tonalElevation = 4.dp,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
@@ -166,31 +159,31 @@ fun DualPodsCard(
|
|||||||
) {
|
) {
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(),
|
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||||
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
|
isCharging = device.isLeftPodCharging ?: false,
|
||||||
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
|
isInEar = device.isLeftInEar ?: false,
|
||||||
showEarDetection = device is HasEarDetectionDual,
|
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||||
isMicrophone = (device as? HasDualMicrophone)?.isLeftPodMicrophone ?: false,
|
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||||
showMicrophone = device is HasDualMicrophone,
|
showMicrophone = device.hasDualMicrophone,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRightPodPercent.toBatteryFloat(),
|
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||||
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
|
isCharging = device.isRightPodCharging ?: false,
|
||||||
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
|
isInEar = device.isRightInEar ?: false,
|
||||||
showEarDetection = device is HasEarDetectionDual,
|
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||||
isMicrophone = (device as? HasDualMicrophone)?.isRightPodMicrophone ?: false,
|
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||||
showMicrophone = device is HasDualMicrophone,
|
showMicrophone = device.hasDualMicrophone,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case row
|
// Case row
|
||||||
if (device is HasCase) {
|
if (device.hasCase) {
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(vertical = 12.dp),
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -199,13 +192,37 @@ fun DualPodsCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection state
|
// Cached battery indicator
|
||||||
if (device is HasStateDetection) {
|
if (device.isBatteryCached && !device.isAapReady) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = device.state.getLabel(context),
|
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 +267,12 @@ private fun PodGauge(
|
|||||||
// Ring with icon inside
|
// Ring with icon inside
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier.size(80.dp),
|
modifier = Modifier.size(68.dp),
|
||||||
) {
|
) {
|
||||||
// Track ring
|
// Track ring
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { 1f },
|
progress = { 1f },
|
||||||
modifier = Modifier.size(80.dp),
|
modifier = Modifier.size(68.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
strokeWidth = 6.dp,
|
strokeWidth = 6.dp,
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
@@ -266,7 +283,7 @@ private fun PodGauge(
|
|||||||
if (clamped >= 0f) {
|
if (clamped >= 0f) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(80.dp),
|
modifier = Modifier.size(68.dp),
|
||||||
color = ringColor,
|
color = ringColor,
|
||||||
strokeWidth = 6.dp,
|
strokeWidth = 6.dp,
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
@@ -314,7 +331,7 @@ private fun PodGauge(
|
|||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun CaseRow(
|
private fun CaseRow(
|
||||||
device: HasCase,
|
device: PodDevice,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -331,13 +348,13 @@ private fun CaseRow(
|
|||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, device.batteryCasePercent),
|
text = formatBatteryPercent(context, device.batteryCase),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCasePercent.toBatteryFloat(),
|
percent = device.batteryCase.toBatteryFloat(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(8.dp),
|
.height(8.dp),
|
||||||
@@ -349,25 +366,23 @@ private fun CaseRow(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
if (device.isCaseCharging) {
|
if (device.isCaseCharging == true) {
|
||||||
StatusChip(
|
StatusChip(
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = stringResource(R.string.pods_charging_label),
|
label = stringResource(R.string.pods_charging_label),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (device is DualApplePods) {
|
val lidState = device.caseLidState
|
||||||
val lidState = device.caseLidState
|
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
|
||||||
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
|
StatusChip(
|
||||||
StatusChip(
|
icon = Icons.TwoTone.GridView,
|
||||||
icon = Icons.TwoTone.GridView,
|
label = when (lidState) {
|
||||||
label = when (lidState) {
|
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
|
||||||
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
|
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
|
||||||
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
|
else -> ""
|
||||||
else -> ""
|
},
|
||||||
},
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,42 +390,31 @@ private fun CaseRow(
|
|||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun DualPodsCardFullChargePreview() = PreviewWrapper {
|
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
||||||
DualPodsCard(device = MockPodDataProvider.airPodsProFullCharge(), showDebug = false, now = Instant.now())
|
DualPodsCard(
|
||||||
|
device = MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||||
|
showDebug = false,
|
||||||
|
now = Instant.now(),
|
||||||
|
onDeviceSettings = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun DualPodsCardMixedBatteryPreview() = PreviewWrapper {
|
private fun DualPodsCardMinimalPreview() = PreviewWrapper {
|
||||||
DualPodsCard(device = MockPodDataProvider.airPodsProMixed(), showDebug = false, now = Instant.now())
|
DualPodsCard(
|
||||||
|
device = MockPodDataProvider.dualPodMonitored(),
|
||||||
|
showDebug = false,
|
||||||
|
now = Instant.now(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun DualPodsCardLowBatteryPreview() = PreviewWrapper {
|
private fun DualPodsCardCachedPreview() = PreviewWrapper {
|
||||||
DualPodsCard(device = MockPodDataProvider.airPodsProLowBattery(), showDebug = false, now = Instant.now())
|
DualPodsCard(
|
||||||
}
|
device = MockPodDataProvider.dualPodCachedOnly(),
|
||||||
|
showDebug = false,
|
||||||
@Preview2
|
now = Instant.now(),
|
||||||
@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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,18 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||||
import androidx.compose.material.icons.twotone.Hearing
|
import androidx.compose.material.icons.twotone.Hearing
|
||||||
import androidx.compose.material.icons.twotone.KeyboardVoice
|
import androidx.compose.material.icons.twotone.KeyboardVoice
|
||||||
|
import androidx.compose.material.icons.outlined.Key
|
||||||
|
import androidx.compose.material.icons.twotone.Bluetooth
|
||||||
|
import androidx.compose.material.icons.twotone.Key
|
||||||
|
import androidx.compose.material.icons.twotone.LinkOff
|
||||||
import androidx.compose.material.icons.twotone.SettingsInputAntenna
|
import androidx.compose.material.icons.twotone.SettingsInputAntenna
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -34,7 +42,14 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
|
||||||
private val CapsuleShape = RoundedCornerShape(6.dp)
|
private val CapsuleShape = RoundedCornerShape(6.dp)
|
||||||
|
|
||||||
@@ -147,6 +162,9 @@ fun StatusChipRow(
|
|||||||
@Composable
|
@Composable
|
||||||
fun SignalBadge(
|
fun SignalBadge(
|
||||||
signalText: String,
|
signalText: String,
|
||||||
|
bleKeyState: BleKeyState = BleKeyState.NONE,
|
||||||
|
isAapConnected: Boolean = false,
|
||||||
|
isLive: Boolean = true,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -158,18 +176,48 @@ fun SignalBadge(
|
|||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (!isLive) {
|
||||||
imageVector = Icons.TwoTone.SettingsInputAntenna,
|
Icon(
|
||||||
contentDescription = null,
|
imageVector = Icons.TwoTone.LinkOff,
|
||||||
modifier = Modifier.size(12.dp),
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
modifier = Modifier.size(12.dp),
|
||||||
)
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
Spacer(modifier = Modifier.width(3.dp))
|
)
|
||||||
Text(
|
} else {
|
||||||
text = signalText,
|
if (bleKeyState != BleKeyState.NONE) {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Icon(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
imageVector = if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) Icons.TwoTone.Key else Icons.Outlined.Key,
|
||||||
)
|
contentDescription = stringResource(
|
||||||
|
if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) R.string.signal_badge_key_encrypted_cd
|
||||||
|
else R.string.signal_badge_key_irk_cd
|
||||||
|
),
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(3.dp))
|
||||||
|
}
|
||||||
|
if (isAapConnected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.TwoTone.Bluetooth,
|
||||||
|
contentDescription = stringResource(R.string.signal_badge_aap_cd),
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,3 +241,84 @@ 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 SignalBadgeDefaultPreview() = PreviewWrapper {
|
||||||
|
SignalBadge(signalText = "85%")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun SignalBadgeIrkOnlyPreview() = PreviewWrapper {
|
||||||
|
SignalBadge(signalText = "85%", bleKeyState = BleKeyState.IRK_ONLY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun SignalBadgeAllIconsPreview() = PreviewWrapper {
|
||||||
|
SignalBadge(signalText = "85%", bleKeyState = BleKeyState.IRK_AND_ENCRYPTED, isAapConnected = true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||||
import androidx.compose.material.icons.twotone.Hearing
|
import androidx.compose.material.icons.twotone.Hearing
|
||||||
|
import androidx.compose.material.icons.twotone.Tune
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -31,6 +34,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
@@ -41,26 +45,25 @@ import eu.darken.capod.R
|
|||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import eu.darken.capod.pods.core.HasChargeDetection
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||||
import eu.darken.capod.pods.core.SinglePodDevice
|
import eu.darken.capod.monitor.core.getSignalQuality
|
||||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.getSignalQuality
|
|
||||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SinglePodsCard(
|
fun SinglePodsCard(
|
||||||
device: SinglePodDevice,
|
device: PodDevice,
|
||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||||
|
onDeviceSettings: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val clamped = device.batteryHeadsetPercent?.coerceIn(0f, 1f)
|
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = clamped ?: 0f,
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
@@ -92,53 +95,59 @@ fun SinglePodsCard(
|
|||||||
Image(
|
Image(
|
||||||
painter = painterResource(device.iconRes),
|
painter = painterResource(device.iconRes),
|
||||||
contentDescription = null,
|
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)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = device.meta.profile?.label ?: "?",
|
text = device.label ?: "?",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
Row(
|
||||||
text = device.getLabel(context),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Text(
|
||||||
maxLines = 1,
|
text = device.getLabel(context),
|
||||||
overflow = TextOverflow.Ellipsis,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
SignalBadge(
|
||||||
|
signalText = device.getSignalQuality(context),
|
||||||
|
bleKeyState = device.bleKeyState,
|
||||||
|
isAapConnected = device.isAapConnected,
|
||||||
|
isLive = device.isLive,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
Spacer(modifier = Modifier.height(12.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))
|
|
||||||
|
|
||||||
// Central gauge
|
// Central gauge
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
tonalElevation = 1.dp,
|
tonalElevation = 4.dp,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -148,12 +157,12 @@ fun SinglePodsCard(
|
|||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier.size(100.dp),
|
modifier = Modifier.size(88.dp),
|
||||||
) {
|
) {
|
||||||
// Track ring
|
// Track ring
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { 1f },
|
progress = { 1f },
|
||||||
modifier = Modifier.size(100.dp),
|
modifier = Modifier.size(88.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
strokeWidth = 8.dp,
|
strokeWidth = 8.dp,
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
@@ -164,7 +173,7 @@ fun SinglePodsCard(
|
|||||||
if (clamped != null) {
|
if (clamped != null) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(100.dp),
|
modifier = Modifier.size(88.dp),
|
||||||
color = ringColor,
|
color = ringColor,
|
||||||
strokeWidth = 8.dp,
|
strokeWidth = 8.dp,
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
@@ -174,9 +183,9 @@ fun SinglePodsCard(
|
|||||||
|
|
||||||
// Battery text inside ring
|
// Battery text inside ring
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, device.batteryHeadsetPercent),
|
text = formatBatteryPercent(context, device.batteryHeadset),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = if (device.batteryHeadsetPercent != null) {
|
color = if (device.batteryHeadset != null) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -192,13 +201,13 @@ fun SinglePodsCard(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
if (device is HasChargeDetection && device.isHeadsetBeingCharged) {
|
if (device.isHeadsetBeingCharged == true) {
|
||||||
StatusChip(
|
StatusChip(
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = stringResource(R.string.pods_charging_label),
|
label = stringResource(R.string.pods_charging_label),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (device is HasEarDetection && device.isBeingWorn) {
|
if (device.isBeingWorn == true) {
|
||||||
StatusChip(
|
StatusChip(
|
||||||
icon = Icons.TwoTone.Hearing,
|
icon = Icons.TwoTone.Hearing,
|
||||||
label = stringResource(R.string.pods_inear_label),
|
label = stringResource(R.string.pods_inear_label),
|
||||||
@@ -208,6 +217,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
|
// Debug info
|
||||||
if (showDebug) {
|
if (showDebug) {
|
||||||
DebugSection(rawDataHex = device.rawDataHex)
|
DebugSection(rawDataHex = device.rawDataHex)
|
||||||
@@ -218,18 +261,31 @@ fun SinglePodsCard(
|
|||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun SinglePodsCardWearingPreview() = PreviewWrapper {
|
private fun SinglePodsCardFullPreview() = PreviewWrapper {
|
||||||
SinglePodsCard(device = MockPodDataProvider.airPodsMax(), showDebug = false, now = Instant.now())
|
SinglePodsCard(
|
||||||
|
device = MockPodDataProvider.singlePodMonitoredWithAap(),
|
||||||
|
showDebug = false,
|
||||||
|
now = Instant.now(),
|
||||||
|
onDeviceSettings = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun SinglePodsCardChargingPreview() = PreviewWrapper {
|
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
|
||||||
SinglePodsCard(device = MockPodDataProvider.airPodsMaxCharging(), showDebug = false, now = Instant.now())
|
SinglePodsCard(
|
||||||
|
device = MockPodDataProvider.singlePodMonitored(),
|
||||||
|
showDebug = false,
|
||||||
|
now = Instant.now(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun SinglePodsCardDebugPreview() = PreviewWrapper {
|
private fun SinglePodsCardCachedPreview() = PreviewWrapper {
|
||||||
SinglePodsCard(device = MockPodDataProvider.beatsSolo3(), showDebug = true, now = Instant.now())
|
SinglePodsCard(
|
||||||
|
device = MockPodDataProvider.singlePodCachedOnly(),
|
||||||
|
showDebug = false,
|
||||||
|
now = Instant.now(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,20 +21,17 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.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.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import java.time.Instant
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.getSignalQuality
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UnknownPodDeviceCard(
|
fun UnknownPodDeviceCard(
|
||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -71,20 +68,13 @@ fun UnknownPodDeviceCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = when (device) {
|
text = if (device.ble is ApplePods) {
|
||||||
is ApplePods -> stringResource(R.string.pods_unknown_contact_dev)
|
stringResource(R.string.pods_unknown_contact_dev)
|
||||||
else -> stringResource(R.string.pods_unknown_label)
|
} else {
|
||||||
|
stringResource(R.string.pods_unknown_label)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
@@ -110,11 +100,11 @@ fun UnknownPodDeviceCard(
|
|||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun UnknownPodDeviceCardPreview() = PreviewWrapper {
|
private fun UnknownPodDeviceCardPreview() = PreviewWrapper {
|
||||||
UnknownPodDeviceCard(device = MockPodDataProvider.unknownDevice(), showDebug = false, now = Instant.now())
|
UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun UnknownPodDeviceCardDebugPreview() = PreviewWrapper {
|
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") },
|
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 {
|
item {
|
||||||
SettingsBaseItem(
|
SettingsBaseItem(
|
||||||
title = "FuriousMAC",
|
title = "FuriousMAC",
|
||||||
@@ -124,6 +131,20 @@ fun AcknowledgementsScreen(
|
|||||||
onClick = { onOpenUrl("https://iconduck.com/sets/zwicon-icon-set") },
|
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 {
|
item {
|
||||||
SettingsBaseItem(
|
SettingsBaseItem(
|
||||||
title = "Kotlin",
|
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.appwidget.AppWidgetManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.Keep
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -10,7 +11,6 @@ import androidx.glance.appwidget.GlanceAppWidget
|
|||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import androidx.glance.appwidget.SizeMode
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.annotation.Keep
|
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
@@ -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.debug.logging.logTag
|
||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.common.upgrade.isPro
|
import eu.darken.capod.common.upgrade.isPro
|
||||||
import eu.darken.capod.monitor.core.PodMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
|
||||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||||
@@ -34,7 +34,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
@EntryPoint
|
@EntryPoint
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface WidgetEntryPoint {
|
interface WidgetEntryPoint {
|
||||||
fun podMonitor(): PodMonitor
|
fun deviceMonitor(): DeviceMonitor
|
||||||
fun upgradeRepo(): UpgradeRepo
|
fun upgradeRepo(): UpgradeRepo
|
||||||
fun widgetSettings(): WidgetSettings
|
fun widgetSettings(): WidgetSettings
|
||||||
fun deviceProfilesRepo(): DeviceProfilesRepo
|
fun deviceProfilesRepo(): DeviceProfilesRepo
|
||||||
@@ -53,7 +53,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||||
initialIsPro = ep.upgradeRepo().isPro()
|
initialIsPro = ep.upgradeRepo().isPro()
|
||||||
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||||
cachedDevice = initialProfileId?.let { ep.podMonitor().getDeviceForProfile(it) }
|
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||||
provideContent {
|
provideContent {
|
||||||
@@ -73,7 +73,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
provideContent {
|
provideContent {
|
||||||
// Composable reads — must be outside try-catch
|
// 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 profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||||
val widthDp = LocalSize.current.width
|
val widthDp = LocalSize.current.width
|
||||||
@@ -86,8 +86,8 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||||
|
|
||||||
val liveDevice = devices.firstOrNull { it.meta.profile?.id == profileId }
|
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
||||||
val device = liveDevice ?: cachedDevice?.takeIf { it.meta.profile?.id == profileId }
|
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
||||||
|
|
||||||
val profileLabel = profileId?.let { pid ->
|
val profileLabel = profileId?.let { pid ->
|
||||||
profiles.firstOrNull { it.id == pid }?.label
|
profiles.firstOrNull { it.id == pid }?.label
|
||||||
@@ -139,7 +139,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
var n = 2
|
var n = 2
|
||||||
while (70 * n - 30 < size) {
|
while (70 * n - 30 < size) {
|
||||||
++n
|
++n
|
||||||
}
|
}
|
||||||
return n - 1
|
return n - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.pods.core.toBatteryOrNull
|
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import androidx.glance.text.TextAlign
|
|||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.main.ui.MainActivity
|
import eu.darken.capod.main.ui.MainActivity
|
||||||
import eu.darken.capod.pods.core.toBatteryOrNull
|
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.only
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.systemBars
|
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.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.profiles.core.DeviceProfile
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@@ -413,7 +413,7 @@ private fun ProfileSelectionItem(
|
|||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
)
|
)
|
||||||
val modelText = when (profile.model) {
|
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
|
else -> profile.model.label
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@@ -456,7 +456,9 @@ private fun WidgetConfigPreview(
|
|||||||
tonalElevation = 2.dp,
|
tonalElevation = 2.dp,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (hasTransparency) {
|
if (hasTransparency) {
|
||||||
@@ -691,8 +693,8 @@ private fun HexColorInput(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WidgetConfigurationScreenPreview() = PreviewWrapper {
|
private fun WidgetConfigurationScreenPreview() = PreviewWrapper {
|
||||||
val profiles = listOf(
|
val profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
|
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
|
||||||
)
|
)
|
||||||
WidgetConfigurationScreen(
|
WidgetConfigurationScreen(
|
||||||
state = WidgetConfigurationViewModel.State(
|
state = WidgetConfigurationViewModel.State(
|
||||||
@@ -720,7 +722,7 @@ private fun WidgetConfigurationScreenPreview() = PreviewWrapper {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WidgetConfigurationScreenCustomPreview() = PreviewWrapper {
|
private fun WidgetConfigurationScreenCustomPreview() = PreviewWrapper {
|
||||||
val profiles = listOf(
|
val profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
)
|
)
|
||||||
WidgetConfigurationScreen(
|
WidgetConfigurationScreen(
|
||||||
state = WidgetConfigurationViewModel.State(
|
state = WidgetConfigurationViewModel.State(
|
||||||
@@ -753,7 +755,7 @@ private fun WidgetConfigurationScreenCustomPreview() = PreviewWrapper {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WidgetConfigurationScreenNonProPreview() = PreviewWrapper {
|
private fun WidgetConfigurationScreenNonProPreview() = PreviewWrapper {
|
||||||
val profiles = listOf(
|
val profiles = listOf(
|
||||||
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
|
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||||
)
|
)
|
||||||
WidgetConfigurationScreen(
|
WidgetConfigurationScreen(
|
||||||
state = WidgetConfigurationViewModel.State(
|
state = WidgetConfigurationViewModel.State(
|
||||||
|
|||||||
@@ -4,15 +4,10 @@ import android.content.Context
|
|||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.appcompat.view.ContextThemeWrapper
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.pods.core.DualPodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.HasCase
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||||
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
|
|
||||||
|
|
||||||
object WidgetRenderStateMapper {
|
object WidgetRenderStateMapper {
|
||||||
|
|
||||||
@@ -39,7 +34,7 @@ object WidgetRenderStateMapper {
|
|||||||
secondaryText = context.getString(R.string.upgrade_capod_description),
|
secondaryText = context.getString(R.string.upgrade_capod_description),
|
||||||
)
|
)
|
||||||
|
|
||||||
device is DualPodDevice -> WidgetRenderState.DualPod(
|
device != null && device.hasDualPods -> WidgetRenderState.DualPod(
|
||||||
theme = theme,
|
theme = theme,
|
||||||
resolvedBgColor = bgColor,
|
resolvedBgColor = bgColor,
|
||||||
resolvedTextColor = textColor,
|
resolvedTextColor = textColor,
|
||||||
@@ -47,32 +42,32 @@ object WidgetRenderStateMapper {
|
|||||||
isWide = isWide,
|
isWide = isWide,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
leftIcon = device.leftPodIcon,
|
leftIcon = device.leftPodIcon,
|
||||||
leftPercent = device.batteryLeftPodPercent.toBatteryFloat(),
|
leftPercent = device.batteryLeft.toBatteryFloat(),
|
||||||
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
|
leftCharging = device.isLeftPodCharging == true,
|
||||||
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
|
leftInEar = device.isLeftInEar == true,
|
||||||
rightIcon = device.rightPodIcon,
|
rightIcon = device.rightPodIcon,
|
||||||
rightPercent = device.batteryRightPodPercent.toBatteryFloat(),
|
rightPercent = device.batteryRight.toBatteryFloat(),
|
||||||
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
|
rightCharging = device.isRightPodCharging == true,
|
||||||
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
|
rightInEar = device.isRightInEar == true,
|
||||||
caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case,
|
caseIcon = device.caseIcon,
|
||||||
casePercent = (device as? HasCase)?.batteryCasePercent.toBatteryFloat(),
|
casePercent = device.batteryCase.toBatteryFloat(),
|
||||||
caseCharging = device is HasCase && device.isCaseCharging,
|
caseCharging = device.isCaseCharging == true,
|
||||||
)
|
)
|
||||||
|
|
||||||
device is SinglePodDevice -> WidgetRenderState.SinglePod(
|
device != null && device.model != PodModel.UNKNOWN -> WidgetRenderState.SinglePod(
|
||||||
theme = theme,
|
theme = theme,
|
||||||
resolvedBgColor = bgColor,
|
resolvedBgColor = bgColor,
|
||||||
resolvedTextColor = textColor,
|
resolvedTextColor = textColor,
|
||||||
resolvedIconColor = iconColor,
|
resolvedIconColor = iconColor,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
headsetIcon = device.iconRes,
|
headsetIcon = device.iconRes,
|
||||||
percent = device.batteryHeadsetPercent.toBatteryFloat(),
|
percent = device.batteryHeadset.toBatteryFloat(),
|
||||||
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
|
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||||
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
|
charging = device.isHeadsetBeingCharged == true,
|
||||||
worn = device is HasEarDetection && device.isBeingWorn,
|
worn = device.isBeingWorn == true,
|
||||||
)
|
)
|
||||||
|
|
||||||
device is PodDevice -> WidgetRenderState.Message(
|
device != null -> WidgetRenderState.Message(
|
||||||
theme = theme,
|
theme = theme,
|
||||||
resolvedBgColor = bgColor,
|
resolvedBgColor = bgColor,
|
||||||
resolvedTextColor = textColor,
|
resolvedTextColor = textColor,
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
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.aap.AapConnectionManager
|
||||||
|
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 bondedAddress = pod.meta.profile?.address
|
||||||
|
val profile = pod.meta.profile
|
||||||
|
PodDevice(
|
||||||
|
profileId = profile?.id,
|
||||||
|
label = profile?.label,
|
||||||
|
ble = pod,
|
||||||
|
aap = bondedAddress?.let { aapStates[it] },
|
||||||
|
cached = profile?.id?.let { cachedStates[it] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached-only devices — profiles with cache but no live BLE
|
||||||
|
val liveProfileIds = liveDevices.mapNotNull { it.profileId }.toSet()
|
||||||
|
val cachedOnlyDevices = profiles
|
||||||
|
.filter { it.id !in liveProfileIds }
|
||||||
|
.mapNotNull { profile ->
|
||||||
|
cachedStates[profile.id]?.let {
|
||||||
|
PodDevice(profileId = profile.id, label = profile.label, ble = null, aap = null, cached = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
liveDevices + cachedOnlyDevices
|
||||||
|
}
|
||||||
|
.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 cached = deviceStateCache.load(profileId)
|
||||||
|
if (cached != null) {
|
||||||
|
log(TAG) { "Found cached state for profile $profileId" }
|
||||||
|
val profileLabel = profilesRepo.profiles.firstOrNull()?.firstOrNull { it.id == profileId }?.label
|
||||||
|
return PodDevice(profileId = profileId, label = profileLabel, ble = null, aap = null, cached = cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
log(TAG) { "No device found for profile $profileId" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("DeviceMonitor")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.icu.text.RelativeDateTimeFormatter
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
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.getSignalQuality(context: Context): String {
|
||||||
|
val multiplier = 100 * signalQuality
|
||||||
|
return "${multiplier.roundToInt()}%"
|
||||||
|
}
|
||||||
|
|
||||||
|
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,301 @@
|
|||||||
|
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,
|
||||||
|
) {
|
||||||
|
val model: PodModel get() = ble?.model ?: cached?.model ?: PodModel.UNKNOWN
|
||||||
|
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
|
||||||
|
val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: 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() = ble?.seenLastAt ?: cached?.lastSeenAt
|
||||||
|
val seenFirstAt: Instant? get() = ble?.seenFirstAt
|
||||||
|
val signalQuality: Float
|
||||||
|
get() {
|
||||||
|
val bleQuality = ble?.signalQuality ?: 0f
|
||||||
|
return (bleQuality + computeAapBoost(Instant.now())).coerceAtMost(1f)
|
||||||
|
}
|
||||||
|
val rssi: Int get() = ble?.rssi ?: 0
|
||||||
|
|
||||||
|
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 inCaseTone: AapSetting.InCaseTone?
|
||||||
|
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
|
||||||
|
|
||||||
|
val bleKeyState: BleKeyState
|
||||||
|
get() {
|
||||||
|
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 }
|
||||||
@@ -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 android.bluetooth.le.ScanFilter
|
||||||
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.bluetooth.BleScanner
|
import eu.darken.capod.common.bluetooth.BleScanner
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||||
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
|
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
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
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
@@ -16,19 +16,20 @@ import eu.darken.capod.common.flow.replayingShare
|
|||||||
import eu.darken.capod.common.flow.throttleLatest
|
import eu.darken.capod.common.flow.throttleLatest
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.PodFactory
|
import eu.darken.capod.pods.core.apple.ble.PodFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import eu.darken.capod.profiles.core.currentProfiles
|
import eu.darken.capod.profiles.core.currentProfiles
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.retryWhen
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -39,22 +40,21 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class PodMonitor @Inject constructor(
|
class BlePodMonitor @Inject constructor(
|
||||||
@AppScope private val appScope: CoroutineScope,
|
@AppScope private val appScope: CoroutineScope,
|
||||||
private val bleScanner: BleScanner,
|
private val bleScanner: BleScanner,
|
||||||
private val podFactory: PodFactory,
|
private val podFactory: PodFactory,
|
||||||
private val generalSettings: GeneralSettings,
|
private val generalSettings: GeneralSettings,
|
||||||
bluetoothManager: BluetoothManager2,
|
bluetoothManager: BluetoothManager2,
|
||||||
private val debugSettings: DebugSettings,
|
private val debugSettings: DebugSettings,
|
||||||
private val podDeviceCache: PodDeviceCache,
|
|
||||||
permissionTool: PermissionTool,
|
permissionTool: PermissionTool,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
|
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
||||||
private val cacheLock = Mutex()
|
private val cacheLock = Mutex()
|
||||||
|
|
||||||
val devices: Flow<List<PodDevice>> = combine(
|
val devices: Flow<List<BlePodSnapshot>> = combine(
|
||||||
permissionTool.missingScanPermissions,
|
permissionTool.missingScanPermissions,
|
||||||
bluetoothManager.isBluetoothEnabled
|
bluetoothManager.isBluetoothEnabled
|
||||||
) { missingScanPermissions, isBluetoothEnabled ->
|
) { missingScanPermissions, isBluetoothEnabled ->
|
||||||
@@ -63,10 +63,16 @@ class PodMonitor @Inject constructor(
|
|||||||
}
|
}
|
||||||
.flatMapLatest { isReady ->
|
.flatMapLatest { isReady ->
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
log(TAG, WARN) { "Bluetooth is not ready" }
|
log(TAG, Logging.Priority.WARN) { "Bluetooth is not ready" }
|
||||||
flowOf(null)
|
flowOf(null)
|
||||||
} else {
|
} 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) } }
|
.map { results -> results?.mapNotNull { podFactory.createPod(it) } }
|
||||||
@@ -76,10 +82,13 @@ class PodMonitor @Inject constructor(
|
|||||||
}
|
}
|
||||||
.retryWhen { cause, attempt ->
|
.retryWhen { cause, attempt ->
|
||||||
if (cause is SecurityException) {
|
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
|
false
|
||||||
} else {
|
} 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)
|
delay(3000)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -87,12 +96,12 @@ class PodMonitor @Inject constructor(
|
|||||||
.onStart { emit(emptyList()) }
|
.onStart { emit(emptyList()) }
|
||||||
.replayingShare(appScope)
|
.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 now = Instant.now()
|
||||||
val profiles = profilesRepo.currentProfiles() ?: emptyList()
|
val profiles = profilesRepo.currentProfiles() ?: emptyList()
|
||||||
|
|
||||||
return devices.sortedWith(
|
return devices.sortedWith(
|
||||||
compareBy<PodDevice> { device ->
|
compareBy<BlePodSnapshot> { device ->
|
||||||
// Use profile position in list as priority (0 = highest priority)
|
// Use profile position in list as priority (0 = highest priority)
|
||||||
device.meta.profile?.let { profile ->
|
device.meta.profile?.let { profile ->
|
||||||
profiles.indexOfFirst { it.id == profile.id }.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
profiles.indexOfFirst { it.id == profile.id }.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
||||||
@@ -140,7 +149,7 @@ class PodMonitor @Inject constructor(
|
|||||||
.flatMapLatest { options ->
|
.flatMapLatest { options ->
|
||||||
val filters = when {
|
val filters = when {
|
||||||
options.showUnfiltered -> {
|
options.showUnfiltered -> {
|
||||||
log(TAG, WARN) { "Using unfiltered scan mode" }
|
log(TAG, Logging.Priority.WARN) { "Using unfiltered scan mode" }
|
||||||
setOf(ScanFilter.Builder().build())
|
setOf(ScanFilter.Builder().build())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +168,7 @@ class PodMonitor @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun processWithCache(
|
private suspend fun processWithCache(
|
||||||
newPods: List<PodFactory.Result>?
|
newPods: List<PodFactory.Result>?
|
||||||
): Map<PodDevice.Id, PodDevice> = cacheLock.withLock {
|
): Map<BlePodSnapshot.Id, BlePodSnapshot> = cacheLock.withLock {
|
||||||
if (newPods == null) {
|
if (newPods == null) {
|
||||||
log(TAG) { "Null result, Bluetooth is disabled." }
|
log(TAG) { "Null result, Bluetooth is disabled." }
|
||||||
deviceCache.clear()
|
deviceCache.clear()
|
||||||
@@ -168,13 +177,13 @@ class PodMonitor @Inject constructor(
|
|||||||
|
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
deviceCache.toList().forEach { (key, value) ->
|
deviceCache.toList().forEach { (key, value) ->
|
||||||
if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) {
|
if (Duration.between(value.seenLastAt, now) > STALE_DEVICE_TIMEOUT) {
|
||||||
log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
|
log(TAG, Logging.Priority.VERBOSE) { "Removing stale device from cache: $value" }
|
||||||
deviceCache.remove(key)
|
deviceCache.remove(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
|
val pods = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
||||||
|
|
||||||
pods.putAll(deviceCache)
|
pods.putAll(deviceCache)
|
||||||
|
|
||||||
@@ -183,35 +192,12 @@ class PodMonitor @Inject constructor(
|
|||||||
pods[it.identifier] = it
|
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
|
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 {
|
companion object {
|
||||||
private val TAG = logTag("Monitor", "PodMonitor")
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
|
import eu.darken.capod.common.datastore.valueBlocking
|
||||||
import eu.darken.capod.common.debug.Bugs
|
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.VERBOSE
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
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.GeneralSettings
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
import eu.darken.capod.main.core.MonitorMode
|
||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
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.core.primaryDevice
|
||||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
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.DeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
||||||
@@ -53,7 +56,6 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import eu.darken.capod.common.datastore.valueBlocking
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MonitorService : Service() {
|
class MonitorService : Service() {
|
||||||
@@ -63,13 +65,15 @@ class MonitorService : Service() {
|
|||||||
@Inject lateinit var notificationManager: NotificationManager
|
@Inject lateinit var notificationManager: NotificationManager
|
||||||
@Inject lateinit var generalSettings: GeneralSettings
|
@Inject lateinit var generalSettings: GeneralSettings
|
||||||
@Inject lateinit var permissionTool: PermissionTool
|
@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 bluetoothManager: BluetoothManager2
|
||||||
@Inject lateinit var playPause: PlayPause
|
@Inject lateinit var playPause: PlayPause
|
||||||
@Inject lateinit var autoConnect: AutoConnect
|
@Inject lateinit var autoConnect: AutoConnect
|
||||||
@Inject lateinit var popUpReaction: PopUpReaction
|
@Inject lateinit var popUpReaction: PopUpReaction
|
||||||
@Inject lateinit var popUpWindow: PopUpWindow
|
@Inject lateinit var popUpWindow: PopUpWindow
|
||||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||||
|
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||||
|
|
||||||
private val monitorScope = MonitorCoroutineScope()
|
private val monitorScope = MonitorCoroutineScope()
|
||||||
private var monitoringJob: Job? = null
|
private var monitoringJob: Job? = null
|
||||||
@@ -176,8 +180,8 @@ class MonitorService : Service() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val monitorJob = podMonitor.primaryDevice()
|
val monitorJob = deviceMonitor.primaryDevice()
|
||||||
.setupCommonEventHandlers(TAG) { "PodMonitor" }
|
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.throttleLatest(1000)
|
.throttleLatest(1000)
|
||||||
.onEach { currentDevice ->
|
.onEach { currentDevice ->
|
||||||
@@ -209,8 +213,9 @@ class MonitorService : Service() {
|
|||||||
generalSettings.monitorMode.flow,
|
generalSettings.monitorMode.flow,
|
||||||
profilesRepo.profiles,
|
profilesRepo.profiles,
|
||||||
bluetoothManager.connectedDevices,
|
bluetoothManager.connectedDevices,
|
||||||
) { monitorMode, profiles, connectedDevices ->
|
aapConnectionManager.allStates,
|
||||||
listOf(monitorMode, profiles, connectedDevices)
|
) { monitorMode, profiles, connectedDevices, aapStates ->
|
||||||
|
listOf(monitorMode, profiles, connectedDevices, aapStates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,9 +225,13 @@ class MonitorService : Service() {
|
|||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val profiles = arguments[1] as List<DeviceProfile>
|
val profiles = arguments[1] as List<DeviceProfile>
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val devices = arguments[2] as Collection<BluetoothDevice2>
|
val devices = arguments[2] as Collection<BluetoothDevice2>
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val aapStates = arguments[3] as Map<*, *>
|
||||||
|
|
||||||
val connectedAddresses = devices.map { it.address }.toSet()
|
val connectedAddresses = devices.map { it.address }.toSet()
|
||||||
val knownAddresses = profiles.mapNotNull { it.address }.toSet()
|
val knownAddresses = profiles.mapNotNull { it.address }.toSet()
|
||||||
log(TAG) { "Monitor mode: $monitorMode" }
|
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" }
|
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." }
|
log(TAG) { "A device is connected, aborting any timeout." }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,10 @@ import android.view.View
|
|||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.pods.core.DualPodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.HasCase
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||||
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 javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -22,58 +17,61 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun createContentView(device: PodDevice): RemoteViews = when (device) {
|
fun createContentView(device: PodDevice): RemoteViews = when {
|
||||||
is DualPodDevice -> createDualPods(device)
|
device.hasDualPods -> createDualPods(device)
|
||||||
is SinglePodDevice -> createSinglePod(device)
|
device.model != PodModel.UNKNOWN -> createSinglePod(device)
|
||||||
else -> createUnknownDevice(device)
|
else -> createUnknownDevice(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createDualPods(device: DualPodDevice): RemoteViews = RemoteViews(
|
private fun createDualPods(device: PodDevice): RemoteViews = RemoteViews(
|
||||||
context.packageName,
|
context.packageName,
|
||||||
R.layout.monitor_notification_dual_pods_small
|
R.layout.monitor_notification_dual_pods_small
|
||||||
).apply {
|
).apply {
|
||||||
// Left
|
// Left
|
||||||
val leftPercent = device.batteryLeftPodPercent
|
val leftPercent = device.batteryLeft
|
||||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
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)
|
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)
|
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
|
||||||
|
|
||||||
// Case
|
// Case
|
||||||
setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE)
|
setViewVisibility(R.id.pod_case_charging, if (device.hasCase) View.VISIBLE else View.GONE)
|
||||||
(device as? HasCase)?.let { case ->
|
if (device.hasCase) {
|
||||||
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
|
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))
|
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
|
// Right
|
||||||
val rightPercent = device.batteryRightPodPercent
|
val rightPercent = device.batteryRight
|
||||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
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)
|
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)
|
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,
|
context.packageName,
|
||||||
R.layout.monitor_notification_single_pods_small
|
R.layout.monitor_notification_single_pods_small
|
||||||
).apply {
|
).apply {
|
||||||
val headsetPercent = device.batteryHeadsetPercent
|
val headsetPercent = device.batteryHeadset
|
||||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||||
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
|
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
|
||||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||||
if (device is HasEarDetection) {
|
if (device.hasEarDetection) {
|
||||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
|
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||||
}
|
}
|
||||||
if (device is HasChargeDetectionDual) {
|
if (device.isHeadsetBeingCharged != null) {
|
||||||
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
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))
|
setTextViewText(R.id.device, device.getLabel(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createBigContentView(device: PodDevice): RemoteViews = when (device) {
|
fun createBigContentView(device: PodDevice): RemoteViews = when {
|
||||||
is DualPodDevice -> createDualPodsBig(device)
|
device.hasDualPods -> createDualPodsBig(device)
|
||||||
is SinglePodDevice -> createSinglePodBig(device)
|
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
|
||||||
else -> createUnknownDeviceBig(device)
|
else -> createUnknownDeviceBig(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createDualPodsBig(device: DualPodDevice): RemoteViews = RemoteViews(
|
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||||
context.packageName,
|
context.packageName,
|
||||||
R.layout.monitor_notification_dual_pods_big
|
R.layout.monitor_notification_dual_pods_big
|
||||||
).apply {
|
).apply {
|
||||||
// Left
|
// Left
|
||||||
val leftPercent = device.batteryLeftPodPercent
|
val leftPercent = device.batteryLeft
|
||||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||||
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
|
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
|
||||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
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)
|
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)
|
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
|
||||||
|
|
||||||
// Case
|
// Case
|
||||||
setViewVisibility(R.id.pod_case_container, if (device is HasCase) View.VISIBLE else View.GONE)
|
setViewVisibility(R.id.pod_case_container, if (device.hasCase) View.VISIBLE else View.GONE)
|
||||||
(device as? HasCase)?.let { case ->
|
if (device.hasCase) {
|
||||||
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
|
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)
|
setProgressBar(R.id.pod_case_progress, 100, percentToInt(casePercent), false)
|
||||||
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
|
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
|
// Right
|
||||||
val rightPercent = device.batteryRightPodPercent
|
val rightPercent = device.batteryRight
|
||||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||||
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
|
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
|
||||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
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)
|
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)
|
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,
|
context.packageName,
|
||||||
R.layout.monitor_notification_single_pods_big
|
R.layout.monitor_notification_single_pods_big
|
||||||
).apply {
|
).apply {
|
||||||
val headsetPercent = device.batteryHeadsetPercent
|
val headsetPercent = device.batteryHeadset
|
||||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||||
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
|
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
|
||||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||||
if (device is HasEarDetection) {
|
if (device.hasEarDetection) {
|
||||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
|
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||||
}
|
}
|
||||||
if (device is HasChargeDetectionDual) {
|
if (device.isHeadsetBeingCharged != null) {
|
||||||
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
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)
|
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.debug.logging.logTag
|
||||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||||
import eu.darken.capod.main.ui.MainActivity
|
import eu.darken.capod.main.ui.MainActivity
|
||||||
import eu.darken.capod.pods.core.DualPodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.HasCase
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.HasChargeDetection
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
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 javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +53,11 @@ class MonitorNotifications @Inject constructor(
|
|||||||
setOngoing(true)
|
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) {
|
if (device == null) {
|
||||||
return baseBuilder(channelId).apply {
|
return baseBuilder(channelId).apply {
|
||||||
if (showHint) {
|
if (showHint) {
|
||||||
@@ -75,59 +75,42 @@ class MonitorNotifications @Inject constructor(
|
|||||||
|
|
||||||
return baseBuilder(channelId).apply {
|
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 {
|
val stateText = when {
|
||||||
// Pods charging state
|
device.isHeadsetBeingCharged == true -> {
|
||||||
// This goes first as pods should not be worn if it is still charging
|
|
||||||
device is HasChargeDetection && device.isHeadsetBeingCharged -> {
|
|
||||||
context.getString(R.string.pods_charging_label)
|
context.getString(R.string.pods_charging_label)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pods wear state
|
device.hasEarDetection -> {
|
||||||
device is HasEarDetection -> {
|
if (device.isBeingWorn == true) context.getString(R.string.headset_being_worn_label)
|
||||||
if (device.isBeingWorn) context.getString(R.string.headset_being_worn_label)
|
|
||||||
else context.getString(R.string.headset_not_being_worn_label)
|
else context.getString(R.string.headset_not_being_worn_label)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case charge state
|
device.hasCase && device.isCaseCharging == true -> {
|
||||||
// This is under pods wear state as we don't want it conflicting with it
|
|
||||||
device is HasCase && device.isCaseCharging -> {
|
|
||||||
context.getString(R.string.pods_charging_label)
|
context.getString(R.string.pods_charging_label)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> context.getString(R.string.pods_case_unknown_state)
|
else -> context.getString(R.string.pods_case_unknown_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
val batteryText = when (device) {
|
val batteryText = when {
|
||||||
is DualPodDevice -> {
|
device.hasDualPods -> {
|
||||||
val leftPercent = device.batteryLeftPodPercent
|
val left = formatBatteryPercent(context, device.batteryLeft)
|
||||||
val rightPercent = device.batteryRightPodPercent
|
val right = formatBatteryPercent(context, device.batteryRight)
|
||||||
val left = formatBatteryPercent(context, leftPercent)
|
if (device.hasCase) {
|
||||||
val right = formatBatteryPercent(context, rightPercent)
|
val case = formatBatteryPercent(context, device.batteryCase)
|
||||||
when {
|
"$left $case $right"
|
||||||
device is HasCase -> {
|
} else {
|
||||||
val casePercent = device.batteryCasePercent
|
"$left $right"
|
||||||
val case = formatBatteryPercent(context, casePercent)
|
|
||||||
"$left $case $right"
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> "$left $right"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is SinglePodDevice -> {
|
device.model != PodModel.UNKNOWN -> {
|
||||||
val headsetPercent = device.batteryHeadsetPercent
|
val headset = formatBatteryPercent(context, device.batteryHeadset)
|
||||||
val headset = formatBatteryPercent(context, headsetPercent)
|
if (device.hasCase) {
|
||||||
when {
|
val case = formatBatteryPercent(context, device.batteryCase)
|
||||||
device is HasCase -> {
|
"$headset $case"
|
||||||
val casePercent = device.batteryCasePercent
|
} else {
|
||||||
val case = formatBatteryPercent(context, casePercent)
|
headset
|
||||||
"$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,538 @@
|
|||||||
|
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,
|
||||||
|
hasInCaseTone = 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,
|
||||||
|
hasInCaseTone = 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,
|
||||||
|
hasInCaseTone = 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,
|
||||||
|
hasInCaseTone = 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,
|
||||||
|
hasInCaseTone = 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,
|
||||||
|
val hasInCaseTone: Boolean = false,
|
||||||
|
// Protocol
|
||||||
|
val needsInitExt: Boolean = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
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.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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyOptimisticUpdate(currentState, command)
|
||||||
|
sendRaw(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.SetInCaseTone -> {
|
||||||
|
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled = command.enabled)
|
||||||
|
}
|
||||||
|
is AapCommand.SetDeviceName -> return // No optimistic state — name comes from deviceInfo
|
||||||
|
}
|
||||||
|
_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" }
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,27 @@
|
|||||||
|
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 SetInCaseTone(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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+475
@@ -0,0 +1,475 @@
|
|||||||
|
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
|
||||||
|
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.SetInCaseTone -> buildSettingsMessage(SETTING_IN_CASE_TONE, 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 + 5 - 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[5 - 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 {
|
||||||
|
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,
|
||||||
|
0x1E, 0x00,
|
||||||
|
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.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
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.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
import eu.darken.capod.pods.core.apple.ble.devices.misc.UnknownAppleSnapshotBle
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||||
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
|
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.AppleDeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import eu.darken.capod.profiles.core.currentProfiles
|
import eu.darken.capod.profiles.core.currentProfiles
|
||||||
@@ -27,7 +29,7 @@ class AppleFactory @Inject constructor(
|
|||||||
private val proximityPairingDecoder: ProximityPairing.Decoder,
|
private val proximityPairingDecoder: ProximityPairing.Decoder,
|
||||||
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
|
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
|
||||||
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory>,
|
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory>,
|
||||||
private val unknownAppleFactory: UnknownAppleDevice.Factory,
|
private val unknownAppleFactory: UnknownAppleSnapshotBle.Factory,
|
||||||
private val rpaChecker: RPAChecker,
|
private val rpaChecker: RPAChecker,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
) {
|
) {
|
||||||
@@ -59,7 +61,7 @@ class AppleFactory @Inject constructor(
|
|||||||
return proximityMessage
|
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 proximityMessage = getMessage(scanResult) ?: return@withLock null
|
||||||
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory
|
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ class AppleFactory @Inject constructor(
|
|||||||
meta = ApplePods.AppleMeta(),
|
meta = ApplePods.AppleMeta(),
|
||||||
)
|
)
|
||||||
profile = profiles
|
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 }
|
.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,78 @@
|
|||||||
|
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.abs
|
||||||
|
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
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
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 androidx.annotation.DrawableRes
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
|
|
||||||
interface DualPodDevice : PodDevice {
|
interface DualBlePodSnapshot : BlePodSnapshot {
|
||||||
|
|
||||||
enum class Pod {
|
enum class Pod {
|
||||||
LEFT,
|
LEFT,
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
package eu.darken.capod.pods.core
|
package eu.darken.capod.pods.core.apple.ble
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.icu.text.RelativeDateTimeFormatter
|
import android.icu.text.RelativeDateTimeFormatter
|
||||||
@@ -18,7 +18,7 @@ fun formatBatteryPercent(context: Context, percent: Float?): String =
|
|||||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
percent?.let { "${(it * 100).roundToInt()}%" }
|
||||||
?: context.getString(R.string.general_value_not_available_label)
|
?: context.getString(R.string.general_value_not_available_label)
|
||||||
|
|
||||||
fun PodDevice.getSignalQuality(context: Context): String {
|
fun BlePodSnapshot.getSignalQuality(context: Context): String {
|
||||||
val multiplier = 100 * signalQuality
|
val multiplier = 100 * signalQuality
|
||||||
return "${multiplier.roundToInt()}%"
|
return "${multiplier.roundToInt()}%"
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ fun getBatteryDrawable(percent: Float?): Int = when {
|
|||||||
else -> R.drawable.ic_baseline_battery_0_bar_24
|
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 formatter = RelativeDateTimeFormatter.getInstance()
|
||||||
val duration = Duration.between(seenLastAt, now)
|
val duration = Duration.between(seenLastAt, now)
|
||||||
return if (duration > Duration.ofMinutes(1)) {
|
return if (duration > Duration.ofMinutes(1)) {
|
||||||
@@ -54,7 +54,7 @@ fun PodDevice.lastSeenFormatted(now: Instant): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun PodDevice.firstSeenFormatted(now: Instant): String {
|
fun BlePodSnapshot.firstSeenFormatted(now: Instant): String {
|
||||||
val formatter = RelativeDateTimeFormatter.getInstance()
|
val formatter = RelativeDateTimeFormatter.getInstance()
|
||||||
val duration = Duration.between(seenFirstAt, now)
|
val duration = Duration.between(seenFirstAt, now)
|
||||||
return formatter.format(
|
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 dagger.Reusable
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
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.Logging.Priority.VERBOSE
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.apple.AppleFactory
|
|
||||||
import eu.darken.capod.pods.core.unknown.UnknownDeviceFactory
|
import eu.darken.capod.pods.core.unknown.UnknownDeviceFactory
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ class PodFactory @Inject constructor(
|
|||||||
|
|
||||||
data class Result(
|
data class Result(
|
||||||
val scanResult: BleScanResult,
|
val scanResult: BleScanResult,
|
||||||
val device: PodDevice
|
val device: BlePodSnapshot
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
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.lowerNibble
|
||||||
import eu.darken.capod.common.upperNibble
|
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.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
|
val payload: ProximityPayload
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ interface ApplePods : PodDevice {
|
|||||||
data class AppleMeta(
|
data class AppleMeta(
|
||||||
val isIRKMatch: Boolean = false,
|
val isIRKMatch: Boolean = false,
|
||||||
override val profile: AppleDeviceProfile? = null,
|
override val profile: AppleDeviceProfile? = null,
|
||||||
) : PodDevice.Meta
|
) : BlePodSnapshot.Meta
|
||||||
|
|
||||||
override val meta: AppleMeta
|
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.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.pods.core.apple.history.KnownDevice
|
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||||
|
|
||||||
interface ApplePodsFactory {
|
interface ApplePodsFactory {
|
||||||
fun isResponsible(message: ProximityMessage): Boolean
|
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.debug.logging.log
|
||||||
import eu.darken.capod.common.isBitSet
|
import eu.darken.capod.common.isBitSet
|
||||||
import eu.darken.capod.common.lowerNibble
|
import eu.darken.capod.common.lowerNibble
|
||||||
import eu.darken.capod.common.upperNibble
|
import eu.darken.capod.common.upperNibble
|
||||||
import eu.darken.capod.pods.core.DualPodDevice
|
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.DualPodDevice.Pod
|
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot.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
|
|
||||||
|
|
||||||
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
|
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot, HasEarDetectionDual, HasCase,
|
||||||
HasDualMicrophone, HasAppleColor {
|
HasDualMicrophone, HasAppleColor {
|
||||||
|
|
||||||
val primaryPod: Pod
|
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 android.content.Context
|
||||||
import eu.darken.capod.pods.core.HasPodStyle
|
|
||||||
import eu.darken.capod.R
|
|
||||||
|
|
||||||
interface HasAppleColor : ApplePods, HasPodStyle {
|
interface HasAppleColor : ApplePods, HasPodStyle {
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ interface HasAppleColor : ApplePods, HasPodStyle {
|
|||||||
|
|
||||||
override fun getLabel(context: Context): String = this.name
|
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
|
override val identifier: String
|
||||||
get() = name
|
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 androidx.annotation.DrawableRes
|
||||||
import eu.darken.capod.R
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 android.content.Context
|
||||||
import androidx.annotation.ColorRes
|
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
|
import android.content.Context
|
||||||
|
|
||||||
+3
-3
@@ -1,13 +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.debug.logging.log
|
||||||
import eu.darken.capod.common.lowerNibble
|
import eu.darken.capod.common.lowerNibble
|
||||||
import eu.darken.capod.pods.core.SinglePodDevice
|
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Devices that only present a single charge level, e.g. most Beats devices
|
* Devices that only present a single charge level, e.g. most Beats devices
|
||||||
*/
|
*/
|
||||||
interface SingleApplePods : ApplePods, SinglePodDevice, HasAppleColor {
|
interface SingleApplePods : ApplePods, SingleBlePodSnapshot, HasAppleColor {
|
||||||
|
|
||||||
override val batteryHeadsetPercent: Float?
|
override val batteryHeadsetPercent: Float?
|
||||||
get() {
|
get() {
|
||||||
+13
-12
@@ -1,33 +1,34 @@
|
|||||||
package eu.darken.capod.pods.core.apple.airpods
|
package eu.darken.capod.pods.core.apple.ble.devices.airpods
|
||||||
|
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
import eu.darken.capod.pods.core.apple.ble.history.PodHistoryRepo
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class AirPodsGen1(
|
data class AirPodsGen1(
|
||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
|
||||||
override val seenLastAt: Instant = Instant.now(),
|
override val seenLastAt: Instant = Instant.now(),
|
||||||
override val seenFirstAt: Instant = Instant.now(),
|
override val seenFirstAt: Instant = Instant.now(),
|
||||||
override val seenCounter: Int = 1,
|
override val seenCounter: Int = 1,
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val payload: ProximityPayload,
|
override val payload: ProximityPayload,
|
||||||
override val meta: ApplePods.AppleMeta,
|
override val meta: ApplePods.AppleMeta,
|
||||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
|
||||||
private val rssiAverage: Int? = null,
|
private val rssiAverage: Int? = null,
|
||||||
private val cachedBatteryPercentage: Float? = null,
|
private val cachedBatteryPercentage: Float? = null,
|
||||||
private val cachedCaseState: DualApplePods.LidState? = null
|
private val cachedCaseState: DualApplePods.LidState? = null
|
||||||
) : DualApplePods {
|
) : DualApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
|
override val model: PodModel = PodModel.AIRPODS_GEN1
|
||||||
|
|
||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
+13
-12
@@ -1,33 +1,34 @@
|
|||||||
package eu.darken.capod.pods.core.apple.airpods
|
package eu.darken.capod.pods.core.apple.ble.devices.airpods
|
||||||
|
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
import eu.darken.capod.pods.core.apple.ble.history.PodHistoryRepo
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class AirPodsGen2(
|
data class AirPodsGen2(
|
||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
|
||||||
override val seenLastAt: Instant = Instant.now(),
|
override val seenLastAt: Instant = Instant.now(),
|
||||||
override val seenFirstAt: Instant = Instant.now(),
|
override val seenFirstAt: Instant = Instant.now(),
|
||||||
override val seenCounter: Int = 1,
|
override val seenCounter: Int = 1,
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val payload: ProximityPayload,
|
override val payload: ProximityPayload,
|
||||||
override val meta: ApplePods.AppleMeta,
|
override val meta: ApplePods.AppleMeta,
|
||||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
|
||||||
private val rssiAverage: Int? = null,
|
private val rssiAverage: Int? = null,
|
||||||
private val cachedBatteryPercentage: Float? = null,
|
private val cachedBatteryPercentage: Float? = null,
|
||||||
private val cachedCaseState: DualApplePods.LidState? = null
|
private val cachedCaseState: DualApplePods.LidState? = null
|
||||||
) : DualApplePods, HasStateDetectionAirPods {
|
) : DualApplePods, HasStateDetectionAirPods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
|
override val model: PodModel = PodModel.AIRPODS_GEN2
|
||||||
|
|
||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
+13
-12
@@ -1,35 +1,36 @@
|
|||||||
package eu.darken.capod.pods.core.apple.airpods
|
package eu.darken.capod.pods.core.apple.ble.devices.airpods
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
import eu.darken.capod.pods.core.apple.ble.history.PodHistoryRepo
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class AirPodsGen3(
|
data class AirPodsGen3(
|
||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
|
||||||
override val seenLastAt: Instant = Instant.now(),
|
override val seenLastAt: Instant = Instant.now(),
|
||||||
override val seenFirstAt: Instant = Instant.now(),
|
override val seenFirstAt: Instant = Instant.now(),
|
||||||
override val seenCounter: Int = 1,
|
override val seenCounter: Int = 1,
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val payload: ProximityPayload,
|
override val payload: ProximityPayload,
|
||||||
override val meta: ApplePods.AppleMeta,
|
override val meta: ApplePods.AppleMeta,
|
||||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
|
||||||
private val rssiAverage: Int? = null,
|
private val rssiAverage: Int? = null,
|
||||||
private val cachedBatteryPercentage: Float? = null,
|
private val cachedBatteryPercentage: Float? = null,
|
||||||
private val cachedCaseState: DualApplePods.LidState? = null
|
private val cachedCaseState: DualApplePods.LidState? = null
|
||||||
) : DualApplePods, HasStateDetectionAirPods {
|
) : DualApplePods, HasStateDetectionAirPods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
|
override val model: PodModel = PodModel.AIRPODS_GEN3
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val leftPodIcon: Int
|
override val leftPodIcon: Int
|
||||||
+13
-12
@@ -1,35 +1,36 @@
|
|||||||
package eu.darken.capod.pods.core.apple.airpods
|
package eu.darken.capod.pods.core.apple.ble.devices.airpods
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
import eu.darken.capod.pods.core.apple.ble.history.PodHistoryRepo
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class AirPodsGen4(
|
data class AirPodsGen4(
|
||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
|
||||||
override val seenLastAt: Instant = Instant.now(),
|
override val seenLastAt: Instant = Instant.now(),
|
||||||
override val seenFirstAt: Instant = Instant.now(),
|
override val seenFirstAt: Instant = Instant.now(),
|
||||||
override val seenCounter: Int = 1,
|
override val seenCounter: Int = 1,
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val payload: ProximityPayload,
|
override val payload: ProximityPayload,
|
||||||
override val meta: ApplePods.AppleMeta,
|
override val meta: ApplePods.AppleMeta,
|
||||||
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
|
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
|
||||||
private val rssiAverage: Int? = null,
|
private val rssiAverage: Int? = null,
|
||||||
private val cachedBatteryPercentage: Float? = null,
|
private val cachedBatteryPercentage: Float? = null,
|
||||||
private val cachedCaseState: DualApplePods.LidState? = null
|
private val cachedCaseState: DualApplePods.LidState? = null
|
||||||
) : DualApplePods, HasStateDetectionAirPods {
|
) : DualApplePods, HasStateDetectionAirPods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4
|
override val model: PodModel = PodModel.AIRPODS_GEN4
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val leftPodIcon: Int
|
override val leftPodIcon: Int
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user