diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 76b7a4c..92be4ea 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -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( } } } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt index c865f9b..d8a79d2 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt index bab51b1..c4740d7 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt index 7a1f335..743e918 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt @@ -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) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt index 421c920..f3e320b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt @@ -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) -} +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt index 7720c08..afb1796 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt index e3a73ae..eb83542 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt @@ -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), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt similarity index 53% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt rename to android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt index 78f240a..6c8d661 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt @@ -16,10 +16,7 @@ * along with this program. If not, see . */ - -@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) { -// 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, newModes: Set): 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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt b/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt new file mode 100644 index 0000000..3c5be49 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt @@ -0,0 +1,42 @@ +/* + * 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 . + */ + +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 = 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, + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index ce325d5..97bdaeb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -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)) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt index 6f2ce0d..308c280 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt @@ -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( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt index 76e0d88..6529cbe 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt @@ -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( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt index bb8668e..4d2f2c8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt @@ -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), + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt index a30a5ef..efddf8a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt @@ -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) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 1cb5960..f8cb408 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -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", " 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) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt index 82f7395..2a3831d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt @@ -15,18 +15,21 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ + @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 = mutableListOf() var controlCommandListeners: MutableMap> = 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 { + 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 { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt index 1f97da7..978294e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt @@ -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?) { 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(R.id.island_battery_text) val batteryProgressBar = islandView.findViewById(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(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(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) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt index c0c8f00..c719349 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt @@ -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") diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt index e8bd0bf..d050cba 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt @@ -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(R.id.name).text = name - + updateBatteryStatus(batteryNotification) - + val vid = mView.findViewById(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) { val batteryLeftText = mView.findViewById(R.id.left_battery) val batteryRightText = mView.findViewById(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(R.id.video) vid.stopPlayback() - + ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply { duration = 500 interpolator = AccelerateInterpolator() diff --git a/android/app/src/main/res/drawable/settings_voice.xml b/android/app/src/main/res/drawable/settings_voice.xml new file mode 100644 index 0000000..5831570 --- /dev/null +++ b/android/app/src/main/res/drawable/settings_voice.xml @@ -0,0 +1,10 @@ + + +