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 @@
+
+
+