mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-07-06 04:10:17 +00:00
Compare commits
26 Commits
v0.1.0-rc.
...
v0.1.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8e9765aff | ||
|
|
62aabe80c1 | ||
|
|
dc0b06a369 | ||
|
|
96baebee28 | ||
|
|
c05a37bcca | ||
|
|
8a69dbe173 | ||
|
|
b783b86b7a | ||
|
|
445c999208 | ||
|
|
96e63cf35e | ||
|
|
5472e09293 | ||
|
|
e852182b48 | ||
|
|
5eb13ace0c | ||
|
|
2b1fb5b71e | ||
|
|
c95a619465 | ||
|
|
c4bc47c48a | ||
|
|
6a026ebab0 | ||
|
|
f3ed3bbc70 | ||
|
|
5fe123f544 | ||
|
|
09e1aa1530 | ||
|
|
fd917d3fd0 | ||
|
|
84891a0bdf | ||
|
|
4b3cc92e56 | ||
|
|
b89d6d9dc2 | ||
|
|
6985aa4a7b | ||
|
|
9161f8b294 | ||
|
|
4c0381968f |
@@ -184,7 +184,7 @@ Example packet:
|
|||||||
040004001d0002d5000400416972506f64732050726f004133303438004170706c6520496e632e0051584e524848595850360036312e313836383034303030323030303030302e323731330036312e313836383034303030323030303030302e3237313300312e302e3000636f6d2e6170706c652e6163636573736f72792e757064617465722e6170702e3731004859394c5432454632364a59004833504c5748444a32364b3000363335373533360089312a6567a5400f84a3ca234947efd40b90d78436ae5946748d70273e66066a2589300035333935303630363400```
|
040004001d0002d5000400416972506f64732050726f004133303438004170706c6520496e632e0051584e524848595850360036312e313836383034303030323030303030302e323731330036312e313836383034303030323030303030302e3237313300312e302e3000636f6d2e6170706c652e6163636573736f72792e757064617465722e6170702e3731004859394c5432454632364a59004833504c5748444a32364b3000363335373533360089312a6567a5400f84a3ca234947efd40b90d78436ae5946748d70273e66066a2589300035333935303630363400```
|
||||||
|
|
||||||
The packet contains device identification and version information followed by some encrypted data whose format is not known.
|
The packet contains device identification and version information followed by some encrypted data whose format is not known.
|
||||||
|
```
|
||||||
|
|
||||||
# Writing to the AirPods
|
# Writing to the AirPods
|
||||||
|
|
||||||
@@ -442,4 +442,4 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|||||||
GNU Affero General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "me.kavishdevar.librepods"
|
applicationId = "me.kavishdevar.librepods"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 6
|
versionCode = 7
|
||||||
versionName = "0.1.0-rc.3"
|
versionName = "0.1.0-rc.4"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -61,5 +61,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
implementation(libs.haze)
|
implementation(libs.haze)
|
||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.materials)
|
||||||
|
implementation(libs.androidx.dynamicanimation)
|
||||||
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
android:sharedUserId="android.uid.system"
|
|
||||||
android:sharedUserMaxSdkVersion="32"
|
|
||||||
tools:targetApi="33">
|
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.telephony"
|
android:name="android.hardware.telephony"
|
||||||
@@ -31,7 +28,11 @@
|
|||||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
|
||||||
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
|
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -27,6 +29,7 @@ import android.content.Intent
|
|||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
@@ -113,6 +116,7 @@ import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
|||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.CrossDevice
|
import me.kavishdevar.librepods.utils.CrossDevice
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
lateinit var serviceConnection: ServiceConnection
|
lateinit var serviceConnection: ServiceConnection
|
||||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||||
@@ -183,17 +187,30 @@ fun Main() {
|
|||||||
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||||
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
||||||
|
|
||||||
val permissionState = rememberMultiplePermissionsState(
|
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
permissions = listOf(
|
listOf(
|
||||||
"android.permission.BLUETOOTH_CONNECT",
|
"android.permission.BLUETOOTH_CONNECT",
|
||||||
"android.permission.BLUETOOTH_SCAN",
|
"android.permission.BLUETOOTH_SCAN",
|
||||||
"android.permission.BLUETOOTH",
|
"android.permission.BLUETOOTH",
|
||||||
"android.permission.BLUETOOTH_ADMIN",
|
"android.permission.BLUETOOTH_ADMIN",
|
||||||
"android.permission.BLUETOOTH_ADVERTISE",
|
"android.permission.BLUETOOTH_ADVERTISE"
|
||||||
"android.permission.POST_NOTIFICATIONS",
|
|
||||||
"android.permission.READ_PHONE_STATE",
|
|
||||||
"android.permission.ANSWER_PHONE_CALLS",
|
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
"android.permission.BLUETOOTH",
|
||||||
|
"android.permission.BLUETOOTH_ADMIN",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val otherPermissions = listOf(
|
||||||
|
"android.permission.POST_NOTIFICATIONS",
|
||||||
|
"android.permission.READ_PHONE_STATE",
|
||||||
|
"android.permission.ANSWER_PHONE_CALLS"
|
||||||
|
)
|
||||||
|
val allPermissions = bluetoothPermissions + otherPermissions
|
||||||
|
|
||||||
|
val permissionState = rememberMultiplePermissionsState(
|
||||||
|
permissions = allPermissions
|
||||||
)
|
)
|
||||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -72,16 +75,12 @@ import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedBu
|
|||||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
data class DismissAnimationValues(
|
|
||||||
val offsetY: Dp = 0.dp,
|
|
||||||
val scale: Float = 1f,
|
|
||||||
val alpha: Float = 1f
|
|
||||||
)
|
|
||||||
|
|
||||||
class QuickSettingsDialogActivity : ComponentActivity() {
|
class QuickSettingsDialogActivity : ComponentActivity() {
|
||||||
|
|
||||||
private var airPodsService: AirPodsService? = null
|
private var airPodsService: AirPodsService? = null
|
||||||
@@ -114,7 +113,6 @@ class QuickSettingsDialogActivity : ComponentActivity() {
|
|||||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +157,6 @@ class QuickSettingsDialogActivity : ComponentActivity() {
|
|||||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +179,6 @@ fun DraggableDismissBox(
|
|||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val density = LocalDensity.current
|
|
||||||
|
|
||||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
@@ -218,7 +214,6 @@ fun DraggableDismissBox(
|
|||||||
|
|
||||||
LaunchedEffect(dragOffset, isDragging) {
|
LaunchedEffect(dragOffset, isDragging) {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
val dragDirection = if (dragOffset > 0) 1f else -1f
|
|
||||||
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
||||||
|
|
||||||
animatedOffset.snapTo(dragOffset)
|
animatedOffset.snapTo(dragOffset)
|
||||||
@@ -285,6 +280,7 @@ fun DraggableDismissBox(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
@Composable
|
@Composable
|
||||||
fun NewControlCenterDialogContent(
|
fun NewControlCenterDialogContent(
|
||||||
service: AirPodsService?,
|
service: AirPodsService?,
|
||||||
@@ -353,7 +349,7 @@ fun NewControlCenterDialogContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
service?.let {
|
service?.let {
|
||||||
val initialModeOrdinal = it.getANC().minus(1) ?: NoiseControlMode.TRANSPARENCY.ordinal
|
val initialModeOrdinal = it.getANC().minus(1)
|
||||||
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||||
if (!availableModes.contains(initialMode)) {
|
if (!availableModes.contains(initialMode)) {
|
||||||
initialMode = NoiseControlMode.TRANSPARENCY
|
initialMode = NoiseControlMode.TRANSPARENCY
|
||||||
@@ -482,7 +478,10 @@ fun NewControlCenterDialogContent(
|
|||||||
availableModes = availableModes,
|
availableModes = availableModes,
|
||||||
selectedMode = currentAncMode,
|
selectedMode = currentAncMode,
|
||||||
onModeSelected = { newMode ->
|
onModeSelected = { newMode ->
|
||||||
service.setANCMode(newMode.ordinal + 1)
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||||
|
value = newMode.ordinal + 1
|
||||||
|
)
|
||||||
currentAncMode = newMode
|
currentAncMode = newMode
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(0.8f)
|
modifier = Modifier.fillMaxWidth(0.8f)
|
||||||
@@ -560,7 +559,10 @@ fun NewControlCenterDialogContent(
|
|||||||
.clickable(
|
.clickable(
|
||||||
onClick = {
|
onClick = {
|
||||||
val newState = !isConvAwarenessEnabled
|
val newState = !isConvAwarenessEnabled
|
||||||
service.setCAEnabled(newState)
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||||
|
value = newState
|
||||||
|
)
|
||||||
isConvAwarenessEnabled = newState
|
isConvAwarenessEnabled = newState
|
||||||
},
|
},
|
||||||
indication = null,
|
indication = null,
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -38,7 +38,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -46,14 +45,16 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun AccessibilitySettings() {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.accessibility).uppercase(),
|
text = stringResource(R.string.accessibility).uppercase(),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -87,51 +88,75 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences)
|
ToneVolumeSlider()
|
||||||
}
|
}
|
||||||
|
|
||||||
val pressSpeedOptions = listOf("Default", "Slower", "Slowest")
|
val pressSpeedOptions = mapOf(
|
||||||
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[0]) }
|
0.toByte() to "Default",
|
||||||
|
1.toByte() to "Slower",
|
||||||
|
2.toByte() to "Slowest"
|
||||||
|
)
|
||||||
|
val selectedPressSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) }
|
||||||
DropdownMenuComponent(
|
DropdownMenuComponent(
|
||||||
label = "Press Speed",
|
label = "Press Speed",
|
||||||
options = pressSpeedOptions,
|
options = pressSpeedOptions.values.toList(),
|
||||||
selectedOption = selectedPressSpeed,
|
selectedOption = selectedPressSpeed.toString(),
|
||||||
onOptionSelected = {
|
onOptionSelected = { newValue ->
|
||||||
selectedPressSpeed = it
|
selectedPressSpeed = newValue
|
||||||
service.setPressSpeed(pressSpeedOptions.indexOf(it))
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
|
||||||
|
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
val pressAndHoldDurationOptions = listOf("Default", "Slower", "Slowest")
|
val pressAndHoldDurationOptions = mapOf(
|
||||||
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[0]) }
|
0.toByte() to "Default",
|
||||||
|
1.toByte() to "Slower",
|
||||||
|
2.toByte() to "Slowest"
|
||||||
|
)
|
||||||
|
|
||||||
|
val selectedPressAndHoldDurationValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) }
|
||||||
DropdownMenuComponent(
|
DropdownMenuComponent(
|
||||||
label = "Press and Hold Duration",
|
label = "Press and Hold Duration",
|
||||||
options = pressAndHoldDurationOptions,
|
options = pressAndHoldDurationOptions.values.toList(),
|
||||||
selectedOption = selectedPressAndHoldDuration,
|
selectedOption = selectedPressAndHoldDuration.toString(),
|
||||||
onOptionSelected = {
|
onOptionSelected = { newValue ->
|
||||||
selectedPressAndHoldDuration = it
|
selectedPressAndHoldDuration = newValue
|
||||||
service.setPressAndHoldDuration(pressAndHoldDurationOptions.indexOf(it))
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
|
||||||
|
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
val volumeSwipeSpeedOptions = listOf("Default", "Longer", "Longest")
|
val volumeSwipeSpeedOptions = mapOf<Byte, String>(
|
||||||
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[0]) }
|
1.toByte() to "Default",
|
||||||
|
2.toByte() to "Longer",
|
||||||
|
3.toByte() to "Longest"
|
||||||
|
)
|
||||||
|
val selectedVolumeSwipeSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) }
|
||||||
DropdownMenuComponent(
|
DropdownMenuComponent(
|
||||||
label = "Volume Swipe Speed",
|
label = "Volume Swipe Speed",
|
||||||
options = volumeSwipeSpeedOptions,
|
options = volumeSwipeSpeedOptions.values.toList(),
|
||||||
selectedOption = selectedVolumeSwipeSpeed,
|
selectedOption = selectedVolumeSwipeSpeed.toString(),
|
||||||
onOptionSelected = {
|
onOptionSelected = { newValue ->
|
||||||
selectedVolumeSwipeSpeed = it
|
selectedVolumeSwipeSpeed = newValue
|
||||||
service.setVolumeSwipeSpeed(volumeSwipeSpeedOptions.indexOf(it))
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
|
||||||
|
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences)
|
SinglePodANCSwitch()
|
||||||
VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences)
|
VolumeControlSwitch()
|
||||||
// TransparencySettings(service = service, sharedPreferences = sharedPreferences)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,5 +217,5 @@ fun DropdownMenuComponent(
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AccessibilitySettingsPreview() {
|
fun AccessibilitySettingsPreview() {
|
||||||
AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
AccessibilitySettings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -44,26 +44,26 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
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.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun AdaptiveStrengthSlider() {
|
||||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
LaunchedEffect(sliderValue) {
|
LaunchedEffect(sliderValue) {
|
||||||
if (sharedPreferences.contains("adaptive_strength")) {
|
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||||
sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat()
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
|
||||||
}
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
}
|
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
|
||||||
LaunchedEffect(sliderValue.floatValue) {
|
|
||||||
sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -86,7 +86,10 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
|
|||||||
valueRange = 0f..100f,
|
valueRange = 0f..100f,
|
||||||
onValueChangeFinished = {
|
onValueChangeFinished = {
|
||||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||||
service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt())
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
|
||||||
|
value = (100 - sliderValue.floatValue).toInt()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -151,5 +154,5 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveStrengthSliderPreview() {
|
fun AdaptiveStrengthSliderPreview() {
|
||||||
AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
AdaptiveStrengthSlider()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -30,7 +30,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -38,10 +37,10 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun AudioSettings() {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
@@ -64,9 +63,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
|||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences)
|
ConversationalAwarenessSwitch()
|
||||||
ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences)
|
|
||||||
LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences)
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -95,7 +92,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
AdaptiveStrengthSlider(service = service, sharedPreferences = sharedPreferences)
|
AdaptiveStrengthSlider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,5 +100,5 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettingsPreview() {
|
fun AudioSettingsPreview() {
|
||||||
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
AudioSettings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
@@ -50,6 +52,7 @@ import me.kavishdevar.librepods.utils.AirPodsNotifications
|
|||||||
import me.kavishdevar.librepods.utils.Battery
|
import me.kavishdevar.librepods.utils.Battery
|
||||||
import me.kavishdevar.librepods.utils.BatteryComponent
|
import me.kavishdevar.librepods.utils.BatteryComponent
|
||||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import androidx.compose.material3.Icon
|
|||||||
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
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -88,7 +88,7 @@ fun ControlCenterNoiseControlSegmentedButton(
|
|||||||
) {
|
) {
|
||||||
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
|
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
var iconRowWidthPx by remember { mutableStateOf(0f) }
|
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
|
||||||
val itemCount = availableModes.size
|
val itemCount = availableModes.size
|
||||||
|
|
||||||
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
|
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
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.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun ConversationalAwarenessSwitch() {
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
|
val conversationEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
var conversationalAwarenessEnabled by remember {
|
var conversationalAwarenessEnabled by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
sharedPreferences.getBoolean("conversational_awareness", true)
|
conversationEnabledValue == 1.toByte()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateConversationalAwareness(enabled: Boolean) {
|
fun updateConversationalAwareness(enabled: Boolean) {
|
||||||
conversationalAwarenessEnabled = enabled
|
conversationalAwarenessEnabled = enabled
|
||||||
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
|
service.aacpManager.sendControlCommand(
|
||||||
service.setCAEnabled(enabled)
|
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||||
|
enabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -121,5 +129,5 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun ConversationalAwarenessSwitchPreview() {
|
fun ConversationalAwarenessSwitchPreview() {
|
||||||
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
ConversationalAwarenessSwitch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
@@ -46,17 +48,40 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false) {
|
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val snakeCasedName =
|
||||||
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
|
controlCommandIdentifier?.name ?: name.replace(Regex("[\\W\\s]+"), "_").lowercase()
|
||||||
var checked by remember { mutableStateOf(default) }
|
var checked by remember { mutableStateOf(default) }
|
||||||
|
|
||||||
|
if (controlCommandIdentifier != null) {
|
||||||
|
checked = service!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == controlCommandIdentifier
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
|
||||||
|
fun cb() {
|
||||||
|
if (controlCommandIdentifier == null) {
|
||||||
|
sharedPreferences.edit().putBoolean(snakeCasedName, checked).apply()
|
||||||
|
}
|
||||||
|
if (functionName != null && service != null) {
|
||||||
|
val method =
|
||||||
|
service::class.java.getMethod(functionName, Boolean::class.java)
|
||||||
|
method.invoke(service, checked)
|
||||||
|
}
|
||||||
|
if (controlCommandIdentifier != null) {
|
||||||
|
service?.aacpManager?.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(sharedPreferences) {
|
LaunchedEffect(sharedPreferences) {
|
||||||
checked = sharedPreferences.getBoolean(snakeCasedName, true)
|
checked = sharedPreferences.getBoolean(snakeCasedName, true)
|
||||||
}
|
}
|
||||||
@@ -73,14 +98,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
|
|||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
checked = !checked
|
checked = !checked
|
||||||
sharedPreferences
|
cb()
|
||||||
.edit()
|
|
||||||
.putBoolean(snakeCasedName, checked)
|
|
||||||
.apply()
|
|
||||||
if (functionName != null && service != null) {
|
|
||||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
|
||||||
method.invoke(service, checked)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -98,12 +116,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
|
|||||||
checked = checked,
|
checked = checked,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
checked = it
|
checked = it
|
||||||
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
|
cb()
|
||||||
if (functionName != null && service != null) {
|
|
||||||
val method =
|
|
||||||
service::class.java.getMethod(functionName, Boolean::class.java)
|
|
||||||
method.invoke(service, it)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
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.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
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.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
|
||||||
var loudSoundReductionEnabled by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
sharedPreferences.getBoolean("loud_sound_reduction", true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateLoudSoundReduction(enabled: Boolean) {
|
|
||||||
loudSoundReductionEnabled = enabled
|
|
||||||
sharedPreferences.edit().putBoolean("loud_sound_reduction", enabled).apply()
|
|
||||||
service.setLoudSoundReduction(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
updateLoudSoundReduction(!loudSoundReductionEnabled)
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Loud Sound Reduction",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Reduces loud sounds you are exposed to.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSwitch(
|
|
||||||
checked = loudSoundReductionEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
updateLoudSoundReduction(it)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun LoudSoundReductionSwitchPreview() {
|
|
||||||
LoudSoundReductionSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -23,7 +25,6 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.animation.core.AnimationSpec
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
@@ -50,7 +51,6 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -74,35 +74,34 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseControlSettings(
|
fun NoiseControlSettings(
|
||||||
service: AirPodsService,
|
service: AirPodsService,
|
||||||
onModeSelectedCallback: () -> Unit = {} // Callback parameter remains, but won't finish activity
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
||||||
val offListeningMode = remember { mutableStateOf(sharedPreferences.getBoolean("off_listening_mode", true)) }
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||||
val preferenceChangeListener = remember {
|
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
|
||||||
if (key == "off_listening_mode") {
|
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
||||||
offListeningMode.value = sharedPreferences.getBoolean("off_listening_mode", true)
|
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||||
}
|
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
|
||||||
onDispose {
|
|
||||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service.aacpManager.registerControlCommandListener(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||||
|
offListeningModeListener
|
||||||
|
)
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
@@ -116,27 +115,21 @@ fun NoiseControlSettings(
|
|||||||
val d3a = remember { mutableFloatStateOf(0f) }
|
val d3a = remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||||
val previousMode = noiseControlMode.value // Store previous mode
|
val previousMode = noiseControlMode.value
|
||||||
|
|
||||||
// Ensure the mode is valid if 'Off' is disabled
|
|
||||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
||||||
// If trying to select OFF but it's disabled, default to Transparency or Adaptive
|
NoiseControlMode.TRANSPARENCY
|
||||||
NoiseControlMode.TRANSPARENCY // Or ADAPTIVE, based on preference
|
|
||||||
} else {
|
} else {
|
||||||
mode
|
mode
|
||||||
}
|
}
|
||||||
|
|
||||||
noiseControlMode.value = targetMode // Update internal state immediately
|
noiseControlMode.value = targetMode
|
||||||
|
|
||||||
// Only call service if the mode was manually selected (!received)
|
|
||||||
// and the target mode is actually different from the previous mode
|
|
||||||
if (!received && targetMode != previousMode) {
|
if (!received && targetMode != previousMode) {
|
||||||
service.setANCMode(targetMode.ordinal + 1)
|
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
|
||||||
// onModeSelectedCallback() // REMOVE this call to keep dialog open
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update divider alphas based on the *new* mode
|
when (noiseControlMode.value) {
|
||||||
when (noiseControlMode.value) { // Use the updated noiseControlMode.value
|
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||||
d1a.floatValue = 1f
|
d1a.floatValue = 1f
|
||||||
d2a.floatValue = 1f
|
d2a.floatValue = 1f
|
||||||
@@ -447,5 +440,5 @@ fun NoiseControlSettings(
|
|||||||
@Preview()
|
@Preview()
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseControlSettingsPreview() {
|
fun NoiseControlSettingsPreview() {
|
||||||
NoiseControlSettings(AirPodsService()) {}
|
NoiseControlSettings(AirPodsService())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
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.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
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.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
|
||||||
var personalizedVolumeEnabled by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
sharedPreferences.getBoolean("personalized_volume", true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updatePersonalizedVolume(enabled: Boolean) {
|
|
||||||
personalizedVolumeEnabled = enabled
|
|
||||||
sharedPreferences.edit().putBoolean("personalized_volume", enabled).apply()
|
|
||||||
service.setPVEnabled(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
updatePersonalizedVolume(!personalizedVolumeEnabled)
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Personalized Volume",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Adjusts the volume of media in response to your environment.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSwitch(
|
|
||||||
checked = personalizedVolumeEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
updatePersonalizedVolume(it)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun PersonalizedVolumeSwitchPreview() {
|
|
||||||
PersonalizedVolumeSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
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.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun SinglePodANCSwitch() {
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
|
val singleANCEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
var singleANCEnabled by remember {
|
var singleANCEnabled by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
sharedPreferences.getBoolean("single_anc", true)
|
singleANCEnabledValue == 1.toByte()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSingleEnabled(enabled: Boolean) {
|
fun updateSingleEnabled(enabled: Boolean) {
|
||||||
singleANCEnabled = enabled
|
singleANCEnabled = enabled
|
||||||
sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
|
service.aacpManager.sendControlCommand(
|
||||||
service.setNoiseCancellationWithOnePod(enabled)
|
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value,
|
||||||
|
enabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -121,5 +129,5 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun SinglePodANCSwitchPreview() {
|
fun SinglePodANCSwitchPreview() {
|
||||||
SinglePodANCSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
SinglePodANCSwitch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -35,14 +37,12 @@ import androidx.compose.material3.Slider
|
|||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
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.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -51,21 +51,22 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun ToneVolumeSlider() {
|
||||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
val service = ServiceManager.getService()!!
|
||||||
LaunchedEffect(sliderValue) {
|
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||||
if (sharedPreferences.contains("tone_volume")) {
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
|
||||||
sliderValue.floatValue = sharedPreferences.getInt("tone_volume", 0).toFloat()
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
}
|
val sliderValue = remember { mutableFloatStateOf(
|
||||||
}
|
sliderValueFromAACP?.toFloat() ?: -1f
|
||||||
LaunchedEffect(sliderValue.floatValue) {
|
) }
|
||||||
sharedPreferences.edit().putInt("tone_volume", sliderValue.floatValue.toInt()).apply()
|
Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}")
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
|
||||||
@@ -74,7 +75,6 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
|||||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
@@ -99,7 +99,12 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
|||||||
valueRange = 0f..100f,
|
valueRange = 0f..100f,
|
||||||
onValueChangeFinished = {
|
onValueChangeFinished = {
|
||||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||||
service.setToneVolume(volume = sliderValue.floatValue.toInt())
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
|
||||||
|
value = byteArrayOf(sliderValue.floatValue.toInt().toByte(),
|
||||||
|
0x50.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -156,5 +161,5 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun ToneVolumeSliderPreview() {
|
fun ToneVolumeSliderPreview() {
|
||||||
ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
ToneVolumeSlider()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.SliderDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun TransparencySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
var transparencyModeCustomizationEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_mode_customization", false)) }
|
|
||||||
var amplification by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_amplification", 0)) }
|
|
||||||
var balance by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_balance", 0)) }
|
|
||||||
var tone by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_tone", 0)) }
|
|
||||||
var ambientNoise by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_ambient_noise", 0)) }
|
|
||||||
var conversationBoostEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_conversation_boost", false)) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
transparencyModeCustomizationEnabled = !transparencyModeCustomizationEnabled
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Transparency Mode",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "You can customize Transparency mode for your AirPods Pro.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = transparencyModeCustomizationEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
transparencyModeCustomizationEnabled = it
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (transparencyModeCustomizationEnabled) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
SliderRow(
|
|
||||||
label = "Amplification",
|
|
||||||
value = amplification,
|
|
||||||
onValueChange = {
|
|
||||||
amplification = it
|
|
||||||
sharedPreferences.edit().putInt("transparency_amplification", it).apply()
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
SliderRow(
|
|
||||||
label = "Balance",
|
|
||||||
value = balance,
|
|
||||||
onValueChange = {
|
|
||||||
balance = it
|
|
||||||
sharedPreferences.edit().putInt("transparency_balance", it).apply()
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
SliderRow(
|
|
||||||
label = "Tone",
|
|
||||||
value = tone,
|
|
||||||
onValueChange = {
|
|
||||||
tone = it
|
|
||||||
sharedPreferences.edit().putInt("transparency_tone", it).apply()
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
SliderRow(
|
|
||||||
label = "Ambient Noise",
|
|
||||||
value = ambientNoise,
|
|
||||||
onValueChange = {
|
|
||||||
ambientNoise = it
|
|
||||||
sharedPreferences.edit().putInt("transparency_ambient_noise", it).apply()
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
conversationBoostEnabled = !conversationBoostEnabled
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Conversation Boost",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Conversation Boost focuses your AirPods on the person in front of you, making it easier to hear in a face-to-face conversation.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = conversationBoostEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
conversationBoostEnabled = it
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SliderRow(
|
|
||||||
label: String,
|
|
||||||
value: Int,
|
|
||||||
onValueChange: (Int) -> Unit,
|
|
||||||
isDarkTheme: Boolean
|
|
||||||
) {
|
|
||||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
|
||||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
val thumbColor = Color(0xFFFFFFFF)
|
|
||||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = labelTextColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "\uDBC0\uDEA1",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = labelTextColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 4.dp)
|
|
||||||
)
|
|
||||||
Slider(
|
|
||||||
value = value.toFloat(),
|
|
||||||
onValueChange = {
|
|
||||||
onValueChange(it.toInt())
|
|
||||||
},
|
|
||||||
valueRange = 0f..100f,
|
|
||||||
onValueChangeFinished = {
|
|
||||||
onValueChange(value)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(36.dp),
|
|
||||||
colors = SliderDefaults.colors(
|
|
||||||
thumbColor = thumbColor,
|
|
||||||
activeTrackColor = activeTrackColor,
|
|
||||||
inactiveTrackColor = trackColor
|
|
||||||
),
|
|
||||||
thumb = {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.shadow(4.dp, CircleShape)
|
|
||||||
.background(thumbColor, CircleShape)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
track = {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(12.dp),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(4.dp)
|
|
||||||
.background(trackColor, RoundedCornerShape(4.dp))
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(value.toFloat() / 100)
|
|
||||||
.height(4.dp)
|
|
||||||
.background(activeTrackColor, RoundedCornerShape(4.dp))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "\uDBC0\uDEA9",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = labelTextColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(end = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -41,23 +42,30 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
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.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun VolumeControlSwitch() {
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
|
val volumeControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
var volumeControlEnabled by remember {
|
var volumeControlEnabled by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
sharedPreferences.getBoolean("volume_control", true)
|
volumeControlEnabledValue == 1.toByte()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
fun updateVolumeControlEnabled(enabled: Boolean) {
|
fun updateVolumeControlEnabled(enabled: Boolean) {
|
||||||
volumeControlEnabled = enabled
|
volumeControlEnabled = enabled
|
||||||
sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
|
service.aacpManager.sendControlCommand(
|
||||||
service.setVolumeControl(enabled)
|
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value,
|
||||||
|
enabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -120,5 +128,5 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun VolumeControlSwitchPreview() {
|
fun VolumeControlSwitchPreview() {
|
||||||
VolumeControlSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
VolumeControlSwitch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.receivers
|
package me.kavishdevar.librepods.receivers
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
|
||||||
class BootReceiver: BroadcastReceiver() {
|
class BootReceiver: BroadcastReceiver() {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -36,7 +38,6 @@ 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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -84,7 +85,6 @@ import androidx.navigation.NavController
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import dev.chrisbanes.haze.HazeEffectScope
|
import dev.chrisbanes.haze.HazeEffectScope
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.haze
|
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||||
@@ -101,7 +101,9 @@ import me.kavishdevar.librepods.composables.NoiseControlSettings
|
|||||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||||
@@ -355,7 +357,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
PressAndHoldSettings(navController = navController)
|
PressAndHoldSettings(navController = navController)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AudioSettings(service = service, sharedPreferences = sharedPreferences)
|
AudioSettings()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
IndependentToggle(
|
IndependentToggle(
|
||||||
@@ -363,20 +365,20 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
service = service,
|
service = service,
|
||||||
functionName = "setEarDetection",
|
functionName = "setEarDetection",
|
||||||
sharedPreferences = sharedPreferences,
|
sharedPreferences = sharedPreferences,
|
||||||
true
|
default = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
IndependentToggle(
|
IndependentToggle(
|
||||||
name = "Off Listening Mode",
|
name = "Off Listening Mode",
|
||||||
service = service,
|
service = service,
|
||||||
functionName = "setOffListeningMode",
|
|
||||||
sharedPreferences = sharedPreferences,
|
sharedPreferences = sharedPreferences,
|
||||||
false
|
default = false,
|
||||||
|
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AccessibilitySettings(service = service, sharedPreferences = sharedPreferences)
|
AccessibilitySettings()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
NavigationButton("debug", "Debug", navController)
|
NavigationButton("debug", "Debug", navController)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
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
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||||
@@ -49,11 +50,15 @@ import androidx.compose.material3.CenterAlignedTopAppBar
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -76,6 +81,8 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -88,10 +95,13 @@ import dev.chrisbanes.haze.materials.CupertinoMaterials
|
|||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledSwitch
|
import me.kavishdevar.librepods.composables.StyledSwitch
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppSettingsScreen(navController: NavController) {
|
fun AppSettingsScreen(navController: NavController) {
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
@@ -103,6 +113,35 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
|
|
||||||
var showResetDialog by remember { mutableStateOf(false) }
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
|
var showIrkDialog by remember { mutableStateOf(false) }
|
||||||
|
var showEncKeyDialog by remember { mutableStateOf(false) }
|
||||||
|
var irkValue by remember { mutableStateOf("") }
|
||||||
|
var encKeyValue by remember { mutableStateOf("") }
|
||||||
|
var irkError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var encKeyError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val savedIrk = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
||||||
|
val savedEncKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
||||||
|
|
||||||
|
if (savedIrk != null) {
|
||||||
|
try {
|
||||||
|
val decoded = Base64.decode(savedIrk)
|
||||||
|
irkValue = decoded.joinToString("") { "%02x".format(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
irkValue = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedEncKey != null) {
|
||||||
|
try {
|
||||||
|
val decoded = Base64.decode(savedEncKey)
|
||||||
|
encKeyValue = decoded.joinToString("") { "%02x".format(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
encKeyValue = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var showPhoneBatteryInWidget by remember {
|
var showPhoneBatteryInWidget by remember {
|
||||||
mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
|
mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
|
||||||
@@ -119,7 +158,34 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
var disconnectWhenNotWearing by remember {
|
var disconnectWhenNotWearing by remember {
|
||||||
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
|
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var takeoverWhenDisconnected by remember {
|
||||||
|
mutableStateOf(sharedPreferences.getBoolean("takeover_when_disconnected", true))
|
||||||
|
}
|
||||||
|
var takeoverWhenIdle by remember {
|
||||||
|
mutableStateOf(sharedPreferences.getBoolean("takeover_when_idle", true))
|
||||||
|
}
|
||||||
|
var takeoverWhenMusic by remember {
|
||||||
|
mutableStateOf(sharedPreferences.getBoolean("takeover_when_music", false))
|
||||||
|
}
|
||||||
|
var takeoverWhenCall by remember {
|
||||||
|
mutableStateOf(sharedPreferences.getBoolean("takeover_when_call", true))
|
||||||
|
}
|
||||||
|
|
||||||
|
var takeoverWhenRingingCall by remember {
|
||||||
|
mutableStateOf(sharedPreferences.getBoolean("takeover_when_ringing_call", true))
|
||||||
|
}
|
||||||
|
var takeoverWhenMediaStart by remember {
|
||||||
|
mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true))
|
||||||
|
}
|
||||||
|
|
||||||
var mDensity by remember { mutableFloatStateOf(0f) }
|
var mDensity by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
fun validateHexInput(input: String): Boolean {
|
||||||
|
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
||||||
|
return hexPattern.matches(input)
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -607,6 +673,293 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_header).uppercase(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
backgroundColor,
|
||||||
|
RoundedCornerShape(14.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_airpods_state),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = textColor,
|
||||||
|
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
takeoverWhenDisconnected = !takeoverWhenDisconnected
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_disconnected", takeoverWhenDisconnected).apply()
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_disconnected),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_disconnected_desc),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = takeoverWhenDisconnected,
|
||||||
|
onCheckedChange = {
|
||||||
|
takeoverWhenDisconnected = it
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_disconnected", it).apply()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
takeoverWhenIdle = !takeoverWhenIdle
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_idle", takeoverWhenIdle).apply()
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_idle),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_idle_desc),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = takeoverWhenIdle,
|
||||||
|
onCheckedChange = {
|
||||||
|
takeoverWhenIdle = it
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_idle", it).apply()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
takeoverWhenMusic = !takeoverWhenMusic
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_music", takeoverWhenMusic).apply()
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_music),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_music_desc),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = takeoverWhenMusic,
|
||||||
|
onCheckedChange = {
|
||||||
|
takeoverWhenMusic = it
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_music", it).apply()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
takeoverWhenCall = !takeoverWhenCall
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_call", takeoverWhenCall).apply()
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_call),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_call_desc),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = takeoverWhenCall,
|
||||||
|
onCheckedChange = {
|
||||||
|
takeoverWhenCall = it
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_call", it).apply()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_phone_state),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = textColor,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
takeoverWhenRingingCall = !takeoverWhenRingingCall
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_ringing_call", takeoverWhenRingingCall).apply()
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_ringing_call),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_ringing_call_desc),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = takeoverWhenRingingCall,
|
||||||
|
onCheckedChange = {
|
||||||
|
takeoverWhenRingingCall = it
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_ringing_call", it).apply()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
takeoverWhenMediaStart = !takeoverWhenMediaStart
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_media_start", takeoverWhenMediaStart).apply()
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_media_start),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_media_start_desc),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = takeoverWhenMediaStart,
|
||||||
|
onCheckedChange = {
|
||||||
|
takeoverWhenMediaStart = it
|
||||||
|
sharedPreferences.edit().putBoolean("takeover_when_media_start", it).apply()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Advanced Options".uppercase(),
|
text = "Advanced Options".uppercase(),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -629,6 +982,64 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
showIrkDialog = true
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Set Identity Resolving Key (IRK)",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Manually set the IRK value used for resolving BLE random addresses",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
showEncKeyDialog = true
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Set Encryption Key",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Manually set the ENC_KEY value used for decrypting BLE advertisements",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -758,6 +1169,184 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showIrkDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showIrkDialog = false },
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"Set Identity Resolving Key (IRK)",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"Enter 16-byte IRK as hex string (32 characters):",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = irkValue,
|
||||||
|
onValueChange = {
|
||||||
|
irkValue = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
||||||
|
irkError = null
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = irkError != null,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
capitalization = KeyboardCapitalization.None
|
||||||
|
),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||||
|
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
||||||
|
),
|
||||||
|
supportingText = {
|
||||||
|
if (irkError != null) {
|
||||||
|
Text(irkError!!, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("IRK Hex Value") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (!validateHexInput(irkValue)) {
|
||||||
|
irkError = "Must be exactly 32 hex characters"
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val hexBytes = ByteArray(16)
|
||||||
|
for (i in 0 until 16) {
|
||||||
|
val hexByte = irkValue.substring(i * 2, i * 2 + 2)
|
||||||
|
hexBytes[i] = hexByte.toInt(16).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
val base64Value = Base64.encode(hexBytes)
|
||||||
|
sharedPreferences.edit().putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value).apply()
|
||||||
|
|
||||||
|
Toast.makeText(context, "IRK has been set successfully", Toast.LENGTH_SHORT).show()
|
||||||
|
showIrkDialog = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
irkError = "Error converting hex: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Save",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { showIrkDialog = false }
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Cancel",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showEncKeyDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showEncKeyDialog = false },
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"Set Encryption Key",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"Enter 16-byte ENC_KEY as hex string (32 characters):",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = encKeyValue,
|
||||||
|
onValueChange = {
|
||||||
|
encKeyValue = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
||||||
|
encKeyError = null
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = encKeyError != null,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
capitalization = KeyboardCapitalization.None
|
||||||
|
),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||||
|
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
||||||
|
),
|
||||||
|
supportingText = {
|
||||||
|
if (encKeyError != null) {
|
||||||
|
Text(encKeyError!!, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("ENC_KEY Hex Value") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (!validateHexInput(encKeyValue)) {
|
||||||
|
encKeyError = "Must be exactly 32 hex characters"
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val hexBytes = ByteArray(16)
|
||||||
|
for (i in 0 until 16) {
|
||||||
|
val hexByte = encKeyValue.substring(i * 2, i * 2 + 2)
|
||||||
|
hexBytes[i] = hexByte.toInt(16).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
val base64Value = Base64.encode(hexBytes)
|
||||||
|
sharedPreferences.edit().putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value).apply()
|
||||||
|
|
||||||
|
Toast.makeText(context, "Encryption key has been set successfully", Toast.LENGTH_SHORT).show()
|
||||||
|
showEncKeyDialog = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
encKeyError = "Error converting hex: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Save",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { showEncKeyDialog = false }
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Cancel",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalHazeMaterialsApi::class)
|
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ import me.kavishdevar.librepods.R
|
|||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||||
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
data class PacketInfo(
|
data class PacketInfo(
|
||||||
val type: String,
|
val type: String,
|
||||||
@@ -616,7 +617,12 @@ fun DebugScreen(navController: NavController) {
|
|||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (packet.value.text.isNotBlank()) {
|
if (packet.value.text.isNotBlank()) {
|
||||||
airPodsService?.value?.sendPacket(packet.value.text)
|
airPodsService?.value?.aacpManager?.sendPacket(
|
||||||
|
packet.value.text
|
||||||
|
.split(" ")
|
||||||
|
.map { it.toInt(16).toByte() }
|
||||||
|
.toByteArray()
|
||||||
|
)
|
||||||
packet.value = TextFieldValue("")
|
packet.value = TextFieldValue("")
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -115,6 +117,7 @@ import me.kavishdevar.librepods.R
|
|||||||
import me.kavishdevar.librepods.composables.IndependentToggle
|
import me.kavishdevar.librepods.composables.IndependentToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.HeadTracking
|
import me.kavishdevar.librepods.utils.HeadTracking
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -47,7 +49,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -69,6 +70,9 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.experimental.and
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable()
|
@Composable()
|
||||||
fun RightDivider() {
|
fun RightDivider() {
|
||||||
@@ -83,15 +87,23 @@ fun RightDivider() {
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPress(navController: NavController, name: String) {
|
fun LongPress(navController: NavController, name: String) {
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
val offChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_off", false)) }
|
|
||||||
val ncChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_nc", false)) }
|
|
||||||
val transparencyChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_transparency", false)) }
|
|
||||||
val adaptiveChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_adaptive", false)) }
|
|
||||||
Log.d("LongPress", "offChecked: ${offChecked.value}, ncChecked: ${ncChecked.value}, transparencyChecked: ${transparencyChecked.value}, adaptiveChecked: ${adaptiveChecked.value}")
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
|
||||||
|
if (modesByte != null) {
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x04) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||||
|
}
|
||||||
|
val context = LocalContext.current
|
||||||
|
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
@@ -115,7 +127,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
modifier = Modifier.scale(1.5f)
|
modifier = Modifier.scale(1.5f)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
sharedPreferences.getString("name", "AirPods")!!,
|
deviceName?: "AirPods Pro",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
@@ -159,14 +171,29 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
|
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
LongPressElement("Off", offChecked, "long_press_off", offListeningMode, R.drawable.noise_cancellation, isFirst = true)
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
val offListeningMode = offListeningModeValue == 1.toByte()
|
||||||
|
LongPressElement(
|
||||||
|
name = "Off",
|
||||||
|
enabled = offListeningMode,
|
||||||
|
resourceId = R.drawable.noise_cancellation,
|
||||||
|
isFirst = true)
|
||||||
if (offListeningMode) RightDivider()
|
if (offListeningMode) RightDivider()
|
||||||
LongPressElement("Transparency", transparencyChecked, "long_press_transparency", resourceId = R.drawable.transparency, isFirst = !offListeningMode)
|
LongPressElement(
|
||||||
|
name = "Transparency",
|
||||||
|
resourceId = R.drawable.transparency,
|
||||||
|
isFirst = !offListeningMode)
|
||||||
RightDivider()
|
RightDivider()
|
||||||
LongPressElement("Adaptive", adaptiveChecked, "long_press_adaptive", resourceId = R.drawable.adaptive)
|
LongPressElement(
|
||||||
|
name = "Adaptive",
|
||||||
|
resourceId = R.drawable.adaptive)
|
||||||
RightDivider()
|
RightDivider()
|
||||||
LongPressElement("Noise Cancellation", ncChecked, "long_press_nc", resourceId = R.drawable.noise_cancellation, isLast = true)
|
LongPressElement(
|
||||||
|
name = "Noise Cancellation",
|
||||||
|
resourceId = R.drawable.noise_cancellation,
|
||||||
|
isLast = true)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Press and hold the stem to cycle between the selected noise control modes.",
|
"Press and hold the stem to cycle between the selected noise control modes.",
|
||||||
@@ -178,13 +205,33 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
|
fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
|
||||||
val sharedPreferences =
|
val bit = when (name) {
|
||||||
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
"Off" -> 0x01
|
||||||
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
|
"Transparency" -> 0x02
|
||||||
|
"Noise Cancellation" -> 0x04
|
||||||
|
"Adaptive" -> 0x08
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val currentByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
|
||||||
|
val savedByte = context.getSharedPreferences("settings", Context.MODE_PRIVATE).getInt("long_press_byte", 0b0101.toInt())
|
||||||
|
val byteValue = currentByteValue ?: (savedByte and 0xFF).toByte()
|
||||||
|
|
||||||
|
val isChecked = (byteValue.toInt() and bit) != 0
|
||||||
|
val checked = remember { mutableStateOf(isChecked) }
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "LongPressElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}")
|
||||||
val darkMode = isSystemInDarkTheme()
|
val darkMode = isSystemInDarkTheme()
|
||||||
val textColor = if (darkMode) Color.White else Color.Black
|
val textColor = if (darkMode) Color.White else Color.Black
|
||||||
val desc = when (name) {
|
val desc = when (name) {
|
||||||
@@ -194,30 +241,72 @@ fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, e
|
|||||||
"Adaptive" -> "Dynamically adjust external noise"
|
"Adaptive" -> "Dynamically adjust external noise"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
fun valueChanged(value: Boolean = !checked.value) {
|
|
||||||
val originalLongPressArray = booleanArrayOf(
|
fun countEnabledModes(byteValue: Int): Int {
|
||||||
sharedPreferences.getBoolean("long_press_off", false),
|
var count = 0
|
||||||
sharedPreferences.getBoolean("long_press_nc", false),
|
if ((byteValue and 0x01) != 0) count++
|
||||||
sharedPreferences.getBoolean("long_press_transparency", false),
|
if ((byteValue and 0x02) != 0) count++
|
||||||
sharedPreferences.getBoolean("long_press_adaptive", false)
|
if ((byteValue and 0x04) != 0) count++
|
||||||
)
|
if ((byteValue and 0x08) != 0) count++
|
||||||
if (!value && originalLongPressArray.count { it } <= 2) {
|
|
||||||
return
|
Log.d("PressAndHoldSettingsScreen", "Byte: ${byteValue.toString(2)} Enabled modes: $count")
|
||||||
}
|
return count
|
||||||
checked.value = value
|
|
||||||
with(sharedPreferences.edit()) {
|
|
||||||
putBoolean(id, checked.value)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
val newLongPressArray = booleanArrayOf(
|
|
||||||
sharedPreferences.getBoolean("long_press_off", false),
|
|
||||||
sharedPreferences.getBoolean("long_press_nc", false),
|
|
||||||
sharedPreferences.getBoolean("long_press_transparency", false),
|
|
||||||
sharedPreferences.getBoolean("long_press_adaptive", false)
|
|
||||||
)
|
|
||||||
ServiceManager.getService()
|
|
||||||
?.updateLongPress(originalLongPressArray, newLongPressArray, offListeningMode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun valueChanged(value: Boolean = !checked.value) {
|
||||||
|
val latestByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
|
|
||||||
|
val currentValue = (latestByteValue?.toInt() ?: byteValue.toInt()) and 0xFF
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Current value: $currentValue (binary: ${Integer.toBinaryString(currentValue)}), bit: $bit, value: $value")
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
val newValue = currentValue and bit.inv()
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Bit to disable: $bit, inverted: ${bit.inv()}, after AND: ${Integer.toBinaryString(newValue)}")
|
||||||
|
|
||||||
|
val modeCount = countEnabledModes(newValue)
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "After disabling, enabled modes count: $modeCount")
|
||||||
|
|
||||||
|
if (modeCount < 2) {
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Cannot disable $name mode - need at least 2 modes enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedByte = newValue.toByte()
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Sending updated byte: ${updatedByte.toInt() and 0xFF} (binary: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)})")
|
||||||
|
|
||||||
|
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||||
|
updatedByte
|
||||||
|
)
|
||||||
|
|
||||||
|
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
|
||||||
|
.putInt("long_press_byte", newValue).apply()
|
||||||
|
|
||||||
|
checked.value = false
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: false, byte: ${updatedByte.toInt() and 0xFF}, bits: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)}")
|
||||||
|
} else {
|
||||||
|
val newValue = currentValue or bit
|
||||||
|
val updatedByte = newValue.toByte()
|
||||||
|
|
||||||
|
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||||
|
updatedByte
|
||||||
|
)
|
||||||
|
|
||||||
|
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
|
||||||
|
.putInt("long_press_byte", newValue).apply()
|
||||||
|
|
||||||
|
checked.value = true
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: true, byte: ${updatedByte.toInt() and 0xFF}, bits: ${newValue.toString(2)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val shape = when {
|
val shape = when {
|
||||||
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
||||||
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||||
@@ -238,8 +327,8 @@ fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, e
|
|||||||
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
tryAwaitRelease()
|
tryAwaitRelease()
|
||||||
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
valueChanged()
|
||||||
},
|
},
|
||||||
onTap = { valueChanged() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(horizontal = 16.dp, vertical = 0.dp),
|
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -66,6 +68,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -198,4 +201,4 @@ fun RenameScreen(navController: NavController) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun RenameScreenPreview() {
|
fun RenameScreenPreview() {
|
||||||
RenameScreen(navController = NavController(LocalContext.current))
|
RenameScreen(navController = NavController(LocalContext.current))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.services
|
package me.kavishdevar.librepods.services
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -31,12 +33,12 @@ import android.service.quicksettings.Tile
|
|||||||
import android.service.quicksettings.TileService
|
import android.service.quicksettings.TileService
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import me.kavishdevar.librepods.MainActivity
|
|
||||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
class AirPodsQSService : TileService() {
|
class AirPodsQSService : TileService() {
|
||||||
@@ -171,10 +173,11 @@ class AirPodsQSService : TileService() {
|
|||||||
)
|
)
|
||||||
startActivityAndCollapse(pendingIntent)
|
startActivityAndCollapse(pendingIntent)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
|
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
}
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||||
startActivityAndCollapse(intent)
|
startActivityAndCollapse(intent)
|
||||||
}
|
}
|
||||||
Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity")
|
Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity")
|
||||||
@@ -191,14 +194,17 @@ class AirPodsQSService : TileService() {
|
|||||||
}
|
}
|
||||||
val nextMode = getNextAncMode()
|
val nextMode = getNextAncMode()
|
||||||
Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode")
|
Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode")
|
||||||
service.setANCMode(nextMode)
|
service.aacpManager.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||||
|
nextMode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateTile() {
|
private fun updateTile() {
|
||||||
val tile = qsTile ?: return
|
val tile = qsTile ?: return
|
||||||
Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode")
|
Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode")
|
||||||
|
|
||||||
val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods"
|
val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods"
|
||||||
|
|
||||||
if (isAirPodsConnected) {
|
if (isAirPodsConnected) {
|
||||||
tile.state = Tile.STATE_ACTIVE
|
tile.state = Tile.STATE_ACTIVE
|
||||||
@@ -262,42 +268,9 @@ class AirPodsQSService : TileService() {
|
|||||||
else -> R.drawable.airpods
|
else -> R.drawable.airpods
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
override fun onTileAdded() {
|
override fun onTileAdded() {
|
||||||
super.onTileAdded()
|
super.onTileAdded()
|
||||||
Log.d("AirPodsQSService", "Tile added")
|
Log.d("AirPodsQSService", "Tile added")
|
||||||
|
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
fun openMainActivity() {
|
|
||||||
Log.d("AirPodsQSService", "Opening MainActivity")
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, MainActivity::class.java).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
startActivityAndCollapse(pendingIntent)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
startActivityAndCollapse(intent)
|
|
||||||
}
|
|
||||||
Log.d("AirPodsQSService", "Called startActivityAndCollapse for MainActivity")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("AirPodsQSService", "Error launching MainActivity: $e")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,478 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager class for Apple Accessory Communication Protocol (AACP)
|
||||||
|
* This class is responsible for handling the L2CAP socket management,
|
||||||
|
* constructing and parsing packets for communication with Apple accessories.
|
||||||
|
*/
|
||||||
|
class AACPManager {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AACPManager"
|
||||||
|
|
||||||
|
object Opcodes {
|
||||||
|
const val SET_FEATURE_FLAGS: Byte = 0x4d
|
||||||
|
const val REQUEST_NOTIFICATIONS: Byte = 0x0f
|
||||||
|
const val BATTERY_INFO: Byte = 0x04
|
||||||
|
const val CONTROL_COMMAND: Byte = 0x09
|
||||||
|
const val EAR_DETECTION: Byte = 0x06
|
||||||
|
const val CONVERSATION_AWARENESS: Byte = 0x4b
|
||||||
|
const val DEVICE_METADATA: Byte = 0x1d
|
||||||
|
const val RENAME: Byte = 0x1E
|
||||||
|
const val HEADTRACKING: Byte = 0x17
|
||||||
|
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||||
|
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||||
|
}
|
||||||
|
|
||||||
|
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||||
|
|
||||||
|
data class ControlCommandStatus(
|
||||||
|
val identifier: ControlCommandIdentifiers,
|
||||||
|
val value: ByteArray
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as ControlCommandStatus
|
||||||
|
|
||||||
|
if (identifier != other.identifier) return false
|
||||||
|
if (!value.contentEquals(other.value)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result: Int = identifier.hashCode()
|
||||||
|
result = 31 * result + value.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Suppress("unused")
|
||||||
|
enum class ControlCommandIdentifiers(val value: Byte) {
|
||||||
|
MIC_MODE(0x01),
|
||||||
|
BUTTON_SEND_MODE(0x05),
|
||||||
|
VOICE_TRIGGER(0x12),
|
||||||
|
SINGLE_CLICK_MODE(0x14),
|
||||||
|
DOUBLE_CLICK_MODE(0x15),
|
||||||
|
CLICK_HOLD_MODE(0x16),
|
||||||
|
DOUBLE_CLICK_INTERVAL(0x17),
|
||||||
|
CLICK_HOLD_INTERVAL(0x18),
|
||||||
|
LISTENING_MODE_CONFIGS(0x1A),
|
||||||
|
ONE_BUD_ANC_MODE(0x1B),
|
||||||
|
CROWN_ROTATION_DIRECTION(0x1C),
|
||||||
|
LISTENING_MODE(0x0D),
|
||||||
|
AUTO_ANSWER_MODE(0x1E),
|
||||||
|
CHIME_VOLUME(0x1F),
|
||||||
|
VOLUME_SWIPE_INTERVAL(0x23),
|
||||||
|
CALL_MANAGEMENT_CONFIG(0x24),
|
||||||
|
VOLUME_SWIPE_MODE(0x25),
|
||||||
|
ADAPTIVE_VOLUME_CONFIG(0x26),
|
||||||
|
SOFTWARE_MUTE_CONFIG(0x27),
|
||||||
|
CONVERSATION_DETECT_CONFIG(0x28),
|
||||||
|
SSL(0x29),
|
||||||
|
HEARING_AID(0x2C),
|
||||||
|
AUTO_ANC_STRENGTH(0x2E),
|
||||||
|
HPS_GAIN_SWIPE(0x2F),
|
||||||
|
HRM_STATE(0x30),
|
||||||
|
IN_CASE_TONE_CONFIG(0x31),
|
||||||
|
SIRI_MULTITONE_CONFIG(0x32),
|
||||||
|
HEARING_ASSIST_CONFIG(0x33),
|
||||||
|
ALLOW_OFF_OPTION(0x34);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
|
||||||
|
entries.find { it.value == byte }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ProximityKeyType(val value: Byte) {
|
||||||
|
IRK(0x01),
|
||||||
|
ENC_KEY(0x04);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): ProximityKeyType =
|
||||||
|
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
|
||||||
|
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
|
||||||
|
|
||||||
|
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
||||||
|
return controlCommandStatusList.find { it.identifier == identifier }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) {
|
||||||
|
val existingStatus = getControlCommandStatus(identifier)
|
||||||
|
if (existingStatus == value) {
|
||||||
|
controlCommandStatusList.remove(existingStatus)
|
||||||
|
}
|
||||||
|
if (existingStatus != null) {
|
||||||
|
controlCommandStatusList.remove(existingStatus)
|
||||||
|
}
|
||||||
|
controlCommandListeners[identifier]?.forEach { listener ->
|
||||||
|
listener.onControlCommandReceived(ControlCommand(identifier.value, value))
|
||||||
|
}
|
||||||
|
controlCommandStatusList.add(ControlCommandStatus(identifier, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PacketCallback {
|
||||||
|
fun onBatteryInfoReceived(batteryInfo: ByteArray)
|
||||||
|
fun onEarDetectionReceived(earDetection: ByteArray)
|
||||||
|
fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
|
||||||
|
fun onControlCommandReceived(controlCommand: ByteArray)
|
||||||
|
fun onDeviceMetadataReceived(deviceMetadata: ByteArray)
|
||||||
|
fun onHeadTrackingReceived(headTracking: ByteArray)
|
||||||
|
fun onUnknownPacketReceived(packet: ByteArray)
|
||||||
|
fun onProximityKeysReceived(proximityKeys: ByteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlCommandListener {
|
||||||
|
fun onControlCommandReceived(controlCommand: ControlCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) {
|
||||||
|
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var callback: PacketCallback? = null
|
||||||
|
|
||||||
|
fun setPacketCallback(callback: PacketCallback) {
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createDataPacket(data: ByteArray): ByteArray {
|
||||||
|
return HEADER_BYTES + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00)
|
||||||
|
val payload = ByteArray(7)
|
||||||
|
|
||||||
|
System.arraycopy(opcode, 0, payload, 0, 2)
|
||||||
|
payload[2] = identifier
|
||||||
|
|
||||||
|
val dataLength = minOf(data.size, 4)
|
||||||
|
System.arraycopy(data, 0, payload, 3, dataLength)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendDataPacket(data: ByteArray): Boolean {
|
||||||
|
return sendPacket(createDataPacket(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, value)
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
byteArrayOf(value)
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02))
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: Int): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte()))
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
byteArrayOf(value.toByte())
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
||||||
|
Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
if (data.size < 4) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
|
}
|
||||||
|
if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) {
|
||||||
|
throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode")
|
||||||
|
}
|
||||||
|
val keyCount = data[6].toInt()
|
||||||
|
val keys = mutableMapOf<ProximityKeyType, ByteArray>()
|
||||||
|
var offset = 7
|
||||||
|
for (i in 0 until keyCount) {
|
||||||
|
Log.d(TAG, "Parsing Proximity Key $i")
|
||||||
|
if (offset + 3 >= data.size) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
|
}
|
||||||
|
val keyType = data[offset]
|
||||||
|
val keyLength = data[offset + 2].toInt()
|
||||||
|
Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength")
|
||||||
|
offset += 4
|
||||||
|
if (offset + keyLength > data.size) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
|
}
|
||||||
|
val key = ByteArray(keyLength)
|
||||||
|
System.arraycopy(data, offset, key, 0, keyLength)
|
||||||
|
keys[ProximityKeyType.fromByte(keyType)] = key
|
||||||
|
offset += keyLength
|
||||||
|
Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendRequestProximityKeys(type: Byte): Boolean {
|
||||||
|
Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}")
|
||||||
|
return sendDataPacket(createRequestProximityKeysPacket(type))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRequestProximityKeysPacket(type: Byte): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00)
|
||||||
|
val data = byteArrayOf(type, 0x00)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
|
fun receivePacket(packet: ByteArray) {
|
||||||
|
if (!packet.toHexString().startsWith("04000400")) {
|
||||||
|
Log.w(TAG, "Received packet does not start with expected header: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (packet.size < 6) {
|
||||||
|
Log.w(TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val opcode = packet[4]
|
||||||
|
|
||||||
|
when (opcode) {
|
||||||
|
Opcodes.BATTERY_INFO -> {
|
||||||
|
callback?.onBatteryInfoReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.CONTROL_COMMAND -> {
|
||||||
|
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
|
||||||
|
controlCommand.value
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
Log.d(TAG, "Control command list is now: ${
|
||||||
|
controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" }
|
||||||
|
}")
|
||||||
|
|
||||||
|
val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
||||||
|
if (controlCommandIdentifier != null) {
|
||||||
|
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
||||||
|
listener.onControlCommandReceived(controlCommand)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
callback?.onControlCommandReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.EAR_DETECTION -> {
|
||||||
|
callback?.onEarDetectionReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.CONVERSATION_AWARENESS -> {
|
||||||
|
callback?.onConversationAwarenessReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.DEVICE_METADATA -> {
|
||||||
|
callback?.onDeviceMetadataReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.HEADTRACKING -> {
|
||||||
|
if (packet.size < 70) {
|
||||||
|
Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback?.onHeadTrackingReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.PROXIMITY_KEYS_RSP -> {
|
||||||
|
callback?.onProximityKeysReceived(packet)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
callback?.onUnknownPacketReceived(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendNotificationRequest(): Boolean {
|
||||||
|
return sendDataPacket(createRequestNotificationPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRequestNotificationPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00)
|
||||||
|
val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendSetFeatureFlagsPacket(): Boolean {
|
||||||
|
return sendDataPacket(createSetFeatureFlagsPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSetFeatureFlagsPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00)
|
||||||
|
val data = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createHandshakePacket(): ByteArray {
|
||||||
|
return byteArrayOf(
|
||||||
|
0x00, 0x00, 0x04, 0x00,
|
||||||
|
0x01, 0x00, 0x02, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendStartHeadTracking(): Boolean {
|
||||||
|
return sendDataPacket(createStartHeadTrackingPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createStartHeadTrackingPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||||
|
val data = byteArrayOf(
|
||||||
|
0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00,
|
||||||
|
)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendStopHeadTracking(): Boolean {
|
||||||
|
return sendDataPacket(createStopHeadTrackingPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createStopHeadTrackingPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||||
|
val data = byteArrayOf(
|
||||||
|
0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
|
||||||
|
)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendRename(name: String): Boolean {
|
||||||
|
return sendDataPacket(createRenamePacket(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRenamePacket(name: String): ByteArray {
|
||||||
|
val nameBytes = name.toByteArray()
|
||||||
|
val size = nameBytes.size
|
||||||
|
val packet = ByteArray(5 + size)
|
||||||
|
packet[0] = Opcodes.RENAME
|
||||||
|
packet[1] = 0x00
|
||||||
|
packet[2] = size.toByte()
|
||||||
|
packet[3] = 0x00
|
||||||
|
System.arraycopy(nameBytes, 0, packet, 4, size)
|
||||||
|
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class ControlCommand(
|
||||||
|
val identifier: Byte,
|
||||||
|
val value: ByteArray
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as ControlCommand
|
||||||
|
|
||||||
|
if (identifier != other.identifier) return false
|
||||||
|
if (!value.contentEquals(other.value)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result: Int = identifier.toInt()
|
||||||
|
result = 31 * result + value.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByteArray(data: ByteArray): ControlCommand {
|
||||||
|
if (data.size < 4) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse ControlCommand")
|
||||||
|
}
|
||||||
|
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) {
|
||||||
|
val newData = ByteArray(data.size - 4)
|
||||||
|
System.arraycopy(data, 4, newData, 0, data.size - 4)
|
||||||
|
return fromByteArray(newData)
|
||||||
|
}
|
||||||
|
if (data[0] != Opcodes.CONTROL_COMMAND) {
|
||||||
|
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode")
|
||||||
|
}
|
||||||
|
val identifier = data[2]
|
||||||
|
|
||||||
|
val value = ByteArray(4)
|
||||||
|
System.arraycopy(data, 3, value, 0, 4)
|
||||||
|
|
||||||
|
// drop trailing zeroes in the array, and return the bytearray of the reduced array
|
||||||
|
val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
|
||||||
|
return ControlCommand(identifier, trimmedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
|
fun sendPacket(packet: ByteArray): Boolean {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
|
||||||
|
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
||||||
|
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||||
|
Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
||||||
|
controlCommand.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val socket = BluetoothConnectionManager.getCurrentSocket()
|
||||||
|
if (socket?.isConnected == true) {
|
||||||
|
socket.outputStream?.write(packet)
|
||||||
|
socket.outputStream?.flush()
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Can't send packet: Socket not initialized or connected")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error sending packet: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.le.BluetoothLeScanner
|
||||||
|
import android.bluetooth.le.ScanCallback
|
||||||
|
import android.bluetooth.le.ScanFilter
|
||||||
|
import android.bluetooth.le.ScanResult
|
||||||
|
import android.bluetooth.le.ScanSettings
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
class BLEManager(private val context: Context) {
|
||||||
|
|
||||||
|
data class AirPodsStatus(
|
||||||
|
val address: String,
|
||||||
|
val lastSeen: Long = System.currentTimeMillis(),
|
||||||
|
val paired: Boolean = false,
|
||||||
|
val model: String = "Unknown",
|
||||||
|
val leftBattery: Int? = null,
|
||||||
|
val rightBattery: Int? = null,
|
||||||
|
val caseBattery: Int? = null,
|
||||||
|
val isLeftInEar: Boolean = false,
|
||||||
|
val isRightInEar: Boolean = false,
|
||||||
|
val isLeftCharging: Boolean = false,
|
||||||
|
val isRightCharging: Boolean = false,
|
||||||
|
val isCaseCharging: Boolean = false,
|
||||||
|
val lidOpen: Boolean = false,
|
||||||
|
val color: String = "Unknown",
|
||||||
|
val connectionState: String = "Unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getMostRecentStatus(): AirPodsStatus? {
|
||||||
|
return deviceStatusMap.values.maxByOrNull { it.lastSeen }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AirPodsStatusListener {
|
||||||
|
fun onDeviceStatusChanged(device: AirPodsStatus, previousStatus: AirPodsStatus?)
|
||||||
|
fun onBroadcastFromNewAddress(device: AirPodsStatus)
|
||||||
|
fun onLidStateChanged(lidOpen: Boolean)
|
||||||
|
fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean)
|
||||||
|
fun onBatteryChanged(device: AirPodsStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mBluetoothLeScanner: BluetoothLeScanner? = null
|
||||||
|
private var mScanCallback: ScanCallback? = null
|
||||||
|
private var airPodsStatusListener: AirPodsStatusListener? = null
|
||||||
|
private val deviceStatusMap = mutableMapOf<String, AirPodsStatus>()
|
||||||
|
private val verifiedAddresses = mutableSetOf<String>()
|
||||||
|
private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
private var currentGlobalLidState: Boolean? = null
|
||||||
|
private var lastBroadcastTime: Long = 0
|
||||||
|
private val processedAddresses = mutableSetOf<String>()
|
||||||
|
|
||||||
|
private val lastValidCaseBatteryMap = mutableMapOf<String, Int>()
|
||||||
|
private val modelNames = mapOf(
|
||||||
|
0x0E20 to "AirPods Pro",
|
||||||
|
0x1420 to "AirPods Pro 2",
|
||||||
|
0x2420 to "AirPods Pro 2 (USB-C)",
|
||||||
|
0x0220 to "AirPods 1",
|
||||||
|
0x0F20 to "AirPods 2",
|
||||||
|
0x1320 to "AirPods 3",
|
||||||
|
0x1920 to "AirPods 4",
|
||||||
|
0x1B20 to "AirPods 4 (ANC)",
|
||||||
|
0x0A20 to "AirPods Max",
|
||||||
|
0x1F20 to "AirPods Max (USB-C)"
|
||||||
|
)
|
||||||
|
|
||||||
|
val colorNames = mapOf(
|
||||||
|
0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue",
|
||||||
|
0x04 to "Pink", 0x05 to "Gray", 0x06 to "Silver", 0x07 to "Gold",
|
||||||
|
0x08 to "Rose Gold", 0x09 to "Space Gray", 0x0A to "Dark Blue",
|
||||||
|
0x0B to "Light Blue", 0x0C to "Yellow"
|
||||||
|
)
|
||||||
|
|
||||||
|
val connStates = mapOf(
|
||||||
|
0x00 to "Disconnected", 0x04 to "Idle", 0x05 to "Music",
|
||||||
|
0x06 to "Call", 0x07 to "Ringing", 0x09 to "Hanging Up", 0xFF to "Unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val cleanupHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val cleanupRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
cleanupStaleDevices()
|
||||||
|
checkLidStateTimeout()
|
||||||
|
cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAirPodsStatusListener(listener: AirPodsStatusListener) {
|
||||||
|
airPodsStatusListener = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun startScanning() {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Starting BLE scanner")
|
||||||
|
|
||||||
|
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val btAdapter = btManager.adapter
|
||||||
|
|
||||||
|
if (btAdapter == null) {
|
||||||
|
Log.d(TAG, "No Bluetooth adapter available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||||
|
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||||
|
mScanCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!btAdapter.isEnabled) {
|
||||||
|
Log.d(TAG, "Bluetooth is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mBluetoothLeScanner = btAdapter.bluetoothLeScanner
|
||||||
|
|
||||||
|
val scanSettings = ScanSettings.Builder()
|
||||||
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||||
|
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||||
|
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
||||||
|
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||||
|
.setReportDelay(500L)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manufacturerData = ByteArray(27)
|
||||||
|
val manufacturerDataMask = ByteArray(27)
|
||||||
|
|
||||||
|
manufacturerData[0] = 7
|
||||||
|
manufacturerData[1] = 25
|
||||||
|
|
||||||
|
manufacturerDataMask[0] = -1
|
||||||
|
manufacturerDataMask[1] = -1
|
||||||
|
|
||||||
|
val scanFilter = ScanFilter.Builder()
|
||||||
|
.setManufacturerData(76, manufacturerData, manufacturerDataMask)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mScanCallback = object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
processScanResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBatchScanResults(results: List<ScanResult>) {
|
||||||
|
processedAddresses.clear()
|
||||||
|
for (result in results) {
|
||||||
|
processScanResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
Log.e(TAG, "BLE scan failed with error code: $errorCode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback)
|
||||||
|
Log.d(TAG, "BLE scanner started successfully")
|
||||||
|
|
||||||
|
cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Error starting BLE scanner", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun stopScanning() {
|
||||||
|
try {
|
||||||
|
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||||
|
Log.d(TAG, "Stopping BLE scanner")
|
||||||
|
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||||
|
mScanCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupHandler.removeCallbacks(cleanupRunnable)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Error stopping BLE scanner", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
private fun getEncryptionKeyFromPreferences(): ByteArray? {
|
||||||
|
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
||||||
|
return if (keyBase64 != null) {
|
||||||
|
try {
|
||||||
|
Base64.decode(keyBase64)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decode encryption key", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptLastBytes(data: ByteArray, key: ByteArray): ByteArray? {
|
||||||
|
return try {
|
||||||
|
if (data.size < 16) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val block = data.copyOfRange(data.size - 16, data.size)
|
||||||
|
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||||
|
val secretKey = SecretKeySpec(key, "AES")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey)
|
||||||
|
cipher.doFinal(block)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error decrypting data", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBattery(byteVal: Int): Pair<Boolean, Int> {
|
||||||
|
val charging = (byteVal and 0x80) != 0
|
||||||
|
val level = byteVal and 0x7F
|
||||||
|
return Pair(charging, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processScanResult(result: ScanResult) {
|
||||||
|
try {
|
||||||
|
val scanRecord = result.scanRecord ?: return
|
||||||
|
val address = result.device.address
|
||||||
|
|
||||||
|
if (processedAddresses.contains(address)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
|
||||||
|
if (manufacturerData.size <= 20) return
|
||||||
|
|
||||||
|
if (!verifiedAddresses.contains(address)) {
|
||||||
|
val irk = getIrkFromPreferences()
|
||||||
|
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verifiedAddresses.add(address)
|
||||||
|
Log.d(TAG, "RPA verified and added to trusted list: $address")
|
||||||
|
}
|
||||||
|
|
||||||
|
processedAddresses.add(address)
|
||||||
|
lastBroadcastTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val encryptionKey = getEncryptionKeyFromPreferences()
|
||||||
|
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
|
||||||
|
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
|
||||||
|
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
|
||||||
|
} else {
|
||||||
|
parseProximityMessage(address, manufacturerData)
|
||||||
|
}
|
||||||
|
|
||||||
|
val previousStatus = deviceStatusMap[address]
|
||||||
|
deviceStatusMap[address] = parsedStatus
|
||||||
|
|
||||||
|
airPodsStatusListener?.let { listener ->
|
||||||
|
if (previousStatus == null) {
|
||||||
|
listener.onBroadcastFromNewAddress(parsedStatus)
|
||||||
|
Log.d(TAG, "New AirPods device detected: $address")
|
||||||
|
|
||||||
|
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
|
||||||
|
currentGlobalLidState = parsedStatus.lidOpen
|
||||||
|
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||||
|
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (parsedStatus != previousStatus) {
|
||||||
|
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
|
||||||
|
val previousGlobalState = currentGlobalLidState
|
||||||
|
currentGlobalLidState = parsedStatus.lidOpen
|
||||||
|
|
||||||
|
if (previousGlobalState != parsedStatus.lidOpen) {
|
||||||
|
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||||
|
Log.d(TAG, "Lid state changed from ${previousGlobalState} to ${parsedStatus.lidOpen}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
|
||||||
|
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
|
||||||
|
listener.onEarStateChanged(
|
||||||
|
parsedStatus,
|
||||||
|
parsedStatus.isLeftInEar,
|
||||||
|
parsedStatus.isRightInEar
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
|
||||||
|
parsedStatus.rightBattery != previousStatus.rightBattery ||
|
||||||
|
parsedStatus.caseBattery != previousStatus.caseBattery) {
|
||||||
|
listener.onBatteryChanged(parsedStatus)
|
||||||
|
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Error processing scan result", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
|
||||||
|
val paired = data[2].toInt() == 1
|
||||||
|
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
|
||||||
|
val model = modelNames[modelId] ?: "Unknown ($modelId)"
|
||||||
|
|
||||||
|
val status = data[5].toInt() and 0xFF
|
||||||
|
val flagsCase = data[7].toInt() and 0xFF
|
||||||
|
val lid = data[8].toInt() and 0xFF
|
||||||
|
val color = colorNames[data[9].toInt()] ?: "Unknown"
|
||||||
|
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
|
||||||
|
|
||||||
|
val primaryLeft = ((status shr 5) and 0x01) == 1
|
||||||
|
val thisInCase = ((status shr 6) and 0x01) == 1
|
||||||
|
val xorFactor = primaryLeft xor thisInCase
|
||||||
|
|
||||||
|
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
|
||||||
|
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
|
||||||
|
|
||||||
|
val isFlipped = !primaryLeft
|
||||||
|
|
||||||
|
val leftByteIndex = if (isFlipped) 2 else 1
|
||||||
|
val rightByteIndex = if (isFlipped) 1 else 2
|
||||||
|
|
||||||
|
val (isLeftCharging, leftBattery) = formatBattery(decrypted[leftByteIndex].toInt() and 0xFF)
|
||||||
|
val (isRightCharging, rightBattery) = formatBattery(decrypted[rightByteIndex].toInt() and 0xFF)
|
||||||
|
|
||||||
|
val rawCaseBatteryByte = decrypted[3].toInt() and 0xFF
|
||||||
|
val (isCaseCharging, rawCaseBattery) = formatBattery(rawCaseBatteryByte)
|
||||||
|
|
||||||
|
val caseBattery = if (rawCaseBatteryByte == 0xFF || (isCaseCharging && rawCaseBattery == 127)) {
|
||||||
|
lastValidCaseBatteryMap[address]
|
||||||
|
} else {
|
||||||
|
lastValidCaseBatteryMap[address] = rawCaseBattery
|
||||||
|
rawCaseBattery
|
||||||
|
}
|
||||||
|
|
||||||
|
val lidOpen = ((lid shr 3) and 0x01) == 0
|
||||||
|
|
||||||
|
return AirPodsStatus(
|
||||||
|
address = address,
|
||||||
|
lastSeen = System.currentTimeMillis(),
|
||||||
|
paired = paired,
|
||||||
|
model = model,
|
||||||
|
leftBattery = leftBattery,
|
||||||
|
rightBattery = rightBattery,
|
||||||
|
caseBattery = caseBattery,
|
||||||
|
isLeftInEar = isLeftInEar,
|
||||||
|
isRightInEar = isRightInEar,
|
||||||
|
isLeftCharging = isLeftCharging,
|
||||||
|
isRightCharging = isRightCharging,
|
||||||
|
isCaseCharging = isCaseCharging,
|
||||||
|
lidOpen = lidOpen,
|
||||||
|
color = color,
|
||||||
|
connectionState = conn
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupStaleDevices() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
|
||||||
|
|
||||||
|
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
|
||||||
|
|
||||||
|
for (device in staleDevices) {
|
||||||
|
deviceStatusMap.remove(device.key)
|
||||||
|
Log.d(TAG, "Removed stale device from tracking: ${device.key}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkLidStateTimeout() {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
if (currentTime - lastBroadcastTime > LID_CLOSE_TIMEOUT_MS && currentGlobalLidState == true) {
|
||||||
|
Log.d(TAG, "No broadcasts for ${LID_CLOSE_TIMEOUT_MS}ms, forcing lid state to closed")
|
||||||
|
currentGlobalLidState = false
|
||||||
|
airPodsStatusListener?.onLidStateChanged(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
private fun getIrkFromPreferences(): ByteArray? {
|
||||||
|
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
||||||
|
return if (irkBase64 != null) {
|
||||||
|
try {
|
||||||
|
Base64.decode(irkBase64)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decode IRK", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus {
|
||||||
|
val paired = data[2].toInt() == 1
|
||||||
|
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
|
||||||
|
val model = modelNames[modelId] ?: "Unknown ($modelId)"
|
||||||
|
|
||||||
|
val status = data[5].toInt() and 0xFF
|
||||||
|
val podsBattery = data[6].toInt() and 0xFF
|
||||||
|
val flagsCase = data[7].toInt() and 0xFF
|
||||||
|
val lid = data[8].toInt() and 0xFF
|
||||||
|
val color = colorNames[data[9].toInt()] ?: "Unknown"
|
||||||
|
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
|
||||||
|
|
||||||
|
val primaryLeft = ((status shr 5) and 0x01) == 1
|
||||||
|
val thisInCase = ((status shr 6) and 0x01) == 1
|
||||||
|
val xorFactor = primaryLeft xor thisInCase
|
||||||
|
|
||||||
|
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
|
||||||
|
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
|
||||||
|
|
||||||
|
val isFlipped = !primaryLeft
|
||||||
|
|
||||||
|
val leftBatteryNibble = if (isFlipped) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F
|
||||||
|
val rightBatteryNibble = if (isFlipped) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F
|
||||||
|
|
||||||
|
val caseBattery = flagsCase and 0x0F
|
||||||
|
val flags = (flagsCase shr 4) and 0x0F
|
||||||
|
|
||||||
|
val isLeftCharging = if (isFlipped) (flags and 0x02) != 0 else (flags and 0x01) != 0
|
||||||
|
val isRightCharging = if (isFlipped) (flags and 0x01) != 0 else (flags and 0x02) != 0
|
||||||
|
val isCaseCharging = (flags and 0x04) != 0
|
||||||
|
|
||||||
|
val lidOpen = ((lid shr 3) and 0x01) == 0
|
||||||
|
|
||||||
|
fun decodeBattery(n: Int): Int? = when (n) {
|
||||||
|
in 0x0..0x9 -> n * 10
|
||||||
|
in 0xA..0xE -> 100
|
||||||
|
0xF -> null
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
return AirPodsStatus(
|
||||||
|
address = address,
|
||||||
|
lastSeen = System.currentTimeMillis(),
|
||||||
|
paired = paired,
|
||||||
|
model = model,
|
||||||
|
leftBattery = decodeBattery(leftBatteryNibble),
|
||||||
|
rightBattery = decodeBattery(rightBatteryNibble),
|
||||||
|
caseBattery = decodeBattery(caseBattery),
|
||||||
|
isLeftInEar = isLeftInEar,
|
||||||
|
isRightInEar = isRightInEar,
|
||||||
|
isLeftCharging = isLeftCharging,
|
||||||
|
isRightCharging = isRightCharging,
|
||||||
|
isCaseCharging = isCaseCharging,
|
||||||
|
lidOpen = lidOpen,
|
||||||
|
color = color,
|
||||||
|
connectionState = conn
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AirPodsBLE"
|
||||||
|
private const val CLEANUP_INTERVAL_MS = 30000L
|
||||||
|
private const val STALE_DEVICE_TIMEOUT_MS = 60000L
|
||||||
|
private const val LID_CLOSE_TIMEOUT_MS = 2000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object BluetoothConnectionManager {
|
||||||
|
private const val TAG = "BluetoothConnectionManager"
|
||||||
|
|
||||||
|
private var currentSocket: BluetoothSocket? = null
|
||||||
|
private var currentDevice: BluetoothDevice? = null
|
||||||
|
|
||||||
|
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
||||||
|
currentSocket = socket
|
||||||
|
currentDevice = device
|
||||||
|
Log.d(TAG, "Current connection set to device: ${device.address}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentSocket(): BluetoothSocket? {
|
||||||
|
return currentSocket
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilities for Bluetooth cryptography operations, particularly for
|
||||||
|
* verifying Resolvable Private Addresses (RPA) used by AirPods.
|
||||||
|
*/
|
||||||
|
object BluetoothCryptography {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
|
||||||
|
*
|
||||||
|
* @param addr The Bluetooth address to verify
|
||||||
|
* @param irk The Identity Resolving Key to use for verification
|
||||||
|
* @return true if the address is verified as an RPA matching the IRK
|
||||||
|
*/
|
||||||
|
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
|
||||||
|
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
|
||||||
|
val prand = rpa.copyOfRange(3, 6)
|
||||||
|
val hash = rpa.copyOfRange(0, 3)
|
||||||
|
val computedHash = ah(irk, prand)
|
||||||
|
return hash.contentEquals(computedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs E function (AES-128) as specified in Bluetooth Core Specification
|
||||||
|
*
|
||||||
|
* @param key The key for encryption
|
||||||
|
* @param data The data to encrypt
|
||||||
|
* @return The encrypted data
|
||||||
|
*/
|
||||||
|
fun e(key: ByteArray, data: ByteArray): ByteArray {
|
||||||
|
val swappedKey = key.reversedArray()
|
||||||
|
val swappedData = data.reversedArray()
|
||||||
|
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||||
|
val secretKey = SecretKeySpec(swappedKey, "AES")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||||
|
return cipher.doFinal(swappedData).reversedArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the ah function as specified in Bluetooth Core Specification
|
||||||
|
*
|
||||||
|
* @param k The IRK key
|
||||||
|
* @param r The random part of the address
|
||||||
|
* @return The hash part of the address
|
||||||
|
*/
|
||||||
|
fun ah(k: ByteArray, r: ByteArray): ByteArray {
|
||||||
|
val rPadded = ByteArray(16)
|
||||||
|
r.copyInto(rPadded, 0, 0, 3)
|
||||||
|
val encrypted = e(k, rPadded)
|
||||||
|
return encrypted.copyOfRange(0, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ import kotlinx.coroutines.launch
|
|||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
enum class CrossDevicePackets(val packet: ByteArray) {
|
enum class CrossDevicePackets(val packet: ByteArray) {
|
||||||
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
||||||
@@ -87,7 +89,7 @@ object CrossDevice {
|
|||||||
private fun startServer() {
|
private fun startServer() {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
if (!bluetoothAdapter.isEnabled) return@launch
|
if (!bluetoothAdapter.isEnabled) return@launch
|
||||||
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
||||||
Log.d("CrossDevice", "Server started")
|
Log.d("CrossDevice", "Server started")
|
||||||
while (serverSocket != null) {
|
while (serverSocket != null) {
|
||||||
if (!bluetoothAdapter.isEnabled) {
|
if (!bluetoothAdapter.isEnabled) {
|
||||||
@@ -233,7 +235,7 @@ object CrossDevice {
|
|||||||
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
||||||
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
||||||
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
||||||
ServiceManager.getService()?.sendPacket(packetInHex)
|
// ServiceManager.getService()?.sendPacket(packetInHex)
|
||||||
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
||||||
batteryBytes = trimmedPacket
|
batteryBytes = trimmedPacket
|
||||||
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
|
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -13,6 +15,7 @@ import me.kavishdevar.librepods.services.AirPodsService
|
|||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -20,21 +23,18 @@ import kotlin.math.pow
|
|||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
class GestureDetector(
|
class GestureDetector(
|
||||||
private val airPodsService: AirPodsService,
|
private val airPodsService: AirPodsService
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "GestureDetector"
|
private const val TAG = "GestureDetector"
|
||||||
|
|
||||||
private const val START_CMD = "04 00 04 00 17 00 00 00 10 00 10 00 08 A1 02 42 0B 08 0E 10 02 1A 05 01 40 9C 00 00"
|
|
||||||
private const val STOP_CMD = "04 00 04 00 17 00 00 00 10 00 11 00 08 7E 10 02 42 0B 08 4E 10 02 1A 05 01 00 00 00 00"
|
|
||||||
|
|
||||||
private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600
|
private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600
|
||||||
private const val DIRECTION_CHANGE_SENSITIVITY = 150
|
private const val DIRECTION_CHANGE_SENSITIVITY = 150
|
||||||
|
|
||||||
private const val FAST_MOVEMENT_THRESHOLD = 300.0
|
private const val FAST_MOVEMENT_THRESHOLD = 300.0
|
||||||
private const val MIN_REQUIRED_EXTREMES = 3
|
private const val MIN_REQUIRED_EXTREMES = 3
|
||||||
private const val MAX_REQUIRED_EXTREMES = 4
|
private const val MAX_REQUIRED_EXTREMES = 4
|
||||||
|
|
||||||
private const val MAX_VALID_ORIENTATION_VALUE = 6000
|
private const val MAX_VALID_ORIENTATION_VALUE = 6000
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,13 +87,13 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
isRunning = true
|
isRunning = true
|
||||||
gestureDetectedCallback = onGestureDetected
|
gestureDetectedCallback = onGestureDetected
|
||||||
|
|
||||||
|
Log.d(TAG, "started: ${airPodsService.startHeadTracking()}")
|
||||||
|
|
||||||
clearData()
|
clearData()
|
||||||
|
|
||||||
prevHorizontal = 0.0
|
prevHorizontal = 0.0
|
||||||
prevVertical = 0.0
|
prevVertical = 0.0
|
||||||
|
|
||||||
airPodsService.sendPacket(START_CMD)
|
|
||||||
|
|
||||||
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
||||||
while (isRunning) {
|
while (isRunning) {
|
||||||
delay(50)
|
delay(50)
|
||||||
@@ -117,7 +117,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
Log.d(TAG, "Stopping gesture detection")
|
Log.d(TAG, "Stopping gesture detection")
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
if (!doNotStop) airPodsService.sendPacket(STOP_CMD)
|
if (!doNotStop) airPodsService.stopHeadTracking()
|
||||||
|
|
||||||
detectionJob?.cancel()
|
detectionJob?.cancel()
|
||||||
detectionJob = null
|
detectionJob = null
|
||||||
@@ -187,7 +187,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun detectPeaksAndTroughs() {
|
private fun detectPeaksAndTroughs() {
|
||||||
if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return
|
if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ package me.kavishdevar.librepods.utils
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioDeviceInfo
|
|
||||||
import android.media.AudioFocusRequest
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.media.SoundPool
|
import android.media.SoundPool
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
@@ -22,44 +19,6 @@ class GestureFeedback(private val context: Context) {
|
|||||||
|
|
||||||
private val soundsLoaded = AtomicBoolean(false)
|
private val soundsLoaded = AtomicBoolean(false)
|
||||||
|
|
||||||
private fun forceBluetoothRouting(audioManager: AudioManager) {
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
|
||||||
val bluetoothDevice = devices.find {
|
|
||||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
|
|
||||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
|
|
||||||
}
|
|
||||||
|
|
||||||
bluetoothDevice?.let { device ->
|
|
||||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
|
||||||
.setAudioAttributes(AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
||||||
.build())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
audioManager.requestAudioFocus(focusRequest)
|
|
||||||
|
|
||||||
if (!audioManager.isBluetoothScoOn) {
|
|
||||||
audioManager.isBluetoothScoOn = true
|
|
||||||
audioManager.startBluetoothSco()
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Forced audio routing to Bluetooth device")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!audioManager.isBluetoothScoOn) {
|
|
||||||
audioManager.isBluetoothScoOn = true
|
|
||||||
audioManager.startBluetoothSco()
|
|
||||||
Log.d(TAG, "Started Bluetooth SCO")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to force Bluetooth routing", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val soundPool = SoundPool.Builder()
|
private val soundPool = SoundPool.Builder()
|
||||||
.setMaxStreams(3)
|
.setMaxStreams(3)
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
@@ -201,12 +160,4 @@ class GestureFeedback(private val context: Context) {
|
|||||||
Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId")
|
Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
|
||||||
try {
|
|
||||||
soundPool.release()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error releasing resources", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ object HeadTracking {
|
|||||||
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
||||||
if (!isCalibrated) return Orientation()
|
if (!isCalibrated) return Orientation()
|
||||||
|
|
||||||
// Add offset before normalizationval
|
|
||||||
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
||||||
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
||||||
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
||||||
|
|||||||
@@ -16,57 +16,200 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
import android.animation.ObjectAnimator
|
import android.animation.ObjectAnimator
|
||||||
import android.animation.PropertyValuesHolder
|
import android.animation.PropertyValuesHolder
|
||||||
|
import android.animation.ValueAnimator
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log.e
|
import android.util.Log.e
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.VelocityTracker
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.view.animation.AccelerateInterpolator
|
||||||
import android.view.animation.AnticipateOvershootInterpolator
|
import android.view.animation.AnticipateOvershootInterpolator
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import android.view.animation.OvershootInterpolator
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.VideoView
|
import android.widget.VideoView
|
||||||
import androidx.core.content.ContextCompat.getString
|
import androidx.core.content.ContextCompat.getString
|
||||||
|
import androidx.dynamicanimation.animation.DynamicAnimation
|
||||||
|
import androidx.dynamicanimation.animation.SpringAnimation
|
||||||
|
import androidx.dynamicanimation.animation.SpringForce
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
enum class IslandType {
|
enum class IslandType {
|
||||||
CONNECTED,
|
CONNECTED,
|
||||||
TAKING_OVER,
|
TAKING_OVER,
|
||||||
MOVED_TO_REMOTE,
|
MOVED_TO_REMOTE,
|
||||||
// CALL_GESTURE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class IslandWindow(context: Context) {
|
class IslandWindow(private val context: Context) {
|
||||||
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
@SuppressLint("InflateParams")
|
@SuppressLint("InflateParams")
|
||||||
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
|
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
|
||||||
private var isClosing = false
|
private var isClosing = false
|
||||||
|
private var params: WindowManager.LayoutParams? = null
|
||||||
|
|
||||||
|
private var initialY = 0f
|
||||||
|
private var initialTouchY = 0f
|
||||||
|
private var lastTouchY = 0f
|
||||||
|
private var velocityTracker: VelocityTracker? = null
|
||||||
|
private var isBeingDragged = false
|
||||||
|
private var autoCloseHandler: Handler? = null
|
||||||
|
private var autoCloseRunnable: Runnable? = null
|
||||||
|
private var initialHeight = 0
|
||||||
|
private var screenHeight = 0
|
||||||
|
private var isDraggingDown = false
|
||||||
|
private var lastMoveTime = 0L
|
||||||
|
private var yMovement = 0f
|
||||||
|
private var dragDistance = 0f
|
||||||
|
|
||||||
|
private var initialConnectedTextY = 0f
|
||||||
|
private var initialDeviceTextY = 0f
|
||||||
|
private var initialBatteryViewY = 0f
|
||||||
|
private var initialVideoViewY = 0f
|
||||||
|
private var initialTextSeparation = 0f
|
||||||
|
|
||||||
|
private val containerView = FrameLayout(context)
|
||||||
|
|
||||||
|
private lateinit var springAnimation: SpringAnimation
|
||||||
|
private val flingAnimator = ValueAnimator()
|
||||||
|
|
||||||
|
private val batteryReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
|
||||||
|
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
|
||||||
|
updateBatteryDisplay(batteryList)
|
||||||
|
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||||
|
try {
|
||||||
|
context?.unregisterReceiver(this)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val isVisible: Boolean
|
val isVisible: Boolean
|
||||||
get() = islandView.parent != null && islandView.visibility == View.VISIBLE
|
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
|
||||||
|
if (batteryList == null || batteryList.isEmpty()) return
|
||||||
|
|
||||||
|
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||||
|
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||||
|
|
||||||
|
val leftLevel = leftBattery?.level ?: 0
|
||||||
|
val rightLevel = rightBattery?.level ?: 0
|
||||||
|
val leftStatus = leftBattery?.status ?: BatteryStatus.DISCONNECTED
|
||||||
|
val rightStatus = rightBattery?.status ?: BatteryStatus.DISCONNECTED
|
||||||
|
|
||||||
|
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||||
|
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||||
|
|
||||||
|
val displayBatteryLevel = when {
|
||||||
|
leftLevel > 0 && rightLevel > 0 -> minOf(leftLevel, rightLevel)
|
||||||
|
leftLevel > 0 -> leftLevel
|
||||||
|
rightLevel > 0 -> rightLevel
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayBatteryLevel != null) {
|
||||||
|
batteryText.text = "$displayBatteryLevel%"
|
||||||
|
batteryProgressBar.progress = displayBatteryLevel
|
||||||
|
batteryProgressBar.isIndeterminate = false
|
||||||
|
} else {
|
||||||
|
batteryText.text = "?"
|
||||||
|
batteryProgressBar.progress = 0
|
||||||
|
batteryProgressBar.isIndeterminate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18s", "ClickableViewAccessibility")
|
||||||
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
|
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
|
||||||
if (ServiceManager.getService()?.islandOpen == true) return
|
if (ServiceManager.getService()?.islandOpen == true) return
|
||||||
else ServiceManager.getService()?.islandOpen = true
|
else ServiceManager.getService()?.islandOpen = true
|
||||||
|
|
||||||
val displayMetrics = Resources.getSystem().displayMetrics
|
val displayMetrics = Resources.getSystem().displayMetrics
|
||||||
val width = (displayMetrics.widthPixels * 0.95).toInt()
|
val width = (displayMetrics.widthPixels * 0.95).toInt()
|
||||||
|
screenHeight = displayMetrics.heightPixels
|
||||||
|
|
||||||
val params = WindowManager.LayoutParams(
|
val batteryList = ServiceManager.getService()?.getBattery()
|
||||||
|
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||||
|
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||||
|
|
||||||
|
val displayBatteryLevel = if (batteryList != null) {
|
||||||
|
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||||
|
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||||
|
|
||||||
|
when {
|
||||||
|
leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 ->
|
||||||
|
minOf(leftBattery!!.level, rightBattery!!.level)
|
||||||
|
leftBattery?.level ?: 0 > 0 -> leftBattery!!.level
|
||||||
|
rightBattery?.level ?: 0 > 0 -> rightBattery!!.level
|
||||||
|
batteryPercentage > 0 -> batteryPercentage
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else if (batteryPercentage > 0) {
|
||||||
|
batteryPercentage
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayBatteryLevel != null) {
|
||||||
|
batteryText.text = "$displayBatteryLevel%"
|
||||||
|
batteryProgressBar.progress = displayBatteryLevel
|
||||||
|
} else {
|
||||||
|
batteryText.text = "?"
|
||||||
|
batteryProgressBar.progress = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryProgressBar.isIndeterminate = false
|
||||||
|
islandView.findViewById<TextView>(R.id.island_device_name).text = name
|
||||||
|
|
||||||
|
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
|
||||||
|
batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(batteryReceiver, batteryIntentFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceManager.getService()?.sendBatteryBroadcast()
|
||||||
|
|
||||||
|
containerView.removeAllViews()
|
||||||
|
val containerParams = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
containerView.addView(islandView, containerParams)
|
||||||
|
|
||||||
|
params = WindowManager.LayoutParams(
|
||||||
width,
|
width,
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||||
@@ -77,12 +220,100 @@ class IslandWindow(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
islandView.visibility = View.VISIBLE
|
islandView.visibility = View.VISIBLE
|
||||||
islandView.findViewById<TextView>(R.id.island_battery_text).text = "$batteryPercentage%"
|
containerView.visibility = View.VISIBLE
|
||||||
islandView.findViewById<TextView>(R.id.island_device_name).text = name
|
|
||||||
|
|
||||||
islandView.setOnClickListener {
|
containerView.setOnTouchListener { _, event ->
|
||||||
ServiceManager.getService()?.startMainActivity()
|
when (event.action) {
|
||||||
close()
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
velocityTracker?.recycle()
|
||||||
|
velocityTracker = VelocityTracker.obtain()
|
||||||
|
velocityTracker?.addMovement(event)
|
||||||
|
|
||||||
|
initialY = containerView.translationY
|
||||||
|
initialTouchY = event.rawY
|
||||||
|
lastTouchY = event.rawY
|
||||||
|
initialHeight = islandView.height
|
||||||
|
isBeingDragged = false
|
||||||
|
isDraggingDown = false
|
||||||
|
lastMoveTime = System.currentTimeMillis()
|
||||||
|
dragDistance = 0f
|
||||||
|
|
||||||
|
captureInitialPositions()
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
velocityTracker?.addMovement(event)
|
||||||
|
val deltaY = event.rawY - initialTouchY
|
||||||
|
val moveDelta = event.rawY - lastTouchY
|
||||||
|
dragDistance += abs(moveDelta)
|
||||||
|
|
||||||
|
isDraggingDown = moveDelta > 0
|
||||||
|
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val timeDelta = currentTime - lastMoveTime
|
||||||
|
if (timeDelta > 0) {
|
||||||
|
yMovement = moveDelta / timeDelta * 10
|
||||||
|
}
|
||||||
|
lastMoveTime = currentTime
|
||||||
|
|
||||||
|
if (abs(deltaY) > 5 || isBeingDragged) {
|
||||||
|
isBeingDragged = true
|
||||||
|
|
||||||
|
// Cancel auto close timer when dragging starts
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
|
||||||
|
|
||||||
|
val dampedDeltaY = if (deltaY > 0) {
|
||||||
|
initialY + (deltaY * 0.6f)
|
||||||
|
} else {
|
||||||
|
initialY + (deltaY * 0.9f)
|
||||||
|
}
|
||||||
|
containerView.translationY = dampedDeltaY
|
||||||
|
|
||||||
|
if (isDraggingDown && deltaY > 0) {
|
||||||
|
val stretchAmount = (deltaY * 0.5f).coerceAtMost(200f)
|
||||||
|
applyCustomStretchEffect(stretchAmount, deltaY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTouchY = event.rawY
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
velocityTracker?.addMovement(event)
|
||||||
|
velocityTracker?.computeCurrentVelocity(1000)
|
||||||
|
val yVelocity = velocityTracker?.yVelocity ?: 0f
|
||||||
|
|
||||||
|
if (isBeingDragged) {
|
||||||
|
val currentTranslationY = containerView.translationY
|
||||||
|
val significantVelocity = abs(yVelocity) > 800
|
||||||
|
val significantDrag = abs(dragDistance) > 80
|
||||||
|
|
||||||
|
when {
|
||||||
|
yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> {
|
||||||
|
animateDismissWithInertia(yVelocity)
|
||||||
|
}
|
||||||
|
yVelocity > 1200 || (isDraggingDown && significantDrag) -> {
|
||||||
|
animateExpandWithStretch(yVelocity)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
springBackWithInertia(yVelocity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dragDistance < 10) {
|
||||||
|
resetAutoCloseTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
velocityTracker?.recycle()
|
||||||
|
velocityTracker = null
|
||||||
|
isBeingDragged = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (type) {
|
when (type) {
|
||||||
@@ -95,16 +326,8 @@ class IslandWindow(context: Context) {
|
|||||||
IslandType.MOVED_TO_REMOTE -> {
|
IslandType.MOVED_TO_REMOTE -> {
|
||||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
|
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
|
||||||
}
|
}
|
||||||
// IslandType.CALL_GESTURE -> {
|
|
||||||
// islandView.findViewById<TextView>(R.id.island_connected_text).text = "Incoming Call from $name"
|
|
||||||
// islandView.findViewById<TextView>(R.id.island_device_name).text = "Use Head Gestures to answer."
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
|
||||||
batteryProgressBar.progress = batteryPercentage
|
|
||||||
batteryProgressBar.isIndeterminate = false
|
|
||||||
|
|
||||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
|
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
|
||||||
videoView.setVideoURI(videoUri)
|
videoView.setVideoURI(videoUri)
|
||||||
@@ -113,19 +336,266 @@ class IslandWindow(context: Context) {
|
|||||||
videoView.start()
|
videoView.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
windowManager.addView(islandView, params)
|
windowManager.addView(containerView, params)
|
||||||
|
|
||||||
|
islandView.post {
|
||||||
|
initialHeight = islandView.height
|
||||||
|
captureInitialPositions()
|
||||||
|
}
|
||||||
|
|
||||||
|
springAnimation = SpringAnimation(containerView, DynamicAnimation.TRANSLATION_Y, 0f).apply {
|
||||||
|
spring = SpringForce(0f)
|
||||||
|
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
|
||||||
|
.setStiffness(SpringForce.STIFFNESS_MEDIUM)
|
||||||
|
}
|
||||||
|
|
||||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
|
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
|
||||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
|
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
|
||||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
|
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
|
||||||
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
|
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||||
duration = 700
|
duration = 700
|
||||||
interpolator = AnticipateOvershootInterpolator()
|
interpolator = AnticipateOvershootInterpolator()
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
Handler(Looper.getMainLooper()).postDelayed({
|
|
||||||
close()
|
resetAutoCloseTimer()
|
||||||
}, 4500)
|
}
|
||||||
|
|
||||||
|
private fun captureInitialPositions() {
|
||||||
|
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
|
||||||
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
|
|
||||||
|
connectedText.post {
|
||||||
|
initialConnectedTextY = connectedText.y
|
||||||
|
initialDeviceTextY = deviceText.y
|
||||||
|
initialTextSeparation = deviceText.y - (connectedText.y + connectedText.height)
|
||||||
|
|
||||||
|
if (batteryView != null) initialBatteryViewY = batteryView.y
|
||||||
|
initialVideoViewY = videoView.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyCustomStretchEffect(stretchAmount: Float, dragY: Float) {
|
||||||
|
try {
|
||||||
|
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||||
|
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
|
||||||
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
|
|
||||||
|
val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f)
|
||||||
|
val newMinHeight = (initialHeight * stretchFactor).toInt()
|
||||||
|
mainLayout.minimumHeight = newMinHeight
|
||||||
|
|
||||||
|
val textMarginIncrease = (stretchAmount * 0.8f).toInt()
|
||||||
|
|
||||||
|
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||||
|
deviceTextParams.topMargin = textMarginIncrease
|
||||||
|
deviceText.layoutParams = deviceTextParams
|
||||||
|
|
||||||
|
val background = mainLayout.background
|
||||||
|
if (background is GradientDrawable) {
|
||||||
|
val cornerRadius = 56f
|
||||||
|
background.cornerRadius = cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
params!!.height = screenHeight
|
||||||
|
|
||||||
|
val containerParams = containerView.layoutParams
|
||||||
|
containerParams.height = screenHeight
|
||||||
|
containerView.layoutParams = containerParams
|
||||||
|
|
||||||
|
try {
|
||||||
|
windowManager.updateViewLayout(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetAutoCloseTimer() {
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||||
|
autoCloseHandler = Handler(Looper.getMainLooper())
|
||||||
|
autoCloseRunnable = Runnable { close() }
|
||||||
|
autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun springBackWithInertia(velocity: Float) {
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
springAnimation.setStartVelocity(velocity)
|
||||||
|
|
||||||
|
val baseStiffness = SpringForce.STIFFNESS_MEDIUM
|
||||||
|
val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f))
|
||||||
|
springAnimation.spring = SpringForce(0f)
|
||||||
|
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
|
||||||
|
.setStiffness(dynamicStiffness)
|
||||||
|
|
||||||
|
resetStretchEffects(velocity)
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
params!!.height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||||
|
try {
|
||||||
|
windowManager.updateViewLayout(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
springAnimation.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetStretchEffects(velocity: Float) {
|
||||||
|
try {
|
||||||
|
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
|
||||||
|
val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight)
|
||||||
|
heightAnimator.duration = 300
|
||||||
|
heightAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||||
|
heightAnimator.addUpdateListener { animation ->
|
||||||
|
mainLayout.minimumHeight = animation.animatedValue as Int
|
||||||
|
}
|
||||||
|
|
||||||
|
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||||
|
val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0)
|
||||||
|
textMarginAnimator.duration = 300
|
||||||
|
textMarginAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||||
|
textMarginAnimator.addUpdateListener { animation ->
|
||||||
|
deviceTextParams.topMargin = animation.animatedValue as Int
|
||||||
|
deviceText.layoutParams = deviceTextParams
|
||||||
|
}
|
||||||
|
|
||||||
|
heightAnimator.start()
|
||||||
|
textMarginAnimator.start()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateDismissWithInertia(velocity: Float) {
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
val baseDistance = -screenHeight
|
||||||
|
val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f)
|
||||||
|
val targetDistance = baseDistance * velocityFactor
|
||||||
|
|
||||||
|
val baseDuration = 400L
|
||||||
|
val velocityDurationFactor = (1500f / (abs(velocity) + 1500f))
|
||||||
|
val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L)
|
||||||
|
|
||||||
|
flingAnimator.setFloatValues(containerView.translationY, targetDistance)
|
||||||
|
flingAnimator.duration = duration
|
||||||
|
flingAnimator.addUpdateListener { animation ->
|
||||||
|
containerView.translationY = animation.animatedValue as Float
|
||||||
|
|
||||||
|
val progress = animation.animatedFraction
|
||||||
|
containerView.scaleX = 1f - (progress * 0.5f)
|
||||||
|
containerView.scaleY = 1f - (progress * 0.5f)
|
||||||
|
|
||||||
|
containerView.alpha = 1f - progress
|
||||||
|
}
|
||||||
|
flingAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
forceClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
flingAnimator.interpolator = DecelerateInterpolator(1.2f)
|
||||||
|
flingAnimator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateExpandWithStretch(velocity: Float) {
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
val baseDuration = 600L
|
||||||
|
val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f)
|
||||||
|
val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L)
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
params!!.height = screenHeight
|
||||||
|
try {
|
||||||
|
windowManager.updateViewLayout(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f)
|
||||||
|
containerAnimator.duration = expandDuration
|
||||||
|
containerAnimator.interpolator = DecelerateInterpolator(0.8f)
|
||||||
|
containerAnimator.addUpdateListener { animation ->
|
||||||
|
containerView.translationY = animation.animatedValue as Float
|
||||||
|
}
|
||||||
|
|
||||||
|
val stretchAnimator = ValueAnimator.ofFloat(0f, 1f)
|
||||||
|
stretchAnimator.duration = expandDuration
|
||||||
|
stretchAnimator.interpolator = OvershootInterpolator(0.5f)
|
||||||
|
stretchAnimator.addUpdateListener { animation ->
|
||||||
|
val progress = animation.animatedValue as Float
|
||||||
|
animateCustomStretch(progress, expandDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f)
|
||||||
|
normalizeAnimator.duration = 300
|
||||||
|
normalizeAnimator.startDelay = expandDuration - 150
|
||||||
|
normalizeAnimator.interpolator = AccelerateInterpolator(1.2f)
|
||||||
|
normalizeAnimator.addUpdateListener { animation ->
|
||||||
|
val progress = animation.animatedValue as Float
|
||||||
|
containerView.alpha = progress
|
||||||
|
|
||||||
|
if (progress < 0.7f) {
|
||||||
|
islandView.findViewById<VideoView>(R.id.island_video_view).visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
ServiceManager.getService()?.startMainActivity()
|
||||||
|
forceClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
containerAnimator.start()
|
||||||
|
stretchAnimator.start()
|
||||||
|
normalizeAnimator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateCustomStretch(progress: Float, duration: Long) {
|
||||||
|
try {
|
||||||
|
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||||
|
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
|
||||||
|
val targetHeight = (screenHeight * 0.7f).toInt()
|
||||||
|
val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress)
|
||||||
|
mainLayout.minimumHeight = currentHeight.toInt()
|
||||||
|
|
||||||
|
val mainLayoutParams = mainLayout.layoutParams
|
||||||
|
mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT
|
||||||
|
mainLayout.layoutParams = mainLayoutParams
|
||||||
|
|
||||||
|
val targetMargin = (400 * progress).toInt()
|
||||||
|
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||||
|
deviceTextParams.topMargin = targetMargin
|
||||||
|
deviceText.layoutParams = deviceTextParams
|
||||||
|
|
||||||
|
val baseTextSize = 24f
|
||||||
|
deviceText.textSize = baseTextSize + (progress * 8f)
|
||||||
|
|
||||||
|
val baseSubTextSize = 16f
|
||||||
|
connectedText.textSize = baseSubTextSize + (progress * 4f)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
@@ -133,31 +603,82 @@ class IslandWindow(context: Context) {
|
|||||||
if (isClosing) return
|
if (isClosing) return
|
||||||
isClosing = true
|
isClosing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(batteryReceiver)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
ServiceManager.getService()?.islandOpen = false
|
ServiceManager.getService()?.islandOpen = false
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||||
|
|
||||||
|
resetStretchEffects(0f)
|
||||||
|
|
||||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
videoView.stopPlayback()
|
try {
|
||||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f)
|
videoView.stopPlayback()
|
||||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f)
|
} catch (e: Exception) {
|
||||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f)
|
e.printStackTrace()
|
||||||
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
|
}
|
||||||
|
|
||||||
|
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f)
|
||||||
|
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f)
|
||||||
|
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f)
|
||||||
|
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||||
duration = 700
|
duration = 700
|
||||||
interpolator = AnticipateOvershootInterpolator()
|
interpolator = AnticipateOvershootInterpolator()
|
||||||
addListener(object : AnimatorListenerAdapter() {
|
addListener(object : AnimatorListenerAdapter() {
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
islandView.visibility = View.GONE
|
cleanupAndRemoveView()
|
||||||
try {
|
|
||||||
windowManager.removeView(islandView)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e("IslandWindow", "Error removing view: $e")
|
|
||||||
}
|
|
||||||
isClosing = false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
// Even if animation fails, ensure we cleanup
|
||||||
|
cleanupAndRemoveView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupAndRemoveView() {
|
||||||
|
containerView.visibility = View.GONE
|
||||||
|
try {
|
||||||
|
if (containerView.parent != null) {
|
||||||
|
windowManager.removeView(containerView)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e("IslandWindow", "Error removing view: $e")
|
||||||
|
}
|
||||||
|
isClosing = false
|
||||||
|
// Make sure all animations are canceled
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forceClose() {
|
||||||
|
try {
|
||||||
|
if (isClosing) return
|
||||||
|
isClosing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(batteryReceiver)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Silent catch - receiver might already be unregistered
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceManager.getService()?.islandOpen = false
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||||
|
|
||||||
|
// Cancel all ongoing animations
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
// Immediately remove the view without animations
|
||||||
|
cleanupAndRemoveView()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
isClosing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,35 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (param.packageName == "com.google.android.settings") {
|
||||||
|
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
||||||
|
try {
|
||||||
|
val headerControllerClass = param.classLoader.loadClass(
|
||||||
|
"com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
|
||||||
|
|
||||||
|
val updateIconMethod = headerControllerClass.getDeclaredMethod(
|
||||||
|
"updateIcon",
|
||||||
|
android.widget.ImageView::class.java,
|
||||||
|
String::class.java)
|
||||||
|
|
||||||
|
hook(updateIconMethod, BluetoothIconHooker::class.java)
|
||||||
|
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
|
||||||
|
"displayPreference",
|
||||||
|
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
|
||||||
|
|
||||||
|
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
|
||||||
|
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (param.packageName == "com.android.settings") {
|
if (param.packageName == "com.android.settings") {
|
||||||
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
@@ -28,6 +30,7 @@ import android.util.Log
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
object MediaController {
|
object MediaController {
|
||||||
private var initialVolume: Int? = null
|
private var initialVolume: Int? = null
|
||||||
@@ -85,16 +88,18 @@ object MediaController {
|
|||||||
userPlayedTheMedia = audioManager.isMusicActive
|
userPlayedTheMedia = audioManager.isMusicActive
|
||||||
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
|
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
|
||||||
}
|
}
|
||||||
Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
|
Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice")
|
||||||
if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
|
if (!pausedForCrossDevice && audioManager.isMusicActive) {
|
||||||
Log.d("MediaController", "Pausing for cross device and taking over.")
|
ServiceManager.getService()?.takeOver("music")
|
||||||
sendPause(true)
|
|
||||||
pausedForCrossDevice = true
|
|
||||||
ServiceManager.getService()?.takeOver()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getMusicActive(): Boolean {
|
||||||
|
return audioManager.isMusicActive
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun sendPause(force: Boolean = false) {
|
fun sendPause(force: Boolean = false) {
|
||||||
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
|
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
|
||||||
@@ -135,6 +140,14 @@ object MediaController {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!audioManager.isMusicActive) {
|
||||||
|
Log.d("MediaController", "Setting iPausedTheMedia to false")
|
||||||
|
iPausedTheMedia = false
|
||||||
|
}
|
||||||
|
if (pausedForCrossDevice) {
|
||||||
|
Log.d("MediaController", "Setting pausedForCrossDevice to false")
|
||||||
|
pausedForCrossDevice = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
|||||||
@@ -136,10 +136,23 @@ class AirPodsNotifications {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setStatus(data: ByteArray) {
|
fun setStatus(data: ByteArray) {
|
||||||
if (data.size != 11) {
|
when (data.size) {
|
||||||
return
|
// if the whole packet is given
|
||||||
|
11 -> {
|
||||||
|
status = data[7].toInt()
|
||||||
|
}
|
||||||
|
// if only the data is given
|
||||||
|
1 -> {
|
||||||
|
status = data[0].toInt()
|
||||||
|
}
|
||||||
|
// if the value of control command is given
|
||||||
|
4 -> {
|
||||||
|
status = data[0].toInt()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d("ANC", "Invalid ANC data size: ${data.size}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
status = data[7].toInt()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val name: String =
|
val name: String =
|
||||||
@@ -172,6 +185,19 @@ class AirPodsNotifications {
|
|||||||
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
|
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setBatteryDirect(
|
||||||
|
leftLevel: Int,
|
||||||
|
leftCharging: Boolean,
|
||||||
|
rightLevel: Int,
|
||||||
|
rightCharging: Boolean,
|
||||||
|
caseLevel: Int,
|
||||||
|
caseCharging: Boolean
|
||||||
|
) {
|
||||||
|
first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||||
|
second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||||
|
case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||||
|
}
|
||||||
|
|
||||||
fun setBattery(data: ByteArray) {
|
fun setBattery(data: ByteArray) {
|
||||||
if (data.size != 22) {
|
if (data.size != 22) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -32,6 +34,7 @@ import java.io.FileOutputStream
|
|||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@NoLiveLiterals
|
@NoLiveLiterals
|
||||||
class RadareOffsetFinder(context: Context) {
|
class RadareOffsetFinder(context: Context) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.widgets
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class BatteryWidget : AppWidgetProvider() {
|
class BatteryWidget : AppWidgetProvider() {
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.widgets
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ import android.util.Log
|
|||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class NoiseControlWidget : AppWidgetProvider() {
|
class NoiseControlWidget : AppWidgetProvider() {
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
@@ -79,7 +82,12 @@ class NoiseControlWidget : AppWidgetProvider() {
|
|||||||
if (intent.action == "ACTION_SET_ANC_MODE") {
|
if (intent.action == "ACTION_SET_ANC_MODE") {
|
||||||
val mode = intent.getIntExtra("ANC_MODE", 1)
|
val mode = intent.getIntExtra("ANC_MODE", 1)
|
||||||
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
||||||
ServiceManager.getService()?.setANCMode(mode)
|
ServiceManager.getService()!!
|
||||||
|
.aacpManager
|
||||||
|
.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||||
|
mode.toByte()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/island_window_layout"
|
android:id="@+id/island_window_layout"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="16dp"
|
android:layout_margin="16dp"
|
||||||
android:layout_weight="0.95"
|
|
||||||
android:background="@drawable/island_background"
|
android:background="@drawable/island_background"
|
||||||
android:elevation="4dp"
|
android:elevation="4dp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="0dp"
|
android:layout_margin="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:gravity="bottom"
|
android:gravity="bottom"
|
||||||
@@ -38,12 +37,12 @@
|
|||||||
android:layout_margin="0dp"
|
android:layout_margin="0dp"
|
||||||
android:fontFamily="@font/sf_pro"
|
android:fontFamily="@font/sf_pro"
|
||||||
android:gravity="bottom"
|
android:gravity="bottom"
|
||||||
android:padding="0dp"
|
|
||||||
android:text="@string/island_connected_text"
|
|
||||||
android:textColor="#707072"
|
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:lineSpacingExtra="0dp"
|
android:lineSpacingExtra="0dp"
|
||||||
android:lineSpacingMultiplier="1"
|
android:lineSpacingMultiplier="1"
|
||||||
|
android:padding="0dp"
|
||||||
|
android:text="@string/island_connected_text"
|
||||||
|
android:textColor="#707072"
|
||||||
android:textSize="16sp" />
|
android:textSize="16sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -53,19 +52,20 @@
|
|||||||
android:layout_margin="0dp"
|
android:layout_margin="0dp"
|
||||||
android:fontFamily="@font/sf_pro"
|
android:fontFamily="@font/sf_pro"
|
||||||
android:gravity="bottom"
|
android:gravity="bottom"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:lineSpacingExtra="0dp"
|
||||||
|
android:lineSpacingMultiplier="1"
|
||||||
android:padding="0dp"
|
android:padding="0dp"
|
||||||
android:text="AirPods Pro"
|
android:text="AirPods Pro"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:textSize="24sp"
|
android:textSize="24sp"
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:lineSpacingExtra="0dp"
|
|
||||||
android:lineSpacingMultiplier="1"
|
|
||||||
tools:ignore="HardcodedText" />
|
tools:ignore="HardcodedText" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
android:id="@+id/island_battery_container"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -102,4 +102,4 @@
|
|||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
tools:ignore="HardcodedText" />
|
tools:ignore="HardcodedText" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -64,4 +64,19 @@
|
|||||||
<string name="collect_logs">Collect Logs</string>
|
<string name="collect_logs">Collect Logs</string>
|
||||||
<string name="saved_logs">Saved Logs</string>
|
<string name="saved_logs">Saved Logs</string>
|
||||||
<string name="no_logs_found">No saved logs found</string>
|
<string name="no_logs_found">No saved logs found</string>
|
||||||
|
<string name="takeover_header">Auto-Connect preferences</string>
|
||||||
|
<string name="takeover_airpods_state">Connect to your AirPods when its status is:</string>
|
||||||
|
<string name="takeover_disconnected">Disconnected</string>
|
||||||
|
<string name="takeover_disconnected_desc">AirPods are not connected to a device</string>
|
||||||
|
<string name="takeover_idle">Idle</string>
|
||||||
|
<string name="takeover_idle_desc">A device is connected to your AirPods, but not playing media or on a call</string>
|
||||||
|
<string name="takeover_music">Playing media</string>
|
||||||
|
<string name="takeover_music_desc">A device is playing media on your AirPods</string>
|
||||||
|
<string name="takeover_call">On call</string>
|
||||||
|
<string name="takeover_call_desc">A device is on a call with your AirPods</string>
|
||||||
|
<string name="takeover_phone_state">Connect to AirPods when your phone is:</string>
|
||||||
|
<string name="takeover_ringing_call">Receiving a call</string>
|
||||||
|
<string name="takeover_ringing_call_desc">Your phone starts ringing</string>
|
||||||
|
<string name="takeover_media_start">Starting media playback</string>
|
||||||
|
<string name="takeover_media_start_desc">Your phone starts playing media</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
com.android.bluetooth
|
com.android.bluetooth
|
||||||
me.kavishdevar.librepods
|
com.google.android.settings
|
||||||
android
|
|
||||||
com.android.systemui
|
|
||||||
com.android.settings
|
com.android.settings
|
||||||
com.google.android.bluetooth
|
com.google.android.bluetooth
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ hazeMaterials = "1.5.3"
|
|||||||
sliceBuilders = "1.1.0-alpha02"
|
sliceBuilders = "1.1.0-alpha02"
|
||||||
sliceCore = "1.1.0-alpha02"
|
sliceCore = "1.1.0-alpha02"
|
||||||
sliceView = "1.1.0-alpha02"
|
sliceView = "1.1.0-alpha02"
|
||||||
|
dynamicanimation = "1.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
|
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
|
||||||
@@ -35,6 +36,7 @@ haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", versi
|
|||||||
androidx-slice-builders = { group = "androidx.slice", name = "slice-builders", version.ref = "sliceBuilders" }
|
androidx-slice-builders = { group = "androidx.slice", name = "slice-builders", version.ref = "sliceBuilders" }
|
||||||
androidx-slice-core = { group = "androidx.slice", name = "slice-core", version.ref = "sliceCore" }
|
androidx-slice-core = { group = "androidx.slice", name = "slice-core", version.ref = "sliceCore" }
|
||||||
androidx-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" }
|
androidx-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" }
|
||||||
|
androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
55
docs/control_commands.md
Normal file
55
docs/control_commands.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Control Commands
|
||||||
|
|
||||||
|
AACP uses opcode `9` for control commands. opcodes are 16 bit integers that specify the kind of action being done. The length of a control command is fixed to 7 bytes + 4 bytes header (`04 00 04 00`)
|
||||||
|
|
||||||
|
An AACP packet is formated as:
|
||||||
|
|
||||||
|
`04 00 04 00 [opcode, little endianness] [data]`
|
||||||
|
|
||||||
|
So, our control commands becomes
|
||||||
|
|
||||||
|
```
|
||||||
|
04 00 04 00 09 00 [identifier] [data1] [data2] [data3] [data4]
|
||||||
|
```
|
||||||
|
|
||||||
|
Bytes that are not used are set to `0x00`. From what I've observed, the `data3` and `data4` are never used, and hence always zero. And, the `data2` is usually used when the configuration can be different for the two buds: like, to change the long press mode. Or, if there can be two "state" variables for the same feature: like the Hearing Aid feature.
|
||||||
|
|
||||||
|
## Control Commands
|
||||||
|
These commands
|
||||||
|
|
||||||
|
| Command identifier | Description | Format |
|
||||||
|
|--------------|---------------------|--------|
|
||||||
|
| 0x01 | Mic Mode | Single value (1 byte) |
|
||||||
|
| 0x05 | Button Send Mode | Single value (1 byte) |
|
||||||
|
| 0x12 | VoiceTrigger for Siri | Single Value (1 byte): `0x01` = enabled, `0x01` = disabled |
|
||||||
|
| 0x14 | SingleClickMode | Single value (1 byte) |
|
||||||
|
| 0x15 | DoubleClickMode | Single value (1 byte) |
|
||||||
|
| 0x16 | ClickHoldMode | Two values (2 bytes; First byte = right bud Second byte = for left): `0x01` = Noise control `0x05` = Siri |
|
||||||
|
| 0x17 | DoubleClickInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest|
|
||||||
|
| 0x18 | ClickHoldInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest|
|
||||||
|
| 0x1A | ListeningModeConfigs | Single value (1 byte): bitwise OR of the selected modes. Off mode = `0x01`, ANC=`0x02`, Transparency = 0x04, Adaptive = `0x08` |
|
||||||
|
| 0x1B | OneBudANCMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x1C | CrownRotationDirection | Single value (1 byte): `0x01` = reversed, `0x02` = default |
|
||||||
|
| 0x0D | ListeningMode | Single value (1 byte): 1 = Off, 2 = noise cancellation, 3 = transparency, 4 = adaptive |
|
||||||
|
| 0x1E | AutoAnswerMode | Single value (1 byte) |
|
||||||
|
| 0x1F | Chime Volume | Single value (1 byte): 0 to 100|
|
||||||
|
| 0x23 | VolumeSwipeInterval | Single value (1 byte): 0x00 = Default, `0x01` = Longer, `0x02` = Longest |
|
||||||
|
| 0x24 | Call Management Config | Single value (1 byte) |
|
||||||
|
| 0x25 | VolumeSwipeMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x26 | Adaptive Volume Config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x27 | Software Mute config | Single value (1 byte) |
|
||||||
|
| 0x28 | Conversation Detect config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x29 | SSL | Single value (1 byte) |
|
||||||
|
| 0x2C | Hearing Aid Enrolled and Hearing Aid Enabled | Two values (2 bytes; First byte - enrolled, Second byte = enabled): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x2E | AutoANC Strength | Single value (1 byte): 0 to 100|
|
||||||
|
| 0x2F | HPS Gain Swipe | Single value (1 byte) |
|
||||||
|
| 0x30 | HRM enable/disable state | Single value (1 byte) |
|
||||||
|
| 0x31 | In Case Tone config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x32 | Siri Multitone config | Single value (1 byte) |
|
||||||
|
| 0x33 | Hearing Assist config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
| 0x34 | Allow Off Option for Listening Mode config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||||
|
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> - These identifiers have been extracted from the macOS 15.4 Beta (24E5238a)'s bluetooth stack.
|
||||||
|
> - I have already added the ranges of values a command takes that I know of. Feel free to experiemnt by sending the packets for which the range/values are not given here.
|
||||||
72
linux/BasicControlCommand.hpp
Normal file
72
linux/BasicControlCommand.hpp
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#include <QByteArray>
|
||||||
|
|
||||||
|
// Control Command Header
|
||||||
|
namespace ControlCommand
|
||||||
|
{
|
||||||
|
static const QByteArray HEADER = QByteArray::fromHex("040004000900");
|
||||||
|
|
||||||
|
// Helper function to create control command packets
|
||||||
|
static QByteArray createCommand(quint8 identifier, quint8 data1 = 0x00, quint8 data2 = 0x00,
|
||||||
|
quint8 data3 = 0x00, quint8 data4 = 0x00)
|
||||||
|
{
|
||||||
|
QByteArray packet = HEADER;
|
||||||
|
packet.append(static_cast<char>(identifier));
|
||||||
|
packet.append(static_cast<char>(data1));
|
||||||
|
packet.append(static_cast<char>(data2));
|
||||||
|
packet.append(static_cast<char>(data3));
|
||||||
|
packet.append(static_cast<char>(data4));
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::optional<char> parseActive(const QByteArray &data)
|
||||||
|
{
|
||||||
|
if (!data.startsWith(ControlCommand::HEADER))
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
return static_cast<quint8>(data.at(7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template <quint8 CommandId>
|
||||||
|
struct BasicControlCommand
|
||||||
|
{
|
||||||
|
static constexpr quint8 ID = CommandId;
|
||||||
|
static const QByteArray HEADER;
|
||||||
|
|
||||||
|
static const QByteArray ENABLED;
|
||||||
|
static const QByteArray DISABLED;
|
||||||
|
|
||||||
|
static QByteArray create(quint8 data1 = 0x00, quint8 data2 = 0x00,
|
||||||
|
quint8 data3 = 0x00, quint8 data4 = 0x00)
|
||||||
|
{
|
||||||
|
return ControlCommand::createCommand(ID, data1, data2, data3, data4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basically returns the byte at the index 7
|
||||||
|
static std::optional<bool> parseState(const QByteArray &data)
|
||||||
|
{
|
||||||
|
switch (ControlCommand::parseActive(data).value_or(0x00))
|
||||||
|
{
|
||||||
|
case 0x01: // Enabled
|
||||||
|
return true;
|
||||||
|
case 0x02: // Disabled
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::optional<char> getValue(const QByteArray &data)
|
||||||
|
{
|
||||||
|
return ControlCommand::parseActive(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
template <quint8 CommandId>
|
||||||
|
const QByteArray BasicControlCommand<CommandId>::HEADER = ControlCommand::HEADER + static_cast<char>(CommandId);
|
||||||
|
|
||||||
|
template <quint8 CommandId>
|
||||||
|
const QByteArray BasicControlCommand<CommandId>::ENABLED = create(0x01);
|
||||||
|
|
||||||
|
template <quint8 CommandId>
|
||||||
|
const QByteArray BasicControlCommand<CommandId>::DISABLED = create(0x02);
|
||||||
@@ -22,6 +22,8 @@ qt_add_executable(applinux
|
|||||||
BluetoothMonitor.cpp
|
BluetoothMonitor.cpp
|
||||||
BluetoothMonitor.h
|
BluetoothMonitor.h
|
||||||
autostartmanager.hpp
|
autostartmanager.hpp
|
||||||
|
BasicControlCommand.hpp
|
||||||
|
deviceinfo.hpp
|
||||||
)
|
)
|
||||||
|
|
||||||
qt_add_qml_module(applinux
|
qt_add_qml_module(applinux
|
||||||
|
|||||||
@@ -94,46 +94,46 @@ ApplicationWindow {
|
|||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
PodColumn {
|
PodColumn {
|
||||||
isVisible: airPodsTrayApp.battery.leftPodAvailable
|
isVisible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
|
||||||
inEar: airPodsTrayApp.leftPodInEar
|
inEar: airPodsTrayApp.deviceInfo.leftPodInEar
|
||||||
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.podIcon
|
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
|
||||||
batteryLevel: airPodsTrayApp.battery.leftPodLevel
|
batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel
|
||||||
isCharging: airPodsTrayApp.battery.leftPodCharging
|
isCharging: airPodsTrayApp.deviceInfo.battery.leftPodCharging
|
||||||
indicator: "L"
|
indicator: "L"
|
||||||
}
|
}
|
||||||
|
|
||||||
PodColumn {
|
PodColumn {
|
||||||
isVisible: airPodsTrayApp.battery.rightPodAvailable
|
isVisible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
|
||||||
inEar: airPodsTrayApp.rightPodInEar
|
inEar: airPodsTrayApp.deviceInfo.rightPodInEar
|
||||||
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.podIcon
|
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
|
||||||
batteryLevel: airPodsTrayApp.battery.rightPodLevel
|
batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel
|
||||||
isCharging: airPodsTrayApp.battery.rightPodCharging
|
isCharging: airPodsTrayApp.deviceInfo.battery.rightPodCharging
|
||||||
indicator: "R"
|
indicator: "R"
|
||||||
}
|
}
|
||||||
|
|
||||||
PodColumn {
|
PodColumn {
|
||||||
isVisible: airPodsTrayApp.battery.caseAvailable
|
isVisible: airPodsTrayApp.deviceInfo.battery.caseAvailable
|
||||||
inEar: true
|
inEar: true
|
||||||
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.caseIcon
|
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon
|
||||||
batteryLevel: airPodsTrayApp.battery.caseLevel
|
batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel
|
||||||
isCharging: airPodsTrayApp.battery.caseCharging
|
isCharging: airPodsTrayApp.deviceInfo.battery.caseCharging
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SegmentedControl {
|
SegmentedControl {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
model: ["Off", "Noise Cancellation", "Transparency", "Adaptive"]
|
model: ["Off", "Noise Cancellation", "Transparency", "Adaptive"]
|
||||||
currentIndex: airPodsTrayApp.noiseControlMode
|
currentIndex: airPodsTrayApp.deviceInfo.noiseControlMode
|
||||||
onCurrentIndexChanged: airPodsTrayApp.noiseControlMode = currentIndex
|
onCurrentIndexChanged: airPodsTrayApp.setNoiseControlModeInt(currentIndex)
|
||||||
visible: airPodsTrayApp.airpodsConnected
|
visible: airPodsTrayApp.airpodsConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
Slider {
|
Slider {
|
||||||
visible: airPodsTrayApp.adaptiveModeActive
|
visible: airPodsTrayApp.deviceInfo.adaptiveModeActive
|
||||||
from: 0
|
from: 0
|
||||||
to: 100
|
to: 100
|
||||||
stepSize: 1
|
stepSize: 1
|
||||||
value: airPodsTrayApp.adaptiveNoiseLevel
|
value: airPodsTrayApp.deviceInfo.adaptiveNoiseLevel
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: debounceTimer
|
id: debounceTimer
|
||||||
@@ -153,8 +153,8 @@ ApplicationWindow {
|
|||||||
Switch {
|
Switch {
|
||||||
visible: airPodsTrayApp.airpodsConnected
|
visible: airPodsTrayApp.airpodsConnected
|
||||||
text: "Conversational Awareness"
|
text: "Conversational Awareness"
|
||||||
checked: airPodsTrayApp.conversationalAwareness
|
checked: airPodsTrayApp.deviceInfo.conversationalAwareness
|
||||||
onCheckedChanged: airPodsTrayApp.conversationalAwareness = checked
|
onCheckedChanged: airPodsTrayApp.setConversationalAwareness(checked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +226,19 @@ ApplicationWindow {
|
|||||||
onCheckedChanged: airPodsTrayApp.notificationsEnabled = checked
|
onCheckedChanged: airPodsTrayApp.notificationsEnabled = checked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Switch {
|
||||||
|
visible: airPodsTrayApp.airpodsConnected
|
||||||
|
text: "One Bud ANC Mode"
|
||||||
|
checked: airPodsTrayApp.deviceInfo.oneBudANCMode
|
||||||
|
onCheckedChanged: airPodsTrayApp.deviceInfo.oneBudANCMode = checked
|
||||||
|
|
||||||
|
ToolTip {
|
||||||
|
visible: parent.hovered
|
||||||
|
text: "Enable ANC when using one AirPod\n(More noise reduction, but uses more battery)"
|
||||||
|
delay: 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
spacing: 5
|
spacing: 5
|
||||||
Label {
|
Label {
|
||||||
@@ -246,13 +259,13 @@ ApplicationWindow {
|
|||||||
|
|
||||||
TextField {
|
TextField {
|
||||||
id: newNameField
|
id: newNameField
|
||||||
placeholderText: airPodsTrayApp.deviceName
|
placeholderText: airPodsTrayApp.deviceInfo.deviceName
|
||||||
maximumLength: 32
|
maximumLength: 32
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
text: "Rename"
|
text: "Rename"
|
||||||
onClicked: airPodsTrayApp.renameAirPods(newNameField.text)
|
onClicked: airPodsTrayApp.deviceInfo.renameAirPods(newNameField.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ALN Linux app
|
# LibrePods Linux
|
||||||
|
|
||||||
A native Linux application to control your AirPods, with support for:
|
A native Linux application to control your AirPods, with support for:
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,25 @@
|
|||||||
#define AIRPODS_PACKETS_H
|
#define AIRPODS_PACKETS_H
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
#include "enums.h"
|
#include "enums.h"
|
||||||
|
#include "BasicControlCommand.hpp"
|
||||||
|
|
||||||
namespace AirPodsPackets
|
namespace AirPodsPackets
|
||||||
{
|
{
|
||||||
// Noise Control Mode Packets
|
// Noise Control Mode Packets
|
||||||
namespace NoiseControl
|
namespace NoiseControl
|
||||||
{
|
{
|
||||||
static const QByteArray HEADER = QByteArray::fromHex("0400040009000D"); // Added for parsing
|
using NoiseControlMode = AirpodsTrayApp::Enums::NoiseControlMode;
|
||||||
static const QByteArray OFF = HEADER + QByteArray::fromHex("01000000");
|
static const QByteArray HEADER = ControlCommand::HEADER + 0x0D;
|
||||||
static const QByteArray NOISE_CANCELLATION = HEADER + QByteArray::fromHex("02000000");
|
static const QByteArray OFF = ControlCommand::createCommand(0x0D, 0x01);
|
||||||
static const QByteArray TRANSPARENCY = HEADER + QByteArray::fromHex("03000000");
|
static const QByteArray NOISE_CANCELLATION = ControlCommand::createCommand(0x0D, 0x02);
|
||||||
static const QByteArray ADAPTIVE = HEADER + QByteArray::fromHex("04000000");
|
static const QByteArray TRANSPARENCY = ControlCommand::createCommand(0x0D, 0x03);
|
||||||
|
static const QByteArray ADAPTIVE = ControlCommand::createCommand(0x0D, 0x04);
|
||||||
|
|
||||||
static const QByteArray getPacketForMode(AirpodsTrayApp::Enums::NoiseControlMode mode)
|
static const QByteArray getPacketForMode(AirpodsTrayApp::Enums::NoiseControlMode mode)
|
||||||
{
|
{
|
||||||
using NoiseControlMode = AirpodsTrayApp::Enums::NoiseControlMode;
|
|
||||||
switch (mode)
|
switch (mode)
|
||||||
{
|
{
|
||||||
case NoiseControlMode::Off:
|
case NoiseControlMode::Off:
|
||||||
@@ -33,34 +36,86 @@ namespace AirPodsPackets
|
|||||||
return QByteArray();
|
return QByteArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Conversational Awareness Packets
|
inline std::optional<NoiseControlMode> parseMode(const QByteArray &data)
|
||||||
namespace ConversationalAwareness
|
|
||||||
{
|
|
||||||
static const QByteArray HEADER = QByteArray::fromHex("04000400090028"); // For command/status
|
|
||||||
static const QByteArray ENABLED = HEADER + QByteArray::fromHex("01000000"); // Command to enable
|
|
||||||
static const QByteArray DISABLED = HEADER + QByteArray::fromHex("02000000"); // Command to disable
|
|
||||||
static const QByteArray DATA_HEADER = QByteArray::fromHex("040004004B00020001"); // For received speech level data
|
|
||||||
|
|
||||||
static std::optional<bool> parseCAState(const QByteArray &data)
|
|
||||||
{
|
{
|
||||||
// Extract the status byte (index 7)
|
char mode = ControlCommand::parseActive(data).value_or(CHAR_MAX);
|
||||||
quint8 statusByte = static_cast<quint8>(data.at(HEADER.size())); // HEADER.size() is 7
|
if (mode < static_cast<quint8>(NoiseControlMode::MinValue) ||
|
||||||
|
mode > static_cast<quint8>(NoiseControlMode::MaxValue))
|
||||||
// Interpret the status byte
|
|
||||||
switch (statusByte)
|
|
||||||
{
|
{
|
||||||
case 0x01: // Enabled
|
|
||||||
return true;
|
|
||||||
case 0x02: // Disabled
|
|
||||||
return false;
|
|
||||||
default:
|
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
return static_cast<NoiseControlMode>(mode - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One Bud ANC Mode
|
||||||
|
namespace OneBudANCMode
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x1B>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume Swipe (partial - still needs custom interval function)
|
||||||
|
namespace VolumeSwipe
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x25>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
|
||||||
|
// Keep custom interval function
|
||||||
|
static QByteArray getIntervalPacket(quint8 interval)
|
||||||
|
{
|
||||||
|
return ControlCommand::createCommand(0x23, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptive Volume Config
|
||||||
|
namespace AdaptiveVolume
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x26>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversational Awareness
|
||||||
|
namespace ConversationalAwareness
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x28>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
static const QByteArray DATA_HEADER = QByteArray::fromHex("040004004B00020001");
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hearing Assist
|
||||||
|
namespace HearingAssist
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x33>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow Off Option
|
||||||
|
namespace AllowOffOption
|
||||||
|
{
|
||||||
|
using Type = BasicControlCommand<0x34>;
|
||||||
|
static const QByteArray ENABLED = Type::ENABLED;
|
||||||
|
static const QByteArray DISABLED = Type::DISABLED;
|
||||||
|
static const QByteArray HEADER = Type::HEADER;
|
||||||
|
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
||||||
|
}
|
||||||
|
|
||||||
// Connection Packets
|
// Connection Packets
|
||||||
namespace Connection
|
namespace Connection
|
||||||
{
|
{
|
||||||
@@ -118,65 +173,37 @@ namespace AirPodsPackets
|
|||||||
{
|
{
|
||||||
MagicCloudKeys keys;
|
MagicCloudKeys keys;
|
||||||
|
|
||||||
// Expected size: header (7 bytes) + (1 (tag) + 2 (length) + 1 (reserved) + 16 (value)) * 2 = 47 bytes.
|
if (data.size() < 47 || !data.startsWith(MAGIC_CLOUD_KEYS_HEADER))
|
||||||
if (data.size() < 47)
|
|
||||||
{
|
{
|
||||||
return keys; // or handle error as needed
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check header
|
int index = MAGIC_CLOUD_KEYS_HEADER.size();
|
||||||
if (!data.startsWith(MAGIC_CLOUD_KEYS_HEADER))
|
|
||||||
{
|
|
||||||
return keys; // header mismatch
|
|
||||||
}
|
|
||||||
|
|
||||||
int index = MAGIC_CLOUD_KEYS_HEADER.size(); // Start after header (index 7)
|
// First TLV block (MagicAccIRK)
|
||||||
|
|
||||||
// --- TLV Block 1 (MagicAccIRK) ---
|
|
||||||
// Tag should be 0x01
|
|
||||||
if (static_cast<quint8>(data.at(index)) != 0x01)
|
if (static_cast<quint8>(data.at(index)) != 0x01)
|
||||||
{
|
return keys;
|
||||||
return keys; // unexpected tag
|
|
||||||
}
|
|
||||||
index += 1;
|
index += 1;
|
||||||
|
|
||||||
// Read length (2 bytes, big-endian)
|
|
||||||
quint16 len1 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
|
quint16 len1 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
|
||||||
if (len1 != 16)
|
if (len1 != 16)
|
||||||
{
|
return keys;
|
||||||
return keys; // invalid length
|
index += 3; // Skip length (2 bytes) and reserved byte (1 byte)
|
||||||
}
|
|
||||||
index += 2;
|
|
||||||
|
|
||||||
// Skip reserved byte
|
|
||||||
index += 1;
|
|
||||||
|
|
||||||
// Extract MagicAccIRK (16 bytes)
|
|
||||||
keys.magicAccIRK = data.mid(index, 16);
|
keys.magicAccIRK = data.mid(index, 16);
|
||||||
index += 16;
|
index += 16;
|
||||||
|
|
||||||
// --- TLV Block 2 (MagicAccEncKey) ---
|
// Second TLV block (MagicAccEncKey)
|
||||||
// Tag should be 0x04
|
|
||||||
if (static_cast<quint8>(data.at(index)) != 0x04)
|
if (static_cast<quint8>(data.at(index)) != 0x04)
|
||||||
{
|
return keys;
|
||||||
return keys; // unexpected tag
|
|
||||||
}
|
|
||||||
index += 1;
|
index += 1;
|
||||||
|
|
||||||
// Read length (2 bytes, big-endian)
|
|
||||||
quint16 len2 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
|
quint16 len2 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
|
||||||
if (len2 != 16)
|
if (len2 != 16)
|
||||||
{
|
return keys;
|
||||||
return keys; // invalid length
|
index += 3; // Skip length (2 bytes) and reserved byte (1 byte)
|
||||||
}
|
|
||||||
index += 2;
|
|
||||||
|
|
||||||
// Skip reserved byte
|
|
||||||
index += 1;
|
|
||||||
|
|
||||||
// Extract MagicAccEncKey (16 bytes)
|
|
||||||
keys.magicAccEncKey = data.mid(index, 16);
|
keys.magicAccEncKey = data.mid(index, 16);
|
||||||
index += 16;
|
|
||||||
|
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|||||||
244
linux/deviceinfo.hpp
Normal file
244
linux/deviceinfo.hpp
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QSettings>
|
||||||
|
#include "battery.hpp"
|
||||||
|
#include "enums.h"
|
||||||
|
|
||||||
|
using namespace AirpodsTrayApp::Enums;
|
||||||
|
|
||||||
|
class DeviceInfo : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged)
|
||||||
|
Q_PROPERTY(QString earDetectionStatus READ earDetectionStatus WRITE setEarDetectionStatus NOTIFY earDetectionStatusChanged)
|
||||||
|
Q_PROPERTY(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt)
|
||||||
|
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
|
||||||
|
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
|
||||||
|
Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged)
|
||||||
|
Q_PROPERTY(Battery *battery READ getBattery CONSTANT)
|
||||||
|
Q_PROPERTY(bool primaryInEar READ isPrimaryInEar WRITE setPrimaryInEar NOTIFY primaryChanged)
|
||||||
|
Q_PROPERTY(bool secondaryInEar READ isSecondaryInEar WRITE setSecondaryInEar NOTIFY primaryChanged)
|
||||||
|
Q_PROPERTY(bool oneBudANCMode READ oneBudANCMode WRITE setOneBudANCMode NOTIFY oneBudANCModeChanged)
|
||||||
|
Q_PROPERTY(AirPodsModel model READ model WRITE setModel NOTIFY modelChanged)
|
||||||
|
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChangedInt)
|
||||||
|
Q_PROPERTY(QString podIcon READ podIcon NOTIFY modelChanged)
|
||||||
|
Q_PROPERTY(QString caseIcon READ caseIcon NOTIFY modelChanged)
|
||||||
|
Q_PROPERTY(bool leftPodInEar READ isLeftPodInEar NOTIFY primaryChanged)
|
||||||
|
Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged)
|
||||||
|
Q_PROPERTY(QString bluetoothAddress READ bluetoothAddress WRITE setBluetoothAddress NOTIFY bluetoothAddressChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)) {}
|
||||||
|
|
||||||
|
QString batteryStatus() const { return m_batteryStatus; }
|
||||||
|
void setBatteryStatus(const QString &status)
|
||||||
|
{
|
||||||
|
if (m_batteryStatus != status)
|
||||||
|
{
|
||||||
|
m_batteryStatus = status;
|
||||||
|
emit batteryStatusChanged(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString earDetectionStatus() const { return m_earDetectionStatus; }
|
||||||
|
void setEarDetectionStatus(const QString &status)
|
||||||
|
{
|
||||||
|
if (m_earDetectionStatus != status)
|
||||||
|
{
|
||||||
|
m_earDetectionStatus = status;
|
||||||
|
emit earDetectionStatusChanged(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NoiseControlMode noiseControlMode() const { return m_noiseControlMode; }
|
||||||
|
void setNoiseControlMode(NoiseControlMode mode)
|
||||||
|
{
|
||||||
|
if (m_noiseControlMode != mode)
|
||||||
|
{
|
||||||
|
m_noiseControlMode = mode;
|
||||||
|
emit noiseControlModeChanged(mode);
|
||||||
|
emit noiseControlModeChangedInt(static_cast<int>(mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int noiseControlModeInt() const { return static_cast<int>(noiseControlMode()); }
|
||||||
|
void setNoiseControlModeInt(int mode) { setNoiseControlMode(static_cast<NoiseControlMode>(mode)); }
|
||||||
|
|
||||||
|
bool conversationalAwareness() const { return m_conversationalAwareness; }
|
||||||
|
void setConversationalAwareness(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_conversationalAwareness != enabled)
|
||||||
|
{
|
||||||
|
m_conversationalAwareness = enabled;
|
||||||
|
emit conversationalAwarenessChanged(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
|
||||||
|
void setAdaptiveNoiseLevel(int level)
|
||||||
|
{
|
||||||
|
if (m_adaptiveNoiseLevel != level)
|
||||||
|
{
|
||||||
|
m_adaptiveNoiseLevel = level;
|
||||||
|
emit adaptiveNoiseLevelChanged(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString deviceName() const { return m_deviceName; }
|
||||||
|
void setDeviceName(const QString &name)
|
||||||
|
{
|
||||||
|
if (m_deviceName != name)
|
||||||
|
{
|
||||||
|
m_deviceName = name;
|
||||||
|
emit deviceNameChanged(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Battery *getBattery() const { return m_battery; }
|
||||||
|
|
||||||
|
bool isPrimaryInEar() const { return m_primaryInEar; }
|
||||||
|
void setPrimaryInEar(bool inEar)
|
||||||
|
{
|
||||||
|
if (m_primaryInEar != inEar)
|
||||||
|
{
|
||||||
|
m_primaryInEar = inEar;
|
||||||
|
emit primaryChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSecondaryInEar() const { return m_secoundaryInEar; }
|
||||||
|
void setSecondaryInEar(bool inEar)
|
||||||
|
{
|
||||||
|
if (m_secoundaryInEar != inEar)
|
||||||
|
{
|
||||||
|
m_secoundaryInEar = inEar;
|
||||||
|
emit primaryChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool oneBudANCMode() const { return m_oneBudANCMode; }
|
||||||
|
void setOneBudANCMode(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_oneBudANCMode != enabled)
|
||||||
|
{
|
||||||
|
m_oneBudANCMode = enabled;
|
||||||
|
emit oneBudANCModeChanged(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsModel model() const { return m_model; }
|
||||||
|
void setModel(AirPodsModel model)
|
||||||
|
{
|
||||||
|
if (m_model != model)
|
||||||
|
{
|
||||||
|
m_model = model;
|
||||||
|
emit modelChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray magicAccIRK() const { return m_magicAccIRK; }
|
||||||
|
void setMagicAccIRK(const QByteArray &irk) { m_magicAccIRK = irk; }
|
||||||
|
|
||||||
|
QByteArray magicAccEncKey() const { return m_magicAccEncKey; }
|
||||||
|
void setMagicAccEncKey(const QByteArray &key) { m_magicAccEncKey = key; }
|
||||||
|
|
||||||
|
QString modelNumber() const { return m_modelNumber; }
|
||||||
|
void setModelNumber(const QString &modelNumber) { m_modelNumber = modelNumber; }
|
||||||
|
|
||||||
|
QString manufacturer() const { return m_manufacturer; }
|
||||||
|
void setManufacturer(const QString &manufacturer) { m_manufacturer = manufacturer; }
|
||||||
|
|
||||||
|
QString bluetoothAddress() const { return m_bluetoothAddress; }
|
||||||
|
void setBluetoothAddress(const QString &address)
|
||||||
|
{
|
||||||
|
if (m_bluetoothAddress != address)
|
||||||
|
{
|
||||||
|
m_bluetoothAddress = address;
|
||||||
|
emit bluetoothAddressChanged(address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString podIcon() const { return getModelIcon(model()).first; }
|
||||||
|
QString caseIcon() const { return getModelIcon(model()).second; }
|
||||||
|
bool isLeftPodInEar() const
|
||||||
|
{
|
||||||
|
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return isPrimaryInEar();
|
||||||
|
else return isSecondaryInEar();
|
||||||
|
}
|
||||||
|
bool isRightPodInEar() const
|
||||||
|
{
|
||||||
|
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return isPrimaryInEar();
|
||||||
|
else return isSecondaryInEar();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool adaptiveModeActive() const { return noiseControlMode() == NoiseControlMode::Adaptive; }
|
||||||
|
bool oneOrMorePodsInCase() const { return earDetectionStatus().contains("In case"); }
|
||||||
|
bool oneOrMorePodsInEar() const { return isPrimaryInEar() || isSecondaryInEar(); }
|
||||||
|
|
||||||
|
void reset()
|
||||||
|
{
|
||||||
|
setDeviceName("");
|
||||||
|
setModel(AirPodsModel::Unknown);
|
||||||
|
m_battery->reset();
|
||||||
|
setBatteryStatus("");
|
||||||
|
setEarDetectionStatus("");
|
||||||
|
setPrimaryInEar(false);
|
||||||
|
setSecondaryInEar(false);
|
||||||
|
setNoiseControlMode(NoiseControlMode::Off);
|
||||||
|
setBluetoothAddress("");
|
||||||
|
}
|
||||||
|
|
||||||
|
void save() const
|
||||||
|
{
|
||||||
|
QSettings settings("AirpodsTrayApp", "DeviceInfo");
|
||||||
|
settings.beginGroup("DeviceInfo");
|
||||||
|
settings.setValue("deviceName", m_deviceName);
|
||||||
|
settings.setValue("bluetoothAddress", m_bluetoothAddress);
|
||||||
|
settings.setValue("magicAccIRK", m_magicAccIRK.toBase64());
|
||||||
|
settings.setValue("magicAccEncKey", m_magicAccEncKey.toBase64());
|
||||||
|
settings.endGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
{
|
||||||
|
QSettings settings("AirpodsTrayApp", "DeviceInfo");
|
||||||
|
settings.beginGroup("DeviceInfo");
|
||||||
|
setDeviceName(settings.value("deviceName", "").toString());
|
||||||
|
setBluetoothAddress(settings.value("bluetoothAddress", "").toString());
|
||||||
|
setMagicAccIRK(QByteArray::fromBase64(settings.value("magicAccIRK", "").toByteArray()));
|
||||||
|
setMagicAccEncKey(QByteArray::fromBase64(settings.value("magicAccEncKey", "").toByteArray()));
|
||||||
|
settings.endGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void batteryStatusChanged(const QString &status);
|
||||||
|
void earDetectionStatusChanged(const QString &status);
|
||||||
|
void noiseControlModeChanged(NoiseControlMode mode);
|
||||||
|
void noiseControlModeChangedInt(int mode);
|
||||||
|
void conversationalAwarenessChanged(bool enabled);
|
||||||
|
void adaptiveNoiseLevelChanged(int level);
|
||||||
|
void deviceNameChanged(const QString &name);
|
||||||
|
void primaryChanged();
|
||||||
|
void oneBudANCModeChanged(bool enabled);
|
||||||
|
void modelChanged();
|
||||||
|
void bluetoothAddressChanged(const QString &address);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_batteryStatus;
|
||||||
|
QString m_earDetectionStatus;
|
||||||
|
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
|
||||||
|
bool m_conversationalAwareness = false;
|
||||||
|
int m_adaptiveNoiseLevel = 50;
|
||||||
|
QString m_deviceName;
|
||||||
|
Battery *m_battery;
|
||||||
|
bool m_primaryInEar = false;
|
||||||
|
bool m_secoundaryInEar = false;
|
||||||
|
QByteArray m_magicAccIRK;
|
||||||
|
QByteArray m_magicAccEncKey;
|
||||||
|
bool m_oneBudANCMode = false;
|
||||||
|
AirPodsModel m_model = AirPodsModel::Unknown;
|
||||||
|
QString m_modelNumber;
|
||||||
|
QString m_manufacturer;
|
||||||
|
QString m_bluetoothAddress;
|
||||||
|
};
|
||||||
315
linux/main.cpp
315
linux/main.cpp
@@ -10,6 +10,7 @@
|
|||||||
#include "battery.hpp"
|
#include "battery.hpp"
|
||||||
#include "BluetoothMonitor.h"
|
#include "BluetoothMonitor.h"
|
||||||
#include "autostartmanager.hpp"
|
#include "autostartmanager.hpp"
|
||||||
|
#include "deviceinfo.hpp"
|
||||||
|
|
||||||
using namespace AirpodsTrayApp::Enums;
|
using namespace AirpodsTrayApp::Enums;
|
||||||
|
|
||||||
@@ -17,19 +18,6 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
|
|||||||
|
|
||||||
class AirPodsTrayApp : public QObject {
|
class AirPodsTrayApp : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
Q_PROPERTY(QString batteryStatus READ batteryStatus NOTIFY batteryStatusChanged)
|
|
||||||
Q_PROPERTY(QString earDetectionStatus READ earDetectionStatus NOTIFY earDetectionStatusChanged)
|
|
||||||
Q_PROPERTY(int noiseControlMode READ noiseControlMode WRITE setNoiseControlMode NOTIFY noiseControlModeChanged)
|
|
||||||
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
|
|
||||||
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
|
|
||||||
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChanged)
|
|
||||||
Q_PROPERTY(QString deviceName READ deviceName NOTIFY deviceNameChanged)
|
|
||||||
Q_PROPERTY(Battery* battery READ getBattery NOTIFY batteryStatusChanged)
|
|
||||||
Q_PROPERTY(bool oneOrMorePodsInCase READ oneOrMorePodsInCase NOTIFY earDetectionStatusChanged)
|
|
||||||
Q_PROPERTY(QString podIcon READ podIcon NOTIFY modelChanged)
|
|
||||||
Q_PROPERTY(QString caseIcon READ caseIcon NOTIFY modelChanged)
|
|
||||||
Q_PROPERTY(bool leftPodInEar READ isLeftPodInEar NOTIFY primaryChanged)
|
|
||||||
Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged)
|
|
||||||
Q_PROPERTY(bool airpodsConnected READ areAirpodsConnected NOTIFY airPodsStatusChanged)
|
Q_PROPERTY(bool airpodsConnected READ areAirpodsConnected NOTIFY airPodsStatusChanged)
|
||||||
Q_PROPERTY(int earDetectionBehavior READ earDetectionBehavior WRITE setEarDetectionBehavior NOTIFY earDetectionBehaviorChanged)
|
Q_PROPERTY(int earDetectionBehavior READ earDetectionBehavior WRITE setEarDetectionBehavior NOTIFY earDetectionBehaviorChanged)
|
||||||
Q_PROPERTY(bool crossDeviceEnabled READ crossDeviceEnabled WRITE setCrossDeviceEnabled NOTIFY crossDeviceEnabledChanged)
|
Q_PROPERTY(bool crossDeviceEnabled READ crossDeviceEnabled WRITE setCrossDeviceEnabled NOTIFY crossDeviceEnabledChanged)
|
||||||
@@ -37,23 +25,13 @@ class AirPodsTrayApp : public QObject {
|
|||||||
Q_PROPERTY(bool notificationsEnabled READ notificationsEnabled WRITE setNotificationsEnabled NOTIFY notificationsEnabledChanged)
|
Q_PROPERTY(bool notificationsEnabled READ notificationsEnabled WRITE setNotificationsEnabled NOTIFY notificationsEnabledChanged)
|
||||||
Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged)
|
Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged)
|
||||||
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
|
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
|
||||||
|
Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
|
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
|
||||||
: QObject(parent)
|
: QObject(parent), debugMode(debugMode), m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp")), m_autoStartManager(new AutoStartManager(this)), m_hideOnStart(hideOnStart), parent(parent), m_deviceInfo(new DeviceInfo(this))
|
||||||
, debugMode(debugMode)
|
{
|
||||||
, m_battery(new Battery(this))
|
QLoggingCategory::setFilterRules(QString("airpodsApp.debug=%1").arg(debugMode ? "true" : "false"));
|
||||||
, monitor(new BluetoothMonitor(this))
|
|
||||||
, m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp"))
|
|
||||||
, m_autoStartManager(new AutoStartManager(this))
|
|
||||||
, m_hideOnStart(hideOnStart)
|
|
||||||
, parent(parent)
|
|
||||||
{
|
|
||||||
if (debugMode) {
|
|
||||||
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
|
|
||||||
} else {
|
|
||||||
QLoggingCategory::setFilterRules("airpodsApp.debug=false");
|
|
||||||
}
|
|
||||||
LOG_INFO("Initializing AirPodsTrayApp");
|
LOG_INFO("Initializing AirPodsTrayApp");
|
||||||
|
|
||||||
// Initialize tray icon and connect signals
|
// Initialize tray icon and connect signals
|
||||||
@@ -62,25 +40,26 @@ public:
|
|||||||
connect(trayManager, &TrayIconManager::trayClicked, this, &AirPodsTrayApp::onTrayIconActivated);
|
connect(trayManager, &TrayIconManager::trayClicked, this, &AirPodsTrayApp::onTrayIconActivated);
|
||||||
connect(trayManager, &TrayIconManager::openApp, this, &AirPodsTrayApp::onOpenApp);
|
connect(trayManager, &TrayIconManager::openApp, this, &AirPodsTrayApp::onOpenApp);
|
||||||
connect(trayManager, &TrayIconManager::openSettings, this, &AirPodsTrayApp::onOpenSettings);
|
connect(trayManager, &TrayIconManager::openSettings, this, &AirPodsTrayApp::onOpenSettings);
|
||||||
connect(trayManager, &TrayIconManager::noiseControlChanged, this, qOverload<NoiseControlMode>(&AirPodsTrayApp::setNoiseControlMode));
|
connect(trayManager, &TrayIconManager::noiseControlChanged, this, &AirPodsTrayApp::setNoiseControlMode);
|
||||||
connect(trayManager, &TrayIconManager::conversationalAwarenessToggled, this, &AirPodsTrayApp::setConversationalAwareness);
|
connect(trayManager, &TrayIconManager::conversationalAwarenessToggled, this, &AirPodsTrayApp::setConversationalAwareness);
|
||||||
connect(this, &AirPodsTrayApp::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus);
|
connect(m_deviceInfo, &DeviceInfo::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus);
|
||||||
connect(this, &AirPodsTrayApp::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState);
|
connect(m_deviceInfo, &DeviceInfo::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState);
|
||||||
connect(this, &AirPodsTrayApp::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness);
|
connect(m_deviceInfo, &DeviceInfo::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness);
|
||||||
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::saveNotificationsEnabled);
|
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::saveNotificationsEnabled);
|
||||||
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::notificationsEnabledChanged);
|
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::notificationsEnabledChanged);
|
||||||
|
|
||||||
// Initialize MediaController and connect signals
|
// Initialize MediaController and connect signals
|
||||||
mediaController = new MediaController(this);
|
mediaController = new MediaController(this);
|
||||||
connect(this, &AirPodsTrayApp::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
|
connect(m_deviceInfo, &DeviceInfo::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
|
||||||
connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange);
|
connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange);
|
||||||
mediaController->initializeMprisInterface();
|
mediaController->initializeMprisInterface();
|
||||||
mediaController->followMediaChanges();
|
mediaController->followMediaChanges();
|
||||||
|
|
||||||
|
monitor = new BluetoothMonitor(this);
|
||||||
connect(monitor, &BluetoothMonitor::deviceConnected, this, &AirPodsTrayApp::bluezDeviceConnected);
|
connect(monitor, &BluetoothMonitor::deviceConnected, this, &AirPodsTrayApp::bluezDeviceConnected);
|
||||||
connect(monitor, &BluetoothMonitor::deviceDisconnected, this, &AirPodsTrayApp::bluezDeviceDisconnected);
|
connect(monitor, &BluetoothMonitor::deviceDisconnected, this, &AirPodsTrayApp::bluezDeviceDisconnected);
|
||||||
|
|
||||||
connect(m_battery, &Battery::primaryChanged, this, &AirPodsTrayApp::primaryChanged);
|
connect(m_deviceInfo->getBattery(), &Battery::primaryChanged, this, &AirPodsTrayApp::primaryChanged);
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
CrossDevice.isEnabled = loadCrossDeviceEnabled();
|
CrossDevice.isEnabled = loadCrossDeviceEnabled();
|
||||||
@@ -113,31 +92,6 @@ public:
|
|||||||
delete phoneSocket;
|
delete phoneSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString batteryStatus() const { return m_batteryStatus; }
|
|
||||||
QString earDetectionStatus() const { return m_earDetectionStatus; }
|
|
||||||
int noiseControlMode() const { return static_cast<int>(m_noiseControlMode); }
|
|
||||||
bool conversationalAwareness() const { return m_conversationalAwareness; }
|
|
||||||
bool adaptiveModeActive() const { return m_noiseControlMode == NoiseControlMode::Adaptive; }
|
|
||||||
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
|
|
||||||
QString deviceName() const { return m_deviceName; }
|
|
||||||
Battery *getBattery() const { return m_battery; }
|
|
||||||
bool oneOrMorePodsInCase() const { return m_earDetectionStatus.contains("In case"); }
|
|
||||||
QString podIcon() const { return getModelIcon(m_model).first; }
|
|
||||||
QString caseIcon() const { return getModelIcon(m_model).second; }
|
|
||||||
bool isLeftPodInEar() const {
|
|
||||||
if (m_battery->getPrimaryPod() == Battery::Component::Left) {
|
|
||||||
return m_primaryInEar;
|
|
||||||
} else {
|
|
||||||
return m_secoundaryInEar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bool isRightPodInEar() const {
|
|
||||||
if (m_battery->getPrimaryPod() == Battery::Component::Right) {
|
|
||||||
return m_primaryInEar;
|
|
||||||
} else {
|
|
||||||
return m_secoundaryInEar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bool areAirpodsConnected() const { return socket && socket->isOpen() && socket->state() == QBluetoothSocket::SocketState::ConnectedState; }
|
bool areAirpodsConnected() const { return socket && socket->isOpen() && socket->state() == QBluetoothSocket::SocketState::ConnectedState; }
|
||||||
int earDetectionBehavior() const { return mediaController->getEarDetectionBehavior(); }
|
int earDetectionBehavior() const { return mediaController->getEarDetectionBehavior(); }
|
||||||
bool crossDeviceEnabled() const { return CrossDevice.isEnabled; }
|
bool crossDeviceEnabled() const { return CrossDevice.isEnabled; }
|
||||||
@@ -146,6 +100,7 @@ public:
|
|||||||
void setNotificationsEnabled(bool enabled) { trayManager->setNotificationsEnabled(enabled); }
|
void setNotificationsEnabled(bool enabled) { trayManager->setNotificationsEnabled(enabled); }
|
||||||
int retryAttempts() const { return m_retryAttempts; }
|
int retryAttempts() const { return m_retryAttempts; }
|
||||||
bool hideOnStart() const { return m_hideOnStart; }
|
bool hideOnStart() const { return m_hideOnStart; }
|
||||||
|
DeviceInfo *deviceInfo() const { return m_deviceInfo; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool debugMode;
|
bool debugMode;
|
||||||
@@ -197,34 +152,49 @@ public slots:
|
|||||||
void setNoiseControlMode(NoiseControlMode mode)
|
void setNoiseControlMode(NoiseControlMode mode)
|
||||||
{
|
{
|
||||||
LOG_INFO("Setting noise control mode to: " << mode);
|
LOG_INFO("Setting noise control mode to: " << mode);
|
||||||
if (m_noiseControlMode == mode)
|
|
||||||
{
|
|
||||||
LOG_INFO("Noise control mode is already " << mode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QByteArray packet = AirPodsPackets::NoiseControl::getPacketForMode(mode);
|
QByteArray packet = AirPodsPackets::NoiseControl::getPacketForMode(mode);
|
||||||
writePacketToSocket(packet, "Noise control mode packet written: ");
|
writePacketToSocket(packet, "Noise control mode packet written: ");
|
||||||
}
|
}
|
||||||
void setNoiseControlMode(int mode)
|
void setNoiseControlModeInt(int mode)
|
||||||
{
|
{
|
||||||
|
if (mode < 0 || mode > static_cast<int>(NoiseControlMode::Adaptive))
|
||||||
|
{
|
||||||
|
LOG_ERROR("Invalid noise control mode: " << mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setNoiseControlMode(static_cast<NoiseControlMode>(mode));
|
setNoiseControlMode(static_cast<NoiseControlMode>(mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setConversationalAwareness(bool enabled)
|
void setConversationalAwareness(bool enabled)
|
||||||
{
|
{
|
||||||
if (m_conversationalAwareness == enabled)
|
|
||||||
{
|
|
||||||
LOG_INFO("Conversational awareness is already " << (enabled ? "enabled" : "disabled"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_INFO("Setting conversational awareness to: " << (enabled ? "enabled" : "disabled"));
|
LOG_INFO("Setting conversational awareness to: " << (enabled ? "enabled" : "disabled"));
|
||||||
QByteArray packet = enabled ? AirPodsPackets::ConversationalAwareness::ENABLED
|
QByteArray packet = enabled ? AirPodsPackets::ConversationalAwareness::ENABLED
|
||||||
: AirPodsPackets::ConversationalAwareness::DISABLED;
|
: AirPodsPackets::ConversationalAwareness::DISABLED;
|
||||||
|
|
||||||
writePacketToSocket(packet, "Conversational awareness packet written: ");
|
writePacketToSocket(packet, "Conversational awareness packet written: ");
|
||||||
m_conversationalAwareness = enabled;
|
m_deviceInfo->setConversationalAwareness(enabled);
|
||||||
emit conversationalAwarenessChanged(enabled);
|
}
|
||||||
|
|
||||||
|
void setOneBudANCMode(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_deviceInfo->oneBudANCMode() == enabled)
|
||||||
|
{
|
||||||
|
LOG_INFO("One Bud ANC mode is already " << (enabled ? "enabled" : "disabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("Setting One Bud ANC mode to: " << (enabled ? "enabled" : "disabled"));
|
||||||
|
QByteArray packet = enabled ? AirPodsPackets::OneBudANCMode::ENABLED
|
||||||
|
: AirPodsPackets::OneBudANCMode::DISABLED;
|
||||||
|
|
||||||
|
if (writePacketToSocket(packet, "One Bud ANC mode packet written: "))
|
||||||
|
{
|
||||||
|
m_deviceInfo->setOneBudANCMode(enabled);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LOG_ERROR("Failed to send One Bud ANC mode command: socket not open");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setRetryAttempts(int attempts)
|
void setRetryAttempts(int attempts)
|
||||||
@@ -252,12 +222,11 @@ public slots:
|
|||||||
void setAdaptiveNoiseLevel(int level)
|
void setAdaptiveNoiseLevel(int level)
|
||||||
{
|
{
|
||||||
level = qBound(0, level, 100);
|
level = qBound(0, level, 100);
|
||||||
if (m_adaptiveNoiseLevel != level && adaptiveModeActive())
|
if (m_deviceInfo->adaptiveNoiseLevel() != level && m_deviceInfo->adaptiveModeActive())
|
||||||
{
|
{
|
||||||
m_adaptiveNoiseLevel = level;
|
|
||||||
QByteArray packet = AirPodsPackets::AdaptiveNoise::getPacket(level);
|
QByteArray packet = AirPodsPackets::AdaptiveNoise::getPacket(level);
|
||||||
writePacketToSocket(packet, "Adaptive noise level packet written: ");
|
writePacketToSocket(packet, "Adaptive noise level packet written: ");
|
||||||
emit adaptiveNoiseLevelChanged(level);
|
m_deviceInfo->setAdaptiveNoiseLevel(level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +242,7 @@ public slots:
|
|||||||
LOG_WARN("Name is too long, must be 32 characters or less");
|
LOG_WARN("Name is too long, must be 32 characters or less");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newName == m_deviceName)
|
if (newName == m_deviceInfo->deviceName())
|
||||||
{
|
{
|
||||||
LOG_INFO("Name is already set to: " << newName);
|
LOG_INFO("Name is already set to: " << newName);
|
||||||
return;
|
return;
|
||||||
@@ -283,8 +252,7 @@ public slots:
|
|||||||
if (writePacketToSocket(packet, "Rename packet written: "))
|
if (writePacketToSocket(packet, "Rename packet written: "))
|
||||||
{
|
{
|
||||||
LOG_INFO("Sent rename command for new name: " << newName);
|
LOG_INFO("Sent rename command for new name: " << newName);
|
||||||
m_deviceName = newName;
|
m_deviceInfo->setDeviceName(newName);
|
||||||
emit deviceNameChanged(newName);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -388,7 +356,7 @@ private slots:
|
|||||||
writePacketToSocket(AirPodsPackets::Connection::HANDSHAKE, "Handshake packet written: ");
|
writePacketToSocket(AirPodsPackets::Connection::HANDSHAKE, "Handshake packet written: ");
|
||||||
}
|
}
|
||||||
|
|
||||||
void bluezDeviceConnected(const QString &address, const QString &name)
|
void bluezDeviceConnected(const QString &address, const QString &name)
|
||||||
{
|
{
|
||||||
QBluetoothDeviceInfo device(QBluetoothAddress(address), name, 0);
|
QBluetoothDeviceInfo device(QBluetoothAddress(address), name, 0);
|
||||||
connectToDevice(device);
|
connectToDevice(device);
|
||||||
@@ -410,45 +378,22 @@ private slots:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the device name and model
|
// Clear the device name and model
|
||||||
m_deviceName.clear();
|
m_deviceInfo->reset();
|
||||||
connectedDeviceMacAddress.clear();
|
|
||||||
mediaController->setConnectedDeviceMacAddress(connectedDeviceMacAddress);
|
|
||||||
m_model = AirPodsModel::Unknown;
|
|
||||||
emit deviceNameChanged(m_deviceName);
|
|
||||||
emit modelChanged();
|
|
||||||
|
|
||||||
// Reset battery status
|
|
||||||
m_battery->reset();
|
|
||||||
m_batteryStatus.clear();
|
|
||||||
emit batteryStatusChanged(m_batteryStatus);
|
|
||||||
|
|
||||||
// Reset ear detection
|
|
||||||
m_earDetectionStatus.clear();
|
|
||||||
m_primaryInEar = false;
|
|
||||||
m_secoundaryInEar = false;
|
|
||||||
emit earDetectionStatusChanged(m_earDetectionStatus);
|
|
||||||
emit primaryChanged();
|
|
||||||
|
|
||||||
// Reset noise control mode
|
|
||||||
m_noiseControlMode = NoiseControlMode::Off;
|
|
||||||
emit noiseControlModeChanged(m_noiseControlMode);
|
|
||||||
|
|
||||||
mediaController->pause(); // Since the device is deconnected, we don't know if it was the active output device. Pause to be safe
|
|
||||||
emit airPodsStatusChanged();
|
|
||||||
|
|
||||||
// Show system notification
|
// Show system notification
|
||||||
trayManager->showNotification(
|
trayManager->showNotification(
|
||||||
tr("AirPods Disconnected"),
|
tr("AirPods Disconnected"),
|
||||||
tr("Your AirPods have been disconnected"));
|
tr("Your AirPods have been disconnected"));
|
||||||
|
trayManager->resetTrayIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
void bluezDeviceDisconnected(const QString &address, const QString &name)
|
void bluezDeviceDisconnected(const QString &address, const QString &name)
|
||||||
{
|
{
|
||||||
if (address == connectedDeviceMacAddress.replace("_", ":"))
|
if (address == m_deviceInfo->bluetoothAddress())
|
||||||
{
|
{
|
||||||
onDeviceDisconnected(QBluetoothAddress(address)); }
|
onDeviceDisconnected(QBluetoothAddress(address));
|
||||||
else {
|
} else {
|
||||||
LOG_WARN("Disconnected device does not match connected device: " << address << " != " << connectedDeviceMacAddress);
|
LOG_WARN("Disconnected device does not match connected device: " << address << " != " << m_deviceInfo->bluetoothAddress());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,43 +435,18 @@ private slots:
|
|||||||
return str;
|
return str;
|
||||||
};
|
};
|
||||||
|
|
||||||
m_deviceName = extractString();
|
m_deviceInfo->setDeviceName(extractString());
|
||||||
QString modelNumber = extractString();
|
m_deviceInfo->setModelNumber(extractString());
|
||||||
QString manufacturer = extractString();
|
m_deviceInfo->setManufacturer(extractString());
|
||||||
QString hardwareVersion = extractString();
|
|
||||||
QString firmwareVersion = extractString();
|
|
||||||
QString firmwareVersion2 = extractString();
|
|
||||||
QString softwareVersion = extractString();
|
|
||||||
QString appIdentifier = extractString();
|
|
||||||
QString serialNumber1 = extractString();
|
|
||||||
QString serialNumber2 = extractString();
|
|
||||||
QString unknownNumeric = extractString();
|
|
||||||
QString unknownHash = extractString();
|
|
||||||
QString trailingByte = extractString();
|
|
||||||
|
|
||||||
m_model = parseModelNumber(modelNumber);
|
|
||||||
|
|
||||||
|
m_deviceInfo->setModel(parseModelNumber(m_deviceInfo->modelNumber()));
|
||||||
emit modelChanged();
|
emit modelChanged();
|
||||||
m_model = parseModelNumber(modelNumber);
|
|
||||||
|
|
||||||
emit modelChanged();
|
|
||||||
emit deviceNameChanged(m_deviceName);
|
|
||||||
|
|
||||||
// Log extracted metadata
|
// Log extracted metadata
|
||||||
LOG_INFO("Parsed AirPods metadata:");
|
LOG_INFO("Parsed AirPods metadata:");
|
||||||
LOG_INFO("Device Name: " << m_deviceName);
|
LOG_INFO("Device Name: " << m_deviceInfo->deviceName());
|
||||||
LOG_INFO("Model Number: " << modelNumber);
|
LOG_INFO("Model Number: " << m_deviceInfo->modelNumber());
|
||||||
LOG_INFO("Manufacturer: " << manufacturer);
|
LOG_INFO("Manufacturer: " << m_deviceInfo->manufacturer());
|
||||||
LOG_INFO("Hardware Version: " << hardwareVersion);
|
|
||||||
LOG_INFO("Firmware Version: " << firmwareVersion);
|
|
||||||
LOG_INFO("Firmware Version2: " << firmwareVersion2);
|
|
||||||
LOG_INFO("Software Version: " << softwareVersion);
|
|
||||||
LOG_INFO("App Identifier: " << appIdentifier);
|
|
||||||
LOG_INFO("Serial Number 1: " << serialNumber1);
|
|
||||||
LOG_INFO("Serial Number 2: " << serialNumber2);
|
|
||||||
LOG_INFO("Unknown Numeric: " << unknownNumeric);
|
|
||||||
LOG_INFO("Unknown Hash: " << unknownHash);
|
|
||||||
LOG_INFO("Trailing Byte: " << trailingByte);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QString getEarStatus(char value)
|
QString getEarStatus(char value)
|
||||||
@@ -580,7 +500,7 @@ private slots:
|
|||||||
QTimer::singleShot(1500, this, [this, device]()
|
QTimer::singleShot(1500, this, [this, device]()
|
||||||
{ connectToDevice(device); });
|
{ connectToDevice(device); });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LOG_ERROR("Failed to connect after 3 attempts");
|
LOG_ERROR("Failed to connect after 3 attempts");
|
||||||
retryCount = 0;
|
retryCount = 0;
|
||||||
@@ -592,7 +512,7 @@ private slots:
|
|||||||
this, handleError);
|
this, handleError);
|
||||||
|
|
||||||
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
|
||||||
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
|
m_deviceInfo->setBluetoothAddress(device.address().toString());
|
||||||
notifyAndroidDevice();
|
notifyAndroidDevice();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +529,7 @@ private slots:
|
|||||||
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
|
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
|
||||||
|
|
||||||
QTimer::singleShot(2000, this, [this]() {
|
QTimer::singleShot(2000, this, [this]() {
|
||||||
if (m_batteryStatus.isEmpty()) {
|
if (m_deviceInfo->batteryStatus().isEmpty()) {
|
||||||
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
|
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -622,34 +542,26 @@ private slots:
|
|||||||
LOG_INFO("MagicAccIRK: " << keys.magicAccIRK.toHex());
|
LOG_INFO("MagicAccIRK: " << keys.magicAccIRK.toHex());
|
||||||
LOG_INFO("MagicAccEncKey: " << keys.magicAccEncKey.toHex());
|
LOG_INFO("MagicAccEncKey: " << keys.magicAccEncKey.toHex());
|
||||||
|
|
||||||
// Store the keys for later use if needed
|
// Store the keys
|
||||||
m_magicAccIRK = keys.magicAccIRK;
|
m_deviceInfo->setMagicAccIRK(keys.magicAccIRK);
|
||||||
m_magicAccEncKey = keys.magicAccEncKey;
|
m_deviceInfo->setMagicAccEncKey(keys.magicAccEncKey);
|
||||||
}
|
}
|
||||||
// Get CA state
|
// Get CA state
|
||||||
else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) {
|
else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) {
|
||||||
auto result = AirPodsPackets::ConversationalAwareness::parseCAState(data);
|
if (auto result = AirPodsPackets::ConversationalAwareness::parseState(data))
|
||||||
if (result.has_value()) {
|
{
|
||||||
m_conversationalAwareness = result.value();
|
m_deviceInfo->setConversationalAwareness(result.value());
|
||||||
LOG_INFO("Conversational awareness state received: " << m_conversationalAwareness);
|
LOG_INFO("Conversational awareness state received: " << m_deviceInfo->conversationalAwareness());
|
||||||
emit conversationalAwarenessChanged(m_conversationalAwareness);
|
|
||||||
} else {
|
|
||||||
LOG_ERROR("Failed to parse conversational awareness state");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Noise Control Mode
|
// Noise Control Mode
|
||||||
else if (data.size() == 11 && data.startsWith(AirPodsPackets::NoiseControl::HEADER))
|
else if (data.size() == 11 && data.startsWith(AirPodsPackets::NoiseControl::HEADER))
|
||||||
{
|
{
|
||||||
quint8 rawMode = data[7] - 1; // Offset still needed due to protocol
|
if (auto value = AirPodsPackets::NoiseControl::parseMode(data))
|
||||||
if (rawMode >= (int)NoiseControlMode::MinValue && rawMode <= (int)NoiseControlMode::MaxValue)
|
|
||||||
{
|
{
|
||||||
m_noiseControlMode = static_cast<NoiseControlMode>(rawMode);
|
LOG_INFO("Received noise control mode: " << value.value());
|
||||||
LOG_INFO("Noise control mode: " << rawMode);
|
m_deviceInfo->setNoiseControlMode(value.value());
|
||||||
emit noiseControlModeChanged(m_noiseControlMode);
|
LOG_INFO("Noise control mode received: " << m_deviceInfo->noiseControlMode());
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
LOG_ERROR("Invalid noise control mode value received: " << rawMode);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ear Detection
|
// Ear Detection
|
||||||
@@ -657,28 +569,25 @@ private slots:
|
|||||||
{
|
{
|
||||||
char primary = data[6];
|
char primary = data[6];
|
||||||
char secondary = data[7];
|
char secondary = data[7];
|
||||||
m_primaryInEar = data[6] == 0x00;
|
m_deviceInfo->setPrimaryInEar(data[6] == 0x00);
|
||||||
m_secoundaryInEar = data[7] == 0x00;
|
m_deviceInfo->setSecondaryInEar(data[7] == 0x00);
|
||||||
m_earDetectionStatus = QString("Primary: %1, Secondary: %2")
|
m_deviceInfo->setEarDetectionStatus(QString("Primary: %1, Secondary: %2")
|
||||||
.arg(getEarStatus(primary), getEarStatus(secondary));
|
.arg(getEarStatus(primary), getEarStatus(secondary)));
|
||||||
LOG_INFO("Ear detection status: " << m_earDetectionStatus);
|
LOG_INFO("Ear detection status: " << m_deviceInfo->earDetectionStatus());
|
||||||
emit earDetectionStatusChanged(m_earDetectionStatus);
|
|
||||||
emit primaryChanged();
|
|
||||||
}
|
}
|
||||||
// Battery Status
|
// Battery Status
|
||||||
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
|
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
|
||||||
{
|
{
|
||||||
m_battery->parsePacket(data);
|
m_deviceInfo->getBattery()->parsePacket(data);
|
||||||
|
|
||||||
int leftLevel = m_battery->getState(Battery::Component::Left).level;
|
int leftLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Left).level;
|
||||||
int rightLevel = m_battery->getState(Battery::Component::Right).level;
|
int rightLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Right).level;
|
||||||
int caseLevel = m_battery->getState(Battery::Component::Case).level;
|
int caseLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Case).level;
|
||||||
m_batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%")
|
m_deviceInfo->setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%")
|
||||||
.arg(leftLevel)
|
.arg(leftLevel)
|
||||||
.arg(rightLevel)
|
.arg(rightLevel)
|
||||||
.arg(caseLevel);
|
.arg(caseLevel));
|
||||||
LOG_INFO("Battery status: " << m_batteryStatus);
|
LOG_INFO("Battery status: " << m_deviceInfo->batteryStatus());
|
||||||
emit batteryStatusChanged(m_batteryStatus);
|
|
||||||
}
|
}
|
||||||
// Conversational Awareness Data
|
// Conversational Awareness Data
|
||||||
else if (data.size() == 10 && data.startsWith(AirPodsPackets::ConversationalAwareness::DATA_HEADER))
|
else if (data.size() == 10 && data.startsWith(AirPodsPackets::ConversationalAwareness::DATA_HEADER))
|
||||||
@@ -690,13 +599,20 @@ private slots:
|
|||||||
{
|
{
|
||||||
parseMetadata(data);
|
parseMetadata(data);
|
||||||
initiateMagicPairing();
|
initiateMagicPairing();
|
||||||
mediaController->setConnectedDeviceMacAddress(connectedDeviceMacAddress);
|
mediaController->setConnectedDeviceMacAddress(m_deviceInfo->bluetoothAddress().replace(":", "_"));
|
||||||
if (isLeftPodInEar() || isRightPodInEar()) // AirPods get added as output device only after this
|
if (m_deviceInfo->oneOrMorePodsInEar()) // AirPods get added as output device only after this
|
||||||
{
|
{
|
||||||
mediaController->activateA2dpProfile();
|
mediaController->activateA2dpProfile();
|
||||||
}
|
}
|
||||||
emit airPodsStatusChanged();
|
emit airPodsStatusChanged();
|
||||||
}
|
}
|
||||||
|
else if (data.startsWith(AirPodsPackets::OneBudANCMode::HEADER)) {
|
||||||
|
if (auto value = AirPodsPackets::OneBudANCMode::parseState(data))
|
||||||
|
{
|
||||||
|
m_deviceInfo->setOneBudANCMode(value.value());
|
||||||
|
LOG_INFO("One Bud ANC mode received: " << m_deviceInfo->oneBudANCMode());
|
||||||
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LOG_DEBUG("Unrecognized packet format: " << data.toHex());
|
LOG_DEBUG("Unrecognized packet format: " << data.toHex());
|
||||||
@@ -792,7 +708,7 @@ private slots:
|
|||||||
socket->close();
|
socket->close();
|
||||||
LOG_INFO("Disconnected from AirPods");
|
LOG_INFO("Disconnected from AirPods");
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("bluetoothctl", QStringList() << "disconnect" << connectedDeviceMacAddress.replace("_", ":"));
|
process.start("bluetoothctl", QStringList() << "disconnect" << m_deviceInfo->bluetoothAddress());
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
QString output = process.readAllStandardOutput().trimmed();
|
QString output = process.readAllStandardOutput().trimmed();
|
||||||
LOG_INFO("Bluetoothctl output: " << output);
|
LOG_INFO("Bluetoothctl output: " << output);
|
||||||
@@ -817,14 +733,14 @@ private slots:
|
|||||||
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
|
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
void handleMediaStateChange(MediaController::MediaState state) {
|
void handleMediaStateChange(MediaController::MediaState state) {
|
||||||
if (state == MediaController::MediaState::Playing) {
|
if (state == MediaController::MediaState::Playing) {
|
||||||
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
|
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
|
||||||
sendDisconnectRequestToAndroid();
|
sendDisconnectRequestToAndroid();
|
||||||
connectToAirPods(true);
|
connectToAirPods(true);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void sendDisconnectRequestToAndroid()
|
void sendDisconnectRequestToAndroid()
|
||||||
{
|
{
|
||||||
@@ -854,13 +770,13 @@ private slots:
|
|||||||
if (force) {
|
if (force) {
|
||||||
LOG_INFO("Forcing connection to AirPods");
|
LOG_INFO("Forcing connection to AirPods");
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("bluetoothctl", QStringList() << "connect" << connectedDeviceMacAddress.replace("_", ":"));
|
process.start("bluetoothctl", QStringList() << "connect" << m_deviceInfo->bluetoothAddress());
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
QString output = process.readAllStandardOutput().trimmed();
|
QString output = process.readAllStandardOutput().trimmed();
|
||||||
LOG_INFO("Bluetoothctl output: " << output);
|
LOG_INFO("Bluetoothctl output: " << output);
|
||||||
if (output.contains("Connection successful")) {
|
if (output.contains("Connection successful")) {
|
||||||
LOG_INFO("Connection successful, proceeding with L2CAP connection");
|
LOG_INFO("Connection successful, proceeding with L2CAP connection");
|
||||||
QBluetoothAddress btAddress(connectedDeviceMacAddress.replace("_", ":"));
|
QBluetoothAddress btAddress(m_deviceInfo->bluetoothAddress());
|
||||||
forceL2capConnection(btAddress);
|
forceL2capConnection(btAddress);
|
||||||
} else {
|
} else {
|
||||||
LOG_ERROR("Connection failed, cannot proceed with L2CAP connection");
|
LOG_ERROR("Connection failed, cannot proceed with L2CAP connection");
|
||||||
@@ -926,11 +842,11 @@ signals:
|
|||||||
void crossDeviceEnabledChanged(bool enabled);
|
void crossDeviceEnabledChanged(bool enabled);
|
||||||
void notificationsEnabledChanged(bool enabled);
|
void notificationsEnabledChanged(bool enabled);
|
||||||
void retryAttemptsChanged(int attempts);
|
void retryAttemptsChanged(int attempts);
|
||||||
|
void oneBudANCModeChanged(bool enabled);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QBluetoothSocket *socket = nullptr;
|
QBluetoothSocket *socket = nullptr;
|
||||||
QBluetoothSocket *phoneSocket = nullptr;
|
QBluetoothSocket *phoneSocket = nullptr;
|
||||||
QString connectedDeviceMacAddress;
|
|
||||||
QByteArray lastBatteryStatus;
|
QByteArray lastBatteryStatus;
|
||||||
QByteArray lastEarDetectionStatus;
|
QByteArray lastEarDetectionStatus;
|
||||||
MediaController* mediaController;
|
MediaController* mediaController;
|
||||||
@@ -940,19 +856,7 @@ private:
|
|||||||
AutoStartManager *m_autoStartManager;
|
AutoStartManager *m_autoStartManager;
|
||||||
int m_retryAttempts = 3;
|
int m_retryAttempts = 3;
|
||||||
bool m_hideOnStart = false;
|
bool m_hideOnStart = false;
|
||||||
|
DeviceInfo *m_deviceInfo;
|
||||||
QString m_batteryStatus;
|
|
||||||
QString m_earDetectionStatus;
|
|
||||||
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
|
|
||||||
bool m_conversationalAwareness = false;
|
|
||||||
int m_adaptiveNoiseLevel = 50;
|
|
||||||
QString m_deviceName;
|
|
||||||
Battery *m_battery;
|
|
||||||
AirPodsModel m_model = AirPodsModel::Unknown;
|
|
||||||
bool m_primaryInEar = false;
|
|
||||||
bool m_secoundaryInEar = false;
|
|
||||||
QByteArray m_magicAccIRK;
|
|
||||||
QByteArray m_magicAccEncKey;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
int main(int argc, char *argv[]) {
|
int main(int argc, char *argv[]) {
|
||||||
@@ -997,6 +901,7 @@ int main(int argc, char *argv[]) {
|
|||||||
|
|
||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
qmlRegisterType<Battery>("me.kavishdevar.Battery", 1, 0, "Battery");
|
qmlRegisterType<Battery>("me.kavishdevar.Battery", 1, 0, "Battery");
|
||||||
|
qmlRegisterType<DeviceInfo>("me.kavishdevar.DeviceInfo", 1, 0, "DeviceInfo");
|
||||||
AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine);
|
AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine);
|
||||||
engine.rootContext()->setContextProperty("airPodsTrayApp", trayApp);
|
engine.rootContext()->setContextProperty("airPodsTrayApp", trayApp);
|
||||||
trayApp->loadMainModule();
|
trayApp->loadMainModule();
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void resetTrayIcon()
|
||||||
|
{
|
||||||
|
trayIcon->setIcon(QIcon(":/icons/assets/airpods.png"));
|
||||||
|
trayIcon->setToolTip("");
|
||||||
|
}
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void notificationsEnabledChanged(bool enabled);
|
void notificationsEnabledChanged(bool enabled);
|
||||||
|
|
||||||
|
|||||||
6
update_nonpatch.json
Normal file
6
update_nonpatch.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": "v0.1.0-rc.4",
|
||||||
|
"versionCode": 3,
|
||||||
|
"zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.1.0-rc.4/LibrePods-v0.1.0-rc.4.zip",
|
||||||
|
"changelog": "https://raw.githubusercontent.com/kavishdevar/librepods/main/CHANGELOG.md"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user