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