mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-27 00:23:30 +00:00
android: add ability to launch digital assistant on long press (#180)
* Initial plan * Implement BLE-only mode toggle and basic functionality Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com> * Fix BLE-only mode compatibility issues and enhance MAC address handling Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com> * Address BLE-only mode feedback: hide renaming, add ear detection warning, ensure default is false Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com> * android: add support for invoking digital assistant on long press --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -104,6 +104,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
|||||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.DebugScreen
|
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.screens.TroubleshootingScreen
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.utils.CrossDevice
|
import me.kavishdevar.librepods.utils.CrossDevice
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
lateinit var serviceConnection: ServiceConnection
|
lateinit var serviceConnection: ServiceConnection
|
||||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||||
|
|||||||
@@ -62,22 +62,20 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
||||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
|
||||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
||||||
|
import me.kavishdevar.librepods.composables.IconAreaSize
|
||||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
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.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import me.kavishdevar.librepods.R
|
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.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
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||||
|
|
||||||
private val ContainerColor = Color(0x593C3C3E)
|
private val ContainerColor = Color(0x593C3C3E)
|
||||||
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||||
|
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
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.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -57,6 +58,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.constants.StemAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PressAndHoldSettings(navController: NavController) {
|
fun PressAndHoldSettings(navController: NavController) {
|
||||||
@@ -70,6 +72,24 @@ fun PressAndHoldSettings(navController: NavController) {
|
|||||||
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
|
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
|
||||||
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, 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(
|
||||||
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
|
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -122,7 +142,7 @@ fun PressAndHoldSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.noise_control),
|
text = leftActionText,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
color = textColor.copy(alpha = 0.6f),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
@@ -182,7 +202,7 @@ fun PressAndHoldSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.noise_control),
|
text = rightActionText,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
color = textColor.copy(alpha = 0.6f),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
|||||||
@@ -16,10 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.constants
|
||||||
@file:Suppress("unused")
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -27,27 +24,10 @@ import kotlinx.parcelize.Parcelize
|
|||||||
|
|
||||||
enum class Enums(val value: ByteArray) {
|
enum class Enums(val value: ByteArray) {
|
||||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
||||||
CONVERSATION_AWARENESS(Capabilities.CONVERSATION_AWARENESS),
|
|
||||||
CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY(Capabilities.CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY),
|
|
||||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||||
SETTINGS(byteArrayOf(0x09, 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_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)),
|
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 {
|
object BatteryComponent {
|
||||||
@@ -251,103 +231,10 @@ class AirPodsNotifications {
|
|||||||
class Capabilities {
|
class Capabilities {
|
||||||
companion object {
|
companion object {
|
||||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
||||||
val CONVERSATION_AWARENESS = byteArrayOf(0x28)
|
|
||||||
val CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY = byteArrayOf(0x01, 0x02)
|
|
||||||
val EAR_DETECTION = byteArrayOf(0x06)
|
val EAR_DETECTION = byteArrayOf(0x06)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class NoiseCancellation(val value: ByteArray) {
|
|
||||||
OFF(byteArrayOf(0x01)),
|
|
||||||
ON(byteArrayOf(0x02)),
|
|
||||||
TRANSPARENCY(byteArrayOf(0x03)),
|
|
||||||
ADAPTIVE(byteArrayOf(0x04));
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ConversationAwareness(val value: ByteArray) {
|
|
||||||
OFF(byteArrayOf(0x02)),
|
|
||||||
ON(byteArrayOf(0x01));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class LongPressPackets(val value: ByteArray) {
|
|
||||||
ENABLE_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0F, 0x00, 0x00, 0x00)),
|
|
||||||
|
|
||||||
DISABLE_OFF_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
|
|
||||||
|
|
||||||
ENABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
|
||||||
ENABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
|
||||||
ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
|
|
||||||
|
|
||||||
DISABLE_TRANSPARENCY_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
|
|
||||||
|
|
||||||
ENABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
|
||||||
ENABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
|
||||||
ENABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
|
|
||||||
|
|
||||||
DISABLE_ANC_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0D, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
|
|
||||||
|
|
||||||
ENABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
|
||||||
ENABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
|
||||||
ENABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
|
||||||
|
|
||||||
DISABLE_ADAPTIVE_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
|
|
||||||
|
|
||||||
ENABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
|
|
||||||
ENABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
|
||||||
ENABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
|
||||||
|
|
||||||
ENABLE_EVERYTHING_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0E, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_TRANSPARENCY_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0A, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_ADAPTIVE_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
|
|
||||||
DISABLE_ANC_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0C, 0x00, 0x00, 0x00)),
|
|
||||||
}
|
|
||||||
|
|
||||||
//enum class LongPressMode {
|
|
||||||
// OFF, TRANSPARENCY, ADAPTIVE, ANC
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//data class LongPressPacket(val modes: Set<LongPressMode>) {
|
|
||||||
// val value: ByteArray
|
|
||||||
// get() {
|
|
||||||
// val baseArray = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A)
|
|
||||||
// val modeByte = calculateModeByte()
|
|
||||||
// return baseArray + byteArrayOf(modeByte, 0x00, 0x00, 0x00)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// private fun calculateModeByte(): Byte {
|
|
||||||
// var modeByte: Byte = 0x00
|
|
||||||
// modes.forEach { mode ->
|
|
||||||
// modeByte = when (mode) {
|
|
||||||
// LongPressMode.OFF -> (modeByte + 0x01).toByte()
|
|
||||||
// LongPressMode.TRANSPARENCY -> (modeByte + 0x02).toByte()
|
|
||||||
// LongPressMode.ADAPTIVE -> (modeByte + 0x04).toByte()
|
|
||||||
// LongPressMode.ANC -> (modeByte + 0x08).toByte()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return modeByte
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//fun determinePacket(changedIndex: Int, newEnabled: Boolean, oldModes: Set<LongPressMode>, newModes: Set<LongPressMode>): ByteArray? {
|
|
||||||
// return if (newEnabled) {
|
|
||||||
// LongPressPacket(oldModes + newModes.elementAt(changedIndex)).value
|
|
||||||
// } else {
|
|
||||||
// LongPressPacket(oldModes - newModes.elementAt(changedIndex)).value
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||||
if (data.size <= 60) return false
|
if (data.size <= 60) return false
|
||||||
|
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.constants
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.constants.StemAction.entries
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
|
||||||
|
enum class StemAction {
|
||||||
|
PLAY_PAUSE,
|
||||||
|
PREVIOUS_TRACK,
|
||||||
|
NEXT_TRACK,
|
||||||
|
CAMERA_SHUTTER,
|
||||||
|
DIGITAL_ASSISTANT,
|
||||||
|
CYCLE_NOISE_CONTROL_MODES;
|
||||||
|
companion object {
|
||||||
|
fun fromString(action: String): StemAction? {
|
||||||
|
return entries.find { it.name == action }
|
||||||
|
}
|
||||||
|
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
|
||||||
|
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
|
||||||
|
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
|
||||||
|
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
|
||||||
|
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,10 +99,10 @@ import me.kavishdevar.librepods.composables.NameField
|
|||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
import me.kavishdevar.librepods.composables.NavigationButton
|
||||||
import me.kavishdevar.librepods.composables.NoiseControlSettings
|
import me.kavishdevar.librepods.composables.NoiseControlSettings
|
||||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
||||||
|
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@@ -113,6 +113,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
||||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
val bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false)
|
||||||
var device by remember { mutableStateOf(dev) }
|
var device by remember { mutableStateOf(dev) }
|
||||||
var deviceName by remember {
|
var deviceName by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -329,12 +330,31 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show name field when not in BLE-only mode
|
||||||
|
if (!bleOnlyMode) {
|
||||||
NameField(
|
NameField(
|
||||||
name = stringResource(R.string.name),
|
name = stringResource(R.string.name),
|
||||||
value = deviceName.text,
|
value = deviceName.text,
|
||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show L2CAP-dependent features when not in BLE-only mode
|
||||||
|
if (!bleOnlyMode) {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
NoiseControlSettings(service = service)
|
NoiseControlSettings(service = service)
|
||||||
|
|
||||||
@@ -359,15 +379,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AudioSettings()
|
AudioSettings()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
IndependentToggle(
|
|
||||||
name = "Automatic Ear Detection",
|
|
||||||
service = service,
|
|
||||||
functionName = "setEarDetection",
|
|
||||||
sharedPreferences = sharedPreferences,
|
|
||||||
default = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
IndependentToggle(
|
IndependentToggle(
|
||||||
name = "Off Listening Mode",
|
name = "Off Listening Mode",
|
||||||
@@ -379,9 +390,23 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AccessibilitySettings()
|
AccessibilitySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
IndependentToggle(
|
||||||
|
name = "Automatic Ear Detection",
|
||||||
|
service = service,
|
||||||
|
functionName = "setEarDetection",
|
||||||
|
sharedPreferences = sharedPreferences,
|
||||||
|
default = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only show debug when not in BLE-only mode
|
||||||
|
if (!bleOnlyMode) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
NavigationButton("debug", "Debug", navController)
|
NavigationButton("debug", "Debug", navController)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,17 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false))
|
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) }
|
var mDensity by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
fun validateHexInput(input: String): Boolean {
|
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(
|
||||||
text = "Conversational Awareness".uppercase(),
|
text = "Conversational Awareness".uppercase(),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
|
|||||||
@@ -100,9 +100,9 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
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.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
|
||||||
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
data class PacketInfo(
|
data class PacketInfo(
|
||||||
|
|||||||
@@ -57,10 +57,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -69,6 +68,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.constants.StemAction
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import kotlin.experimental.and
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPress(navController: NavController, name: String) {
|
fun LongPress(navController: NavController, name: String) {
|
||||||
@@ -104,6 +114,10 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
@@ -153,6 +167,36 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
) {
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
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(
|
||||||
text = "NOISE CONTROL",
|
text = "NOISE CONTROL",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -162,7 +206,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
),
|
),
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(8.dp, bottom = 4.dp)
|
.padding(top = 32.dp, bottom = 4.dp)
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -205,6 +250,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
||||||
@@ -336,7 +382,7 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
bitmap = ImageBitmap.imageResource(resourceId),
|
painter = painterResource(resourceId),
|
||||||
contentDescription = "Icon",
|
contentDescription = "Icon",
|
||||||
tint = Color(0xFF007AFF),
|
tint = Color(0xFF007AFF),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -384,3 +430,67 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LongPressActionElement(
|
||||||
|
name: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
isFirst: Boolean = false,
|
||||||
|
isLast: Boolean = false
|
||||||
|
) {
|
||||||
|
val darkMode = isSystemInDarkTheme()
|
||||||
|
val shape = when {
|
||||||
|
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
||||||
|
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||||
|
else -> RoundedCornerShape(0.dp)
|
||||||
|
}
|
||||||
|
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||||
|
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(48.dp)
|
||||||
|
.background(animatedBackgroundColor, shape)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
|
tryAwaitRelease()
|
||||||
|
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
Checkbox(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = { onClick() },
|
||||||
|
colors = CheckboxDefaults.colors().copy(
|
||||||
|
checkedCheckmarkColor = Color(0xFF007AFF),
|
||||||
|
uncheckedCheckmarkColor = Color.Transparent,
|
||||||
|
checkedBoxColor = Color.Transparent,
|
||||||
|
uncheckedBoxColor = Color.Transparent,
|
||||||
|
checkedBorderColor = Color.Transparent,
|
||||||
|
uncheckedBorderColor = Color.Transparent,
|
||||||
|
disabledBorderColor = Color.Transparent,
|
||||||
|
disabledCheckedBoxColor = Color.Transparent,
|
||||||
|
disabledUncheckedBoxColor = Color.Transparent,
|
||||||
|
disabledUncheckedBorderColor = Color.Transparent
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.height(24.dp)
|
||||||
|
.scale(1.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ import android.util.Log
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||||
import me.kavishdevar.librepods.R
|
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.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
|||||||
@@ -77,12 +77,15 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
|||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import me.kavishdevar.librepods.MainActivity
|
import me.kavishdevar.librepods.MainActivity
|
||||||
import me.kavishdevar.librepods.R
|
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.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.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.BluetoothConnectionManager
|
||||||
import me.kavishdevar.librepods.utils.CrossDevice
|
import me.kavishdevar.librepods.utils.CrossDevice
|
||||||
import me.kavishdevar.librepods.utils.CrossDevicePackets
|
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_CHARGING
|
||||||
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON
|
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.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
|
||||||
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
|
||||||
import me.kavishdevar.librepods.widgets.BatteryWidget
|
import me.kavishdevar.librepods.widgets.BatteryWidget
|
||||||
import me.kavishdevar.librepods.widgets.NoiseControlWidget
|
import me.kavishdevar.librepods.widgets.NoiseControlWidget
|
||||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||||
@@ -142,7 +144,7 @@ object ServiceManager {
|
|||||||
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
|
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
var macAddress = ""
|
var macAddress = ""
|
||||||
lateinit var aacpManager: AACPManager
|
lateinit var aacpManager: AACPManager
|
||||||
|
var cameraActive = false
|
||||||
data class ServiceConfig(
|
data class ServiceConfig(
|
||||||
var deviceName: String = "AirPods",
|
var deviceName: String = "AirPods",
|
||||||
var earDetectionEnabled: Boolean = true,
|
var earDetectionEnabled: Boolean = true,
|
||||||
@@ -154,6 +156,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
var conversationalAwarenessVolume: Int = 43,
|
var conversationalAwarenessVolume: Int = 43,
|
||||||
var textColor: Long = -1L,
|
var textColor: Long = -1L,
|
||||||
var qsClickBehavior: String = "cycle",
|
var qsClickBehavior: String = "cycle",
|
||||||
|
var bleOnlyMode: Boolean = false,
|
||||||
|
|
||||||
// AirPods state-based takeover
|
// AirPods state-based takeover
|
||||||
var takeoverWhenDisconnected: Boolean = true,
|
var takeoverWhenDisconnected: Boolean = true,
|
||||||
@@ -163,7 +166,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
// Phone state-based takeover
|
// Phone state-based takeover
|
||||||
var takeoverWhenRingingCall: Boolean = true,
|
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
|
private lateinit var config: ServiceConfig
|
||||||
@@ -192,7 +207,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
device: BLEManager.AirPodsStatus,
|
device: BLEManager.AirPodsStatus,
|
||||||
previousStatus: 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.")
|
Log.d("AirPodsBLEService", "Seems no device has taken over, we will.")
|
||||||
val bluetoothManager = getSystemService(BluetoothManager::class.java)
|
val bluetoothManager = getSystemService(BluetoothManager::class.java)
|
||||||
val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString(
|
val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString(
|
||||||
@@ -259,7 +283,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
leftInEar: Boolean,
|
leftInEar: Boolean,
|
||||||
rightInEar: 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) {
|
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
|
||||||
@@ -300,6 +329,67 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
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
|
@ExperimentalEncodingApi
|
||||||
private fun initializeAACPManagerCallback() {
|
private fun initializeAACPManagerCallback() {
|
||||||
aacpManager.setPacketCallback(object : AACPManager.PacketCallback {
|
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) {
|
override fun onUnknownPacketReceived(packet: ByteArray) {
|
||||||
Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
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) {
|
private fun processEarDetectionChange(earDetection: ByteArray) {
|
||||||
var inEar = false
|
var inEar = false
|
||||||
var inEarData = listOf(earDetectionNotification.status[0] == 0x00.toByte(), earDetectionNotification.status[1] == 0x00.toByte())
|
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),
|
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
|
||||||
textColor = sharedPreferences.getLong("textColor", -1L),
|
textColor = sharedPreferences.getLong("textColor", -1L),
|
||||||
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
|
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
|
||||||
|
bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false),
|
||||||
|
|
||||||
// AirPods state-based takeover
|
// AirPods state-based takeover
|
||||||
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true),
|
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true),
|
||||||
@@ -522,7 +659,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
// Phone state-based takeover
|
// Phone state-based takeover
|
||||||
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true),
|
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)
|
"conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
|
||||||
"textColor" -> config.textColor = preferences.getLong(key, -1L)
|
"textColor" -> config.textColor = preferences.getLong(key, -1L)
|
||||||
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
|
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
|
||||||
|
"ble_only_mode" -> config.bleOnlyMode = preferences.getBoolean(key, false)
|
||||||
|
|
||||||
// AirPods state-based takeover
|
// AirPods state-based takeover
|
||||||
"takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true)
|
"takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true)
|
||||||
@@ -554,6 +705,55 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
// Phone state-based takeover
|
// Phone state-based takeover
|
||||||
"takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true)
|
"takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true)
|
||||||
"takeover_when_media_start" -> config.takeoverWhenMediaStart = 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") {
|
if (key == "mac_address") {
|
||||||
@@ -1002,10 +1202,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!::socket.isInitialized) {
|
if (!::socket.isInitialized && !config.bleOnlyMode) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (connected && socket.isConnected) {
|
if (connected && (config.bleOnlyMode || socket.isConnected)) {
|
||||||
updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status")
|
updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status")
|
||||||
.setSmallIcon(R.drawable.airpods)
|
.setSmallIcon(R.drawable.airpods)
|
||||||
.setContentTitle(airpodsName ?: config.deviceName)
|
.setContentTitle(airpodsName ?: config.deviceName)
|
||||||
@@ -1057,7 +1257,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
notificationManager.notify(1, updatedNotification)
|
notificationManager.notify(1, updatedNotification)
|
||||||
notificationManager.cancel(2)
|
notificationManager.cancel(2)
|
||||||
} else if (!socket.isConnected && isConnectedLocally) {
|
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
|
||||||
Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected")
|
Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected")
|
||||||
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
|
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")
|
val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE")
|
||||||
var ancModeReceiver: BroadcastReceiver? = null
|
var ancModeReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
Log.d("AirPodsService", "Service started")
|
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("qs_click_behavior")) editor.putString("qs_click_behavior", "cycle")
|
||||||
if (!contains("name")) editor.putString("name", "AirPods")
|
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()
|
editor.apply()
|
||||||
}
|
}
|
||||||
@@ -1575,7 +1791,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
|
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
|
||||||
if (!CrossDevice.isAvailable) {
|
if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
|
||||||
Log.d("AirPodsService", "${config.deviceName} connected")
|
Log.d("AirPodsService", "${config.deviceName} connected")
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
connectToSocket(device!!)
|
connectToSocket(device!!)
|
||||||
@@ -1587,6 +1803,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putString("mac_address", macAddress)
|
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) {
|
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
|
||||||
device = null
|
device = null
|
||||||
@@ -1647,7 +1869,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
if (profile == BluetoothProfile.A2DP) {
|
if (profile == BluetoothProfile.A2DP) {
|
||||||
val connectedDevices = proxy.connectedDevices
|
val connectedDevices = proxy.connectedDevices
|
||||||
if (connectedDevices.isNotEmpty()) {
|
if (connectedDevices.isNotEmpty()) {
|
||||||
if (!CrossDevice.isAvailable) {
|
if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
connectToSocket(device)
|
connectToSocket(device)
|
||||||
}
|
}
|
||||||
@@ -1656,6 +1878,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putString("mac_address", macAddress)
|
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(
|
this@AirPodsService.sendBroadcast(
|
||||||
Intent(AirPodsNotifications.AIRPODS_CONNECTED)
|
Intent(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||||
@@ -1760,14 +1988,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (device != null) {
|
if (device != null) {
|
||||||
|
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!!)
|
connectToSocket(device!!)
|
||||||
connectAudio(this, 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!!),
|
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
|
||||||
IslandType.TAKING_OVER)
|
IslandType.TAKING_OVER)
|
||||||
|
|
||||||
isConnectedLocally = true
|
|
||||||
CrossDevice.isAvailable = false
|
CrossDevice.isAvailable = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1879,6 +2119,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
.putExtra("device", device)
|
.putExtra("device", device)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
setupStemActions()
|
||||||
|
|
||||||
while (socket.isConnected == true) {
|
while (socket.isConnected == true) {
|
||||||
socket.let {
|
socket.let {
|
||||||
val buffer = ByteArray(1024)
|
val buffer = ByteArray(1024)
|
||||||
|
|||||||
@@ -15,18 +15,21 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
|
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
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager class for Apple Accessory Communication Protocol (AACP)
|
* Manager class for Apple Accessory Communication Protocol (AACP)
|
||||||
* This class is responsible for handling the L2CAP socket management,
|
* 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 {
|
class AACPManager {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -44,6 +47,7 @@ class AACPManager {
|
|||||||
const val HEADTRACKING: Byte = 0x17
|
const val HEADTRACKING: Byte = 0x17
|
||||||
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||||
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||||
|
const val STEM_PRESS: Byte = 0x19
|
||||||
}
|
}
|
||||||
|
|
||||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||||
@@ -101,8 +105,8 @@ class AACPManager {
|
|||||||
IN_CASE_TONE_CONFIG(0x31),
|
IN_CASE_TONE_CONFIG(0x31),
|
||||||
SIRI_MULTITONE_CONFIG(0x32),
|
SIRI_MULTITONE_CONFIG(0x32),
|
||||||
HEARING_ASSIST_CONFIG(0x33),
|
HEARING_ASSIST_CONFIG(0x33),
|
||||||
ALLOW_OFF_OPTION(0x34);
|
ALLOW_OFF_OPTION(0x34),
|
||||||
|
STEM_CONFIG(0x39);
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
|
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
|
||||||
entries.find { it.value == byte }
|
entries.find { it.value == byte }
|
||||||
@@ -118,6 +122,28 @@ class AACPManager {
|
|||||||
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class StemPressType(val value: Byte) {
|
||||||
|
SINGLE_PRESS(0x05),
|
||||||
|
DOUBLE_PRESS(0x06),
|
||||||
|
TRIPLE_PRESS(0x07),
|
||||||
|
LONG_PRESS(0x08);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): StemPressType? =
|
||||||
|
entries.find { it.value == byte }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class StemPressBudType(val value: Byte) {
|
||||||
|
LEFT(0x01),
|
||||||
|
RIGHT(0x02);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): StemPressBudType? =
|
||||||
|
entries.find { it.value == byte }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
|
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
|
||||||
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
|
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
|
||||||
@@ -149,6 +175,20 @@ class AACPManager {
|
|||||||
fun onHeadTrackingReceived(headTracking: ByteArray)
|
fun onHeadTrackingReceived(headTracking: ByteArray)
|
||||||
fun onUnknownPacketReceived(packet: ByteArray)
|
fun onUnknownPacketReceived(packet: ByteArray)
|
||||||
fun onProximityKeysReceived(proximityKeys: ByteArray)
|
fun onProximityKeysReceived(proximityKeys: ByteArray)
|
||||||
|
fun onStemPressReceived(stemPress: ByteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||||
|
Log.d(TAG, "Parsing Stem Press Response: ${data.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
if (data.size != 8) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Stem Press Response")
|
||||||
|
}
|
||||||
|
if (data[4] != Opcodes.STEM_PRESS) {
|
||||||
|
throw IllegalArgumentException("Data array does not start with STEM_PRESS opcode")
|
||||||
|
}
|
||||||
|
val type = StemPressType.fromByte(data[6]) ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}")
|
||||||
|
val bud = StemPressBudType.fromByte(data[7]) ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}")
|
||||||
|
return Pair(type, bud)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ControlCommandListener {
|
interface ControlCommandListener {
|
||||||
@@ -195,6 +235,7 @@ class AACPManager {
|
|||||||
return sendDataPacket(controlPacket)
|
return sendDataPacket(controlPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
@@ -323,6 +364,9 @@ class AACPManager {
|
|||||||
Opcodes.PROXIMITY_KEYS_RSP -> {
|
Opcodes.PROXIMITY_KEYS_RSP -> {
|
||||||
callback?.onProximityKeysReceived(packet)
|
callback?.onProximityKeysReceived(packet)
|
||||||
}
|
}
|
||||||
|
Opcodes.STEM_PRESS -> {
|
||||||
|
callback?.onStemPressReceived(packet)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
callback?.onUnknownPacketReceived(packet)
|
callback?.onUnknownPacketReceived(packet)
|
||||||
}
|
}
|
||||||
@@ -456,13 +500,29 @@ class AACPManager {
|
|||||||
val value = ByteArray(4)
|
val value = ByteArray(4)
|
||||||
System.arraycopy(data, 3, value, 0, 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()
|
val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
|
||||||
return ControlCommand(identifier, trimmedValue)
|
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)
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
fun sendPacket(packet: ByteArray): Boolean {
|
fun sendPacket(packet: ByteArray): Boolean {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
|
|||||||
import androidx.dynamicanimation.animation.SpringAnimation
|
import androidx.dynamicanimation.animation.SpringAnimation
|
||||||
import androidx.dynamicanimation.animation.SpringForce
|
import androidx.dynamicanimation.animation.SpringForce
|
||||||
import me.kavishdevar.librepods.R
|
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 me.kavishdevar.librepods.services.ServiceManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -118,6 +122,7 @@ class IslandWindow(private val context: Context) {
|
|||||||
val isVisible: Boolean
|
val isVisible: Boolean
|
||||||
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
|
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
|
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
|
||||||
if (batteryList == null || batteryList.isEmpty()) return
|
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) {
|
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
|
||||||
if (ServiceManager.getService()?.islandOpen == true) return
|
if (ServiceManager.getService()?.islandOpen == true) return
|
||||||
else ServiceManager.getService()?.islandOpen = true
|
else ServiceManager.getService()?.islandOpen = true
|
||||||
|
|||||||
@@ -100,6 +100,51 @@ object MediaController {
|
|||||||
return audioManager.isMusicActive
|
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
|
@Synchronized
|
||||||
fun sendPause(force: Boolean = false) {
|
fun sendPause(force: Boolean = false) {
|
||||||
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
|
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.VideoView
|
import android.widget.VideoView
|
||||||
import me.kavishdevar.librepods.R
|
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")
|
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
||||||
class PopupWindow(
|
class PopupWindow(
|
||||||
@@ -162,6 +167,7 @@ class PopupWindow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
private fun registerBatteryUpdateReceiver() {
|
private fun registerBatteryUpdateReceiver() {
|
||||||
batteryUpdateReceiver = object : BroadcastReceiver() {
|
batteryUpdateReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
|||||||
10
android/app/src/main/res/drawable/settings_voice.xml
Normal file
10
android/app/src/main/res/drawable/settings_voice.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M320,960Q303,960 291.5,948.5Q280,937 280,920Q280,903 291.5,891.5Q303,880 320,880Q337,880 348.5,891.5Q360,903 360,920Q360,937 348.5,948.5Q337,960 320,960ZM480,960Q463,960 451.5,948.5Q440,937 440,920Q440,903 451.5,891.5Q463,880 480,880Q497,880 508.5,891.5Q520,903 520,920Q520,937 508.5,948.5Q497,960 480,960ZM640,960Q623,960 611.5,948.5Q600,937 600,920Q600,903 611.5,891.5Q623,880 640,880Q657,880 668.5,891.5Q680,903 680,920Q680,937 668.5,948.5Q657,960 640,960ZM480,560Q430,560 395,525Q360,490 360,440L360,200Q360,150 395,115Q430,80 480,80Q530,80 565,115Q600,150 600,200L600,440Q600,490 565,525Q530,560 480,560ZM440,840L440,716Q336,702 268,623.5Q200,545 200,440L280,440Q280,523 338.5,581.5Q397,640 480,640Q563,640 621.5,581.5Q680,523 680,440L760,440Q760,545 692,623.5Q624,702 520,716L520,840L440,840Z"/>
|
||||||
|
</vector>
|
||||||
Reference in New Issue
Block a user