android: add ability to launch digital assistant on long press (#180)

* Initial plan

* Implement BLE-only mode toggle and basic functionality

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

* Fix BLE-only mode compatibility issues and enhance MAC address handling

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

* Address BLE-only mode feedback: hide renaming, add ear detection warning, ensure default is false

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

* android: add support for invoking digital assistant on long press

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Kavish Devar
2025-07-13 19:51:37 +05:30
committed by GitHub
co-authored by kavishdevar copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent d9359cd81a
commit 24686da1f3
20 changed files with 805 additions and 281 deletions
@@ -104,6 +104,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen
@@ -114,11 +115,10 @@ import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver
@@ -187,7 +187,7 @@ class MainActivity : ComponentActivity() {
private fun handleIncomingIntent(intent: Intent) {
val data: Uri? = intent.data
if (data != null && data.scheme == "librepods") {
when (data.host) {
"add-magic-keys" -> {
@@ -198,34 +198,34 @@ class MainActivity : ComponentActivity() {
// Handle your parameters here
Log.d("LibrePods", "Parameter: $param = $value")
}
// Process the magic keys addition
handleAddMagicKeys(data)
}
}
}
}
private fun handleAddMagicKeys(uri: Uri) {
val context = this
val sharedPreferences = getSharedPreferences("settings", Context.MODE_PRIVATE)
val irkHex = uri.getQueryParameter("irk")
val encKeyHex = uri.getQueryParameter("enc_key")
try {
if (irkHex != null && validateHexInput(irkHex)) {
val irkBytes = hexStringToByteArray(irkHex)
val irkBase64 = Base64.encode(irkBytes)
sharedPreferences.edit().putString("IRK", irkBase64).apply()
}
if (encKeyHex != null && validateHexInput(encKeyHex)) {
val encKeyBytes = hexStringToByteArray(encKeyHex)
val encKeyBase64 = Base64.encode(encKeyBytes)
sharedPreferences.edit().putString("ENC_KEY", encKeyBase64).apply()
}
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
@@ -731,4 +731,4 @@ fun PermissionCard(
}
}
}
}
}
@@ -62,22 +62,20 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
@@ -47,11 +47,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.Battery
import me.kavishdevar.librepods.utils.BatteryComponent
import me.kavishdevar.librepods.utils.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.NoiseControlMode
import me.kavishdevar.librepods.constants.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
@@ -127,4 +127,4 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
@Composable
fun IndependentTogglePreview() {
IndependentToggle("Test", AirPodsService(), "test", LocalContext.current.getSharedPreferences("preview", 0), true)
}
}
@@ -73,10 +73,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@@ -18,6 +18,7 @@
package me.kavishdevar.librepods.composables
import android.content.Context
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -57,6 +58,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
@Composable
fun PressAndHoldSettings(navController: NavController) {
@@ -70,6 +72,24 @@ fun PressAndHoldSettings(navController: NavController) {
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec)
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
Text(
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
style = TextStyle(
@@ -122,7 +142,7 @@ fun PressAndHoldSettings(navController: NavController) {
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.noise_control),
text = leftActionText,
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),
@@ -182,7 +202,7 @@ fun PressAndHoldSettings(navController: NavController) {
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.noise_control),
text = rightActionText,
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),
@@ -16,10 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("unused")
package me.kavishdevar.librepods.utils
package me.kavishdevar.librepods.constants
import android.os.Parcelable
import android.util.Log
@@ -27,27 +24,10 @@ import kotlinx.parcelize.Parcelize
enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
CONVERSATION_AWARENESS(Capabilities.CONVERSATION_AWARENESS),
CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY(Capabilities.CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 0x00)),
SUFFIX(byteArrayOf(0x00, 0x00, 0x00)),
NOTIFICATION_FILTER(byteArrayOf(0x0f)),
HANDSHAKE(byteArrayOf(0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
SPECIFIC_FEATURES(byteArrayOf(0x4d)),
SET_SPECIFIC_FEATURES(PREFIX.value + SPECIFIC_FEATURES.value + byteArrayOf(0x00,
0xff.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
REQUEST_NOTIFICATIONS(PREFIX.value + NOTIFICATION_FILTER.value + byteArrayOf(0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte())),
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
NOISE_CANCELLATION_OFF(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.OFF.value + SUFFIX.value),
NOISE_CANCELLATION_ON(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ON.value + SUFFIX.value),
NOISE_CANCELLATION_TRANSPARENCY(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.TRANSPARENCY.value + SUFFIX.value),
NOISE_CANCELLATION_ADAPTIVE(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ADAPTIVE.value + SUFFIX.value),
SET_CONVERSATION_AWARENESS_OFF(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.OFF.value + SUFFIX.value),
SET_CONVERSATION_AWARENESS_ON(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.ON.value + SUFFIX.value),
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
START_HEAD_TRACKING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00)),
STOP_HEAD_TRACKING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E.toByte(), 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E.toByte(), 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00));
}
object BatteryComponent {
@@ -156,7 +136,7 @@ class AirPodsNotifications {
}
val name: String =
when (status) {
when (status) {
1 -> "OFF"
2 -> "ON"
3 -> "TRANSPARENCY"
@@ -251,103 +231,10 @@ class AirPodsNotifications {
class Capabilities {
companion object {
val NOISE_CANCELLATION = byteArrayOf(0x0d)
val CONVERSATION_AWARENESS = byteArrayOf(0x28)
val CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY = byteArrayOf(0x01, 0x02)
val EAR_DETECTION = byteArrayOf(0x06)
}
enum class NoiseCancellation(val value: ByteArray) {
OFF(byteArrayOf(0x01)),
ON(byteArrayOf(0x02)),
TRANSPARENCY(byteArrayOf(0x03)),
ADAPTIVE(byteArrayOf(0x04));
}
enum class ConversationAwareness(val value: ByteArray) {
OFF(byteArrayOf(0x02)),
ON(byteArrayOf(0x01));
}
}
enum class LongPressPackets(val value: ByteArray) {
ENABLE_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0F, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
ENABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
ENABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
ENABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
ENABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
ENABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0D, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
ENABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
ENABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
ENABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
ENABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
ENABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
ENABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
ENABLE_EVERYTHING_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0E, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0A, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
DISABLE_ANC_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0C, 0x00, 0x00, 0x00)),
}
//enum class LongPressMode {
// OFF, TRANSPARENCY, ADAPTIVE, ANC
//}
//
//data class LongPressPacket(val modes: Set<LongPressMode>) {
// val value: ByteArray
// get() {
// val baseArray = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A)
// val modeByte = calculateModeByte()
// return baseArray + byteArrayOf(modeByte, 0x00, 0x00, 0x00)
// }
//
// private fun calculateModeByte(): Byte {
// var modeByte: Byte = 0x00
// modes.forEach { mode ->
// modeByte = when (mode) {
// LongPressMode.OFF -> (modeByte + 0x01).toByte()
// LongPressMode.TRANSPARENCY -> (modeByte + 0x02).toByte()
// LongPressMode.ADAPTIVE -> (modeByte + 0x04).toByte()
// LongPressMode.ANC -> (modeByte + 0x08).toByte()
// }
// }
// return modeByte
// }
//}
//
//fun determinePacket(changedIndex: Int, newEnabled: Boolean, oldModes: Set<LongPressMode>, newModes: Set<LongPressMode>): ByteArray? {
// return if (newEnabled) {
// LongPressPacket(oldModes + newModes.elementAt(changedIndex)).value
// } else {
// LongPressPacket(oldModes - newModes.elementAt(changedIndex)).value
// }
//}
fun isHeadTrackingData(data: ByteArray): Boolean {
if (data.size <= 60) return false
@@ -0,0 +1,42 @@
/*
* LibrePods - AirPods liberated from Apples 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.constants
import me.kavishdevar.librepods.constants.StemAction.entries
import me.kavishdevar.librepods.utils.AACPManager
enum class StemAction {
PLAY_PAUSE,
PREVIOUS_TRACK,
NEXT_TRACK,
CAMERA_SHUTTER,
DIGITAL_ASSISTANT,
CYCLE_NOISE_CONTROL_MODES;
companion object {
fun fromString(action: String): StemAction? {
return entries.find { it.name == action }
}
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
)
}
}
@@ -99,10 +99,10 @@ import me.kavishdevar.librepods.composables.NameField
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings
import me.kavishdevar.librepods.composables.PressAndHoldSettings
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -113,6 +113,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
var isLocallyConnected by remember { mutableStateOf(isConnected) }
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false)
var device by remember { mutableStateOf(dev) }
var deviceName by remember {
mutableStateOf(
@@ -329,35 +330,67 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Spacer(modifier = Modifier.height(32.dp))
NameField(
name = stringResource(R.string.name),
value = deviceName.text,
navController = navController
)
// Show BLE-only mode indicator
if (bleOnlyMode) {
Text(
text = "BLE-only mode - advanced features disabled",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 16.dp)
)
}
Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service)
// Only show name field when not in BLE-only mode
if (!bleOnlyMode) {
NameField(
name = stringResource(R.string.name),
value = deviceName.text,
navController = navController
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.head_gestures).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
// Only show L2CAP-dependent features when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service)
Spacer(modifier = Modifier.height(2.dp))
NavigationButton(to = "head_tracking", "Head Tracking", navController)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.head_gestures).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Spacer(modifier = Modifier.height(16.dp))
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(2.dp))
NavigationButton(to = "head_tracking", "Head Tracking", navController)
Spacer(modifier = Modifier.height(16.dp))
AudioSettings()
Spacer(modifier = Modifier.height(16.dp))
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp))
AudioSettings()
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Off Listening Mode",
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
Spacer(modifier = Modifier.height(16.dp))
AccessibilitySettings()
}
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
@@ -365,23 +398,15 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
service = service,
functionName = "setEarDetection",
sharedPreferences = sharedPreferences,
default = true
default = true,
)
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Off Listening Mode",
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
// Only show debug when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
}
Spacer(modifier = Modifier.height(16.dp))
AccessibilitySettings()
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
Spacer(Modifier.height(24.dp))
}
}
@@ -183,6 +183,17 @@ fun AppSettingsScreen(navController: NavController) {
mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false))
}
var bleOnlyMode by remember {
mutableStateOf(sharedPreferences.getBoolean("ble_only_mode", false))
}
// Ensure the default value is properly set if not exists
LaunchedEffect(Unit) {
if (!sharedPreferences.contains("ble_only_mode")) {
sharedPreferences.edit().putBoolean("ble_only_mode", false).apply()
}
}
var mDensity by remember { mutableFloatStateOf(0f) }
fun validateHexInput(input: String): Boolean {
@@ -335,6 +346,69 @@ fun AppSettingsScreen(navController: NavController) {
}
}
Text(
text = "Connection Mode".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)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
bleOnlyMode = !bleOnlyMode
sharedPreferences.edit().putBoolean("ble_only_mode", bleOnlyMode).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = "BLE Only Mode",
fontSize = 16.sp,
color = textColor
)
Text(
text = "Only use Bluetooth Low Energy for battery data and ear detection. Disables advanced features requiring L2CAP connection.",
fontSize = 13.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = bleOnlyMode,
onCheckedChange = {
bleOnlyMode = it
sharedPreferences.edit().putBoolean("ble_only_mode", it).apply()
}
)
}
}
Text(
text = "Conversational Awareness".uppercase(),
style = TextStyle(
@@ -100,9 +100,9 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.BatteryStatus
import me.kavishdevar.librepods.utils.isHeadTrackingData
import kotlin.io.encoding.ExperimentalEncodingApi
data class PacketInfo(
@@ -57,10 +57,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -69,6 +68,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.experimental.and
@@ -84,6 +84,16 @@ fun RightDivider() {
)
}
@Composable()
fun RightDividerNoIcon() {
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 20.dp)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(navController: NavController, name: String) {
@@ -104,6 +114,10 @@ fun LongPress(navController: NavController, name: String) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
@@ -153,56 +167,88 @@ fun LongPress(navController: NavController, name: String) {
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
) {
Text(
text = "NOISE CONTROL",
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 = 4.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
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()
LongPressElement(
name = "Transparency",
resourceId = R.drawable.transparency,
isFirst = !offListeningMode)
RightDivider()
LongPressElement(
name = "Adaptive",
resourceId = R.drawable.adaptive)
RightDivider()
LongPressElement(
name = "Noise Cancellation",
resourceId = R.drawable.noise_cancellation,
isLast = true)
LongPressActionElement(
name = "Noise Control",
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = {
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
sharedPreferences.edit().putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name).apply()
},
isFirst = true,
isLast = false
)
RightDividerNoIcon()
LongPressActionElement(
name = "Digital Assistant",
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = {
longPressAction = StemAction.DIGITAL_ASSISTANT
sharedPreferences.edit().putString(prefKey, StemAction.DIGITAL_ASSISTANT.name).apply()
},
isFirst = false,
isLast = true
)
}
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Text(
text = "NOISE CONTROL",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.padding(top = 32.dp, bottom = 4.dp)
.padding(horizontal = 8.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
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()
LongPressElement(
name = "Transparency",
resourceId = R.drawable.transparency,
isFirst = !offListeningMode)
RightDivider()
LongPressElement(
name = "Adaptive",
resourceId = R.drawable.adaptive)
RightDivider()
LongPressElement(
name = "Noise Cancellation",
resourceId = R.drawable.noise_cancellation,
isLast = true)
}
Text(
"Press and hold the stem to cycle between the selected noise control modes.",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
modifier = Modifier
.padding(start = 16.dp, top = 4.dp)
)
}
Text(
"Press and hold the stem to cycle between the selected noise control modes.",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
modifier = Modifier
.padding(start = 16.dp, top = 4.dp)
)
}
}
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
@@ -336,7 +382,7 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(
bitmap = ImageBitmap.imageResource(resourceId),
painter = painterResource(resourceId),
contentDescription = "Icon",
tint = Color(0xFF007AFF),
modifier = Modifier
@@ -384,3 +430,67 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
}
}
}
@Composable
fun LongPressActionElement(
name: String,
selected: Boolean,
onClick: () -> Unit,
isFirst: Boolean = false,
isLast: Boolean = false
) {
val darkMode = isSystemInDarkTheme()
val shape = when {
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
else -> RoundedCornerShape(0.dp)
}
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.height(48.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
onClick()
}
)
}
.padding(horizontal = 16.dp, vertical = 0.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
name,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
)
Checkbox(
checked = selected,
onCheckedChange = { onClick() },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
disabledCheckedBoxColor = Color.Transparent,
disabledUncheckedBoxColor = Color.Transparent,
disabledUncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f),
)
}
}
@@ -35,9 +35,9 @@ import android.util.Log
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(Build.VERSION_CODES.Q)
@@ -77,12 +77,15 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.utils.BLEManager
import me.kavishdevar.librepods.utils.Battery
import me.kavishdevar.librepods.utils.BatteryComponent
import me.kavishdevar.librepods.utils.BatteryStatus
import me.kavishdevar.librepods.utils.BluetoothConnectionManager
import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.CrossDevicePackets
@@ -111,7 +114,6 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
import me.kavishdevar.librepods.utils.isHeadTrackingData
import me.kavishdevar.librepods.widgets.BatteryWidget
import me.kavishdevar.librepods.widgets.NoiseControlWidget
import org.lsposed.hiddenapibypass.HiddenApiBypass
@@ -142,7 +144,7 @@ object ServiceManager {
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
var macAddress = ""
lateinit var aacpManager: AACPManager
var cameraActive = false
data class ServiceConfig(
var deviceName: String = "AirPods",
var earDetectionEnabled: Boolean = true,
@@ -154,6 +156,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var conversationalAwarenessVolume: Int = 43,
var textColor: Long = -1L,
var qsClickBehavior: String = "cycle",
var bleOnlyMode: Boolean = false,
// AirPods state-based takeover
var takeoverWhenDisconnected: Boolean = true,
@@ -163,7 +166,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Phone state-based takeover
var takeoverWhenRingingCall: Boolean = true,
var takeoverWhenMediaStart: Boolean = true
var takeoverWhenMediaStart: Boolean = true,
var leftSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!,
var rightSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!,
var leftDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!,
var rightDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!,
var leftTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!,
var rightTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!,
var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
)
private lateinit var config: ServiceConfig
@@ -192,7 +207,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
device: BLEManager.AirPodsStatus,
previousStatus: BLEManager.AirPodsStatus?
) {
if (device.connectionState == "Disconnected") {
// Store MAC address for BLE-only mode if not already stored
if (config.bleOnlyMode && macAddress.isEmpty()) {
macAddress = device.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
Log.d("AirPodsBLEService", "BLE-only mode: stored MAC address ${device.address}")
}
if (device.connectionState == "Disconnected" && !config.bleOnlyMode) {
Log.d("AirPodsBLEService", "Seems no device has taken over, we will.")
val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString(
@@ -259,7 +283,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
leftInEar: Boolean,
rightInEar: Boolean
) {
Log.d("AirPodsBLEService", "Ear state changed")
Log.d("AirPodsBLEService", "Ear state changed - Left: $leftInEar, Right: $rightInEar")
// In BLE-only mode, ear detection is purely based on BLE data
if (config.bleOnlyMode) {
Log.d("AirPodsBLEService", "BLE-only mode: ear detection from BLE data")
}
}
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
@@ -300,6 +329,67 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
fun cameraOpened() {
Log.d("AirPodsService", "Camera opened, gonna handle stem presses and take action if enabled")
val isCameraShutterUsed = listOf(
config.leftSinglePressAction,
config.rightSinglePressAction,
config.leftDoublePressAction,
config.rightDoublePressAction,
config.leftTriplePressAction,
config.rightTriplePressAction,
config.leftLongPressAction,
config.rightLongPressAction
).any { it == StemAction.CAMERA_SHUTTER }
if (isCameraShutterUsed) {
Log.d("AirPodsService", "Camera opened, setting up stem actions")
cameraActive = true
setupStemActions(isCameraActive = true)
}
}
fun cameraClosed() {
cameraActive = false
setupStemActions()
}
fun isCustomAction(
action: StemAction?,
default: StemAction?,
isCameraActive: Boolean = false
): Boolean {
Log.d("AirPodsService", "Checking if action $action is custom against default $default, camera active: $isCameraActive")
return action != default && (action != StemAction.CAMERA_SHUTTER || isCameraActive)
}
fun setupStemActions(isCameraActive: Boolean = false) {
val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS]
val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]
val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]
val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS]
val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault, isCameraActive) ||
isCustomAction(config.rightSinglePressAction, singlePressDefault, isCameraActive)
val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault, isCameraActive) ||
isCustomAction(config.rightDoublePressAction, doublePressDefault, isCameraActive)
val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault, isCameraActive) ||
isCustomAction(config.rightTriplePressAction, triplePressDefault, isCameraActive)
val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault, isCameraActive) ||
isCustomAction(config.rightLongPressAction, longPressDefault, isCameraActive)
Log.d("AirPodsService", "Setting up stem actions: " +
"Single Press Customized: $singlePressCustomized, " +
"Double Press Customized: $doublePressCustomized, " +
"Triple Press Customized: $triplePressCustomized, " +
"Long Press Customized: $longPressCustomized")
aacpManager.sendStemConfigPacket(
singlePressCustomized,
doublePressCustomized,
triplePressCustomized,
longPressCustomized,
)
}
@ExperimentalEncodingApi
private fun initializeAACPManagerCallback() {
aacpManager.setPacketCallback(object : AACPManager.PacketCallback {
@@ -398,12 +488,58 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
override fun onStemPressReceived(stemPress: ByteArray) {
val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress)
Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud")
val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
action?.let { executeStemAction(it) }
}
override fun onUnknownPacketReceived(packet: ByteArray) {
Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}")
}
})
}
private fun getActionFor(bud: AACPManager.Companion.StemPressBudType, type: StemPressType): StemAction? {
return when (type) {
StemPressType.SINGLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftSinglePressAction else config.rightSinglePressAction
StemPressType.DOUBLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftDoublePressAction else config.rightDoublePressAction
StemPressType.TRIPLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftTriplePressAction else config.rightTriplePressAction
StemPressType.LONG_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftLongPressAction else config.rightLongPressAction
}
}
private fun executeStemAction(action: StemAction) {
when (action) {
StemAction.defaultActions[StemPressType.SINGLE_PRESS] -> {
Log.d("AirPodsParser", "Default single press action: Play/Pause, not taking action.")
}
StemAction.PLAY_PAUSE -> MediaController.sendPlayPause()
StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack()
StemAction.NEXT_TRACK -> MediaController.sendNextTrack()
StemAction.CAMERA_SHUTTER -> Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
StemAction.DIGITAL_ASSISTANT -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
} else {
Log.w("AirPodsParser", "Digital Assistant action is not supported on this Android version.")
}
}
StemAction.CYCLE_NOISE_CONTROL_MODES -> {
Log.d("AirPodsParser", "Cycling noise control modes")
sendBroadcast(Intent("me.kavishdevar.librepods.SET_ANC_MODE"))
}
}
}
private fun processEarDetectionChange(earDetection: ByteArray) {
var inEar = false
var inEarData = listOf(earDetectionNotification.status[0] == 0x00.toByte(), earDetectionNotification.status[1] == 0x00.toByte())
@@ -513,6 +649,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
textColor = sharedPreferences.getLong("textColor", -1L),
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false),
// AirPods state-based takeover
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true),
@@ -522,7 +659,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Phone state-based takeover
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true),
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true)
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true),
// Stem actions
leftSinglePressAction = StemAction.fromString(sharedPreferences.getString("left_single_press_action", "PLAY_PAUSE") ?: "PLAY_PAUSE")!!,
rightSinglePressAction = StemAction.fromString(sharedPreferences.getString("right_single_press_action", "PLAY_PAUSE") ?: "PLAY_PAUSE")!!,
leftDoublePressAction = StemAction.fromString(sharedPreferences.getString("left_double_press_action", "PREVIOUS_TRACK") ?: "NEXT_TRACK")!!,
rightDoublePressAction = StemAction.fromString(sharedPreferences.getString("right_double_press_action", "NEXT_TRACK") ?: "NEXT_TRACK")!!,
leftTriplePressAction = StemAction.fromString(sharedPreferences.getString("left_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!,
rightTriplePressAction = StemAction.fromString(sharedPreferences.getString("right_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!,
leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!,
rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!
)
}
@@ -544,6 +694,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
"textColor" -> config.textColor = preferences.getLong(key, -1L)
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
"ble_only_mode" -> config.bleOnlyMode = preferences.getBoolean(key, false)
// AirPods state-based takeover
"takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true)
@@ -554,6 +705,55 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Phone state-based takeover
"takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true)
"takeover_when_media_start" -> config.takeoverWhenMediaStart = preferences.getBoolean(key, true)
"left_single_press_action" -> {
config.leftSinglePressAction = StemAction.fromString(
preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE"
)!!
setupStemActions()
}
"right_single_press_action" -> {
config.rightSinglePressAction = StemAction.fromString(
preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE"
)!!
setupStemActions()
}
"left_double_press_action" -> {
config.leftDoublePressAction = StemAction.fromString(
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
)!!
setupStemActions()
}
"right_double_press_action" -> {
config.rightDoublePressAction = StemAction.fromString(
preferences.getString(key, "NEXT_TRACK") ?: "NEXT_TRACK"
)!!
setupStemActions()
}
"left_triple_press_action" -> {
config.leftTriplePressAction = StemAction.fromString(
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
)!!
setupStemActions()
}
"right_triple_press_action" -> {
config.rightTriplePressAction = StemAction.fromString(
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
)!!
setupStemActions()
}
"left_long_press_action" -> {
config.leftLongPressAction = StemAction.fromString(
preferences.getString(key, "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES"
)!!
setupStemActions()
}
"right_long_press_action" -> {
config.rightLongPressAction = StemAction.fromString(
preferences.getString(key, "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT"
)!!
setupStemActions()
}
}
if (key == "mac_address") {
@@ -1002,10 +1202,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
if (!::socket.isInitialized) {
if (!::socket.isInitialized && !config.bleOnlyMode) {
return
}
if (connected && socket.isConnected) {
if (connected && (config.bleOnlyMode || socket.isConnected)) {
updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status")
.setSmallIcon(R.drawable.airpods)
.setContentTitle(airpodsName ?: config.deviceName)
@@ -1057,7 +1257,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2)
} else if (!socket.isConnected && isConnectedLocally) {
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
}
@@ -1374,8 +1574,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE")
var ancModeReceiver: BroadcastReceiver? = null
@SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("AirPodsService", "Service started")
@@ -1426,6 +1624,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (!contains("qs_click_behavior")) editor.putString("qs_click_behavior", "cycle")
if (!contains("name")) editor.putString("name", "AirPods")
if (!contains("ble_only_mode")) editor.putBoolean("ble_only_mode", false)
if (!contains("left_single_press_action")) editor.putString("left_single_press_action",
StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
if (!contains("right_single_press_action")) editor.putString("right_single_press_action",
StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
if (!contains("left_double_press_action")) editor.putString("left_double_press_action",
StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
if (!contains("right_double_press_action")) editor.putString("right_double_press_action",
StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
if (!contains("left_triple_press_action")) editor.putString("left_triple_press_action",
StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
if (!contains("right_triple_press_action")) editor.putString("right_triple_press_action",
StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
if (!contains("left_long_press_action")) editor.putString("left_long_press_action",
StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
if (!contains("right_long_press_action")) editor.putString("right_long_press_action",
StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
editor.apply()
}
@@ -1575,7 +1791,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
if (!CrossDevice.isAvailable) {
if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
Log.d("AirPodsService", "${config.deviceName} connected")
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!)
@@ -1587,6 +1803,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit {
putString("mac_address", macAddress)
}
} else if (config.bleOnlyMode) {
Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
macAddress = device!!.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
}
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
device = null
@@ -1647,7 +1869,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
if (!CrossDevice.isAvailable) {
if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device)
}
@@ -1656,6 +1878,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit {
putString("mac_address", macAddress)
}
} else if (config.bleOnlyMode) {
Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
macAddress = device.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
}
this@AirPodsService.sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_CONNECTED)
@@ -1760,14 +1988,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
if (device != null) {
connectToSocket(device!!)
connectAudio(this, device)
if (config.bleOnlyMode) {
// In BLE-only mode, just show connecting status without actual L2CAP connection
Log.d("AirPodsService", "BLE-only mode: showing connecting status without L2CAP connection")
updateNotificationContent(
true,
config.deviceName,
batteryNotification.getBattery()
)
// Set a temporary connecting state
isConnectedLocally = false // Keep as false since we're not actually connecting to L2CAP
} else {
connectToSocket(device!!)
connectAudio(this, device)
isConnectedLocally = true
}
}
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
IslandType.TAKING_OVER)
isConnectedLocally = true
CrossDevice.isAvailable = false
}
@@ -1879,6 +2119,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
.putExtra("device", device)
)
setupStemActions()
while (socket.isConnected == true) {
socket.let {
val buffer = ByteArray(1024)
@@ -15,18 +15,21 @@
* 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 me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressBudType.entries
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType.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.
* constructing and parsing packets for communication with AirPods.
*/
class AACPManager {
companion object {
@@ -44,6 +47,7 @@ class AACPManager {
const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31
const val STEM_PRESS: Byte = 0x19
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -101,8 +105,8 @@ class AACPManager {
IN_CASE_TONE_CONFIG(0x31),
SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34);
ALLOW_OFF_OPTION(0x34),
STEM_CONFIG(0x39);
companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
entries.find { it.value == byte }
@@ -118,6 +122,28 @@ class AACPManager {
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
}
}
enum class StemPressType(val value: Byte) {
SINGLE_PRESS(0x05),
DOUBLE_PRESS(0x06),
TRIPLE_PRESS(0x07),
LONG_PRESS(0x08);
companion object {
fun fromByte(byte: Byte): StemPressType? =
entries.find { it.value == byte }
}
}
enum class StemPressBudType(val value: Byte) {
LEFT(0x01),
RIGHT(0x02);
companion object {
fun fromByte(byte: Byte): StemPressBudType? =
entries.find { it.value == byte }
}
}
}
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
@@ -149,6 +175,20 @@ class AACPManager {
fun onHeadTrackingReceived(headTracking: ByteArray)
fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray)
fun onStemPressReceived(stemPress: ByteArray)
}
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
Log.d(TAG, "Parsing Stem Press Response: ${data.joinToString(" ") { "%02X".format(it) }}")
if (data.size != 8) {
throw IllegalArgumentException("Data array too short to parse Stem Press Response")
}
if (data[4] != Opcodes.STEM_PRESS) {
throw IllegalArgumentException("Data array does not start with STEM_PRESS opcode")
}
val type = StemPressType.fromByte(data[6]) ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}")
val bud = StemPressBudType.fromByte(data[7]) ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}")
return Pair(type, bud)
}
interface ControlCommandListener {
@@ -195,6 +235,7 @@ class AACPManager {
return sendDataPacket(controlPacket)
}
@OptIn(ExperimentalStdlibApi::class)
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue(
@@ -323,6 +364,9 @@ class AACPManager {
Opcodes.PROXIMITY_KEYS_RSP -> {
callback?.onProximityKeysReceived(packet)
}
Opcodes.STEM_PRESS -> {
callback?.onStemPressReceived(packet)
}
else -> {
callback?.onUnknownPacketReceived(packet)
}
@@ -456,13 +500,29 @@ class AACPManager {
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 sendStemConfigPacket(
singlePressCustomized: Boolean = false,
doublePressCustomized: Boolean = false,
triplePressCustomized: Boolean = false,
longPressCustomized: Boolean = false
): Boolean {
val value = ((if (singlePressCustomized) 0x01 else 0) or
(if (doublePressCustomized) 0x02 else 0) or
(if (triplePressCustomized) 0x04 else 0) or
(if (longPressCustomized) 0x08 else 0)).toByte()
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
return sendControlCommand(
ControlCommandIdentifiers.STEM_CONFIG.value, value
)
}
@OptIn(ExperimentalStdlibApi::class)
fun sendPacket(packet: ByteArray): Boolean {
try {
@@ -58,6 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
@@ -118,6 +122,7 @@ class IslandWindow(private val context: Context) {
val isVisible: Boolean
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
@SuppressLint("SetTextI18n")
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
if (batteryList == null || batteryList.isEmpty()) return
@@ -150,7 +155,7 @@ class IslandWindow(private val context: Context) {
}
}
@SuppressLint("SetTextI18s", "ClickableViewAccessibility")
@SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag")
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true
@@ -162,13 +167,13 @@ class IslandWindow(private val context: Context) {
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 ->
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
@@ -180,7 +185,7 @@ class IslandWindow(private val context: Context) {
} else {
null
}
if (displayBatteryLevel != null) {
batteryText.text = "$displayBatteryLevel%"
batteryProgressBar.progress = displayBatteryLevel
@@ -188,7 +193,7 @@ class IslandWindow(private val context: Context) {
batteryText.text = "?"
batteryProgressBar.progress = 0
}
batteryProgressBar.isIndeterminate = false
islandView.findViewById<TextView>(R.id.island_device_name).text = name
@@ -403,11 +408,11 @@ class IslandWindow(private val context: Context) {
if (params != null) {
params!!.height = screenHeight
val containerParams = containerView.layoutParams
containerParams.height = screenHeight
containerView.layoutParams = containerParams
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
@@ -552,7 +557,7 @@ class IslandWindow(private val context: Context) {
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
}
@@ -620,7 +625,7 @@ class IslandWindow(private val context: Context) {
} catch (e: Exception) {
e.printStackTrace()
}
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)
@@ -640,7 +645,7 @@ class IslandWindow(private val context: Context) {
cleanupAndRemoveView()
}
}
private fun cleanupAndRemoveView() {
containerView.visibility = View.GONE
try {
@@ -655,25 +660,25 @@ class IslandWindow(private val context: Context) {
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) {
@@ -100,6 +100,51 @@ object MediaController {
return audioManager.isMusicActive
}
@Synchronized
fun sendPlayPause() {
if (audioManager.isMusicActive) {
Log.d("MediaController", "Sending pause because music is active")
sendPause()
} else {
Log.d("MediaController", "Sending play because music is not active")
sendPlay()
}
}
@Synchronized
fun sendPreviousTrack() {
Log.d("MediaController", "Sending previous track")
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_MEDIA_PREVIOUS
)
)
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_MEDIA_PREVIOUS
)
)
}
@Synchronized
fun sendNextTrack() {
Log.d("MediaController", "Sending next track")
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_MEDIA_NEXT
)
)
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_MEDIA_NEXT
)
)
}
@Synchronized
fun sendPause(force: Boolean = false) {
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
@@ -45,6 +45,11 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.VideoView
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import kotlin.collections.find
@SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow(
@@ -124,9 +129,9 @@ class PopupWindow(
try {
if (mView.windowToken == null && mView.parent == null && !isClosing) {
mView.findViewById<TextView>(R.id.name).text = name
updateBatteryStatus(batteryNotification)
val vid = mView.findViewById<VideoView>(R.id.video)
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
vid.resolveAdjustedSize(vid.width, vid.height)
@@ -134,7 +139,7 @@ class PopupWindow(
vid.setOnCompletionListener {
vid.start()
}
mWindowManager.addView(mView, mParams)
val displayMetrics = mView.context.resources.displayMetrics
@@ -144,13 +149,13 @@ class PopupWindow(
mView.alpha = 1f
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f)
ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply {
duration = 500
interpolator = DecelerateInterpolator()
start()
}
registerBatteryUpdateReceiver()
autoCloseRunnable = Runnable { close() }
@@ -162,6 +167,7 @@ class PopupWindow(
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
private fun registerBatteryUpdateReceiver() {
batteryUpdateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -173,7 +179,7 @@ class PopupWindow(
}
}
}
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
@@ -192,7 +198,7 @@ class PopupWindow(
}
}
}
private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
@@ -205,7 +211,7 @@ class PopupWindow(
""
}
} ?: ""
batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDC8D ${it.level}%"
@@ -213,7 +219,7 @@ class PopupWindow(
""
}
} ?: ""
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDE6C ${it.level}%"
@@ -233,13 +239,13 @@ class PopupWindow(
try {
if (isClosing) return
isClosing = true
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
unregisterBatteryUpdateReceiver()
val vid = mView.findViewById<VideoView>(R.id.video)
vid.stopPlayback()
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
duration = 500
interpolator = AccelerateInterpolator()