android: add more compatibility information, fix FOSS billing, hide upgrade button before first AACP connect

closes #538
This commit is contained in:
Kavish Devar
2026-04-24 18:03:01 +05:30
parent 072b9b4dac
commit 0b578d62cf
31 changed files with 1507 additions and 784 deletions

View File

@@ -28,7 +28,7 @@ android {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 33 minSdk = 33
targetSdk = 37 targetSdk = 37
versionCode = 34 versionCode = 36
versionName = "0.2.3" versionName = "0.2.3"
} }
buildTypes { buildTypes {
@@ -50,14 +50,17 @@ android {
debug { debug {
buildConfigField("Boolean", "PLAY_BUILD", "false") buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
versionNameSuffix = "-debug"
} }
create("playRelease") { create("playRelease") {
initWith(getByName("release")) initWith(getByName("release"))
buildConfigField("Boolean", "PLAY_BUILD", "true") buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-play"
} }
create("playDebug") { create("playDebug") {
initWith(getByName("debug")) initWith(getByName("debug"))
buildConfigField("Boolean", "PLAY_BUILD", "true") buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-youshouldnothavethis"
} }
} }
compileOptions { compileOptions {
@@ -104,7 +107,6 @@ android {
arguments += "-DIS_XPOSED=ON" arguments += "-DIS_XPOSED=ON"
} }
} }
versionNameSuffix = "-xposed"
} }
} }
} }
@@ -113,6 +115,7 @@ dependencies {
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui) implementation(libs.androidx.ui)

View File

@@ -14,9 +14,18 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- <uses-permission--> <uses-permission
<!-- android:name="android.permission.BLUETOOTH_PRIVILEGED"--> android:name="android.permission.BLUETOOTH_PRIVILEGED"
<!-- tools:ignore="ProtectedPermissions" />--> tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.MODIFY_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission <uses-permission
android:name="android.permission.BLUETOOTH_SCAN" android:name="android.permission.BLUETOOTH_SCAN"
@@ -27,8 +36,6 @@
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" /> <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- <uses-permission android:name="android.permission.INTERNET" />--> <!-- <uses-permission android:name="android.permission.INTERNET" />-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"--> <!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
<!-- android:maxSdkVersion="30" />--> <!-- android:maxSdkVersion="30" />-->
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"--> <!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->

View File

@@ -52,7 +52,6 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -64,7 +63,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -81,8 +79,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -94,8 +90,6 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -122,12 +116,11 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.PlayBypassSheet
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
@@ -148,6 +141,7 @@ import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.presentation.screens.VersionScreen import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
@@ -175,7 +169,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
_root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme { LibrePodsTheme {
Main() Main()
} }
} }
@@ -224,81 +218,72 @@ fun Main() {
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences)) { if (!isSupported(sharedPreferences)) {
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
val showPlayBypassVisible = remember { mutableStateOf(false) }
val hazeState = rememberHazeState() val hazeState = rememberHazeState()
val backdrop = rememberLayerBackdrop()
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.hazeSource(hazeState) .hazeSource(hazeState)
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)), .layerBackdrop(backdrop)
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Box (
modifier = Modifier
.fillMaxSize()
)
Column ( Column (
verticalArrangement = Arrangement.spacedBy(8.dp) modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement
.spacedBy(16.dp)
) { ) {
Text( val innerBackdrop = rememberLayerBackdrop()
text = stringResource(R.string.not_supported),
style = TextStyle( Column(
fontFamily = FontFamily(Font(R.font.sf_pro)), modifier = Modifier.layerBackdrop(innerBackdrop),
fontWeight = FontWeight.SemiBold, verticalArrangement = Arrangement
color = if (isSystemInDarkTheme()) Color.White else Color.Black, .spacedBy(16.dp)
fontSize = 20.sp
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Row (
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = "Device Info:", text = stringResource(R.string.not_supported),
style = TextStyle( style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
color = if (isSystemInDarkTheme()) Color.White else Color.Black, color = textColor,
fontSize = 16.sp fontSize = 20.sp,
textAlign = TextAlign.Center
), ),
textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
"MANUFACTURER=${Build.MANUFACTURER}\n" +
"MODEL=${Build.MODEL}\n" +
"BUILD_ID=${Build.ID}\n" +
"SDK_INT_FULL= ${Build.VERSION.SDK_INT_FULL}\n",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.hack)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
textAlign = TextAlign.Start,
) )
DeviceInfoCard()
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
) {
Text(
text = stringResource(R.string.check_the_repository_for_more_info),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 16.dp)
)
}
} }
Text(
text = stringResource(R.string.check_the_repository_for_more_info),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 18.sp
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
StyledButton( StyledButton(
onClick = { showDialog.value = true }, onClick = { showDialog.value = true },
backdrop = rememberLayerBackdrop(), backdrop = innerBackdrop,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp)
) { ) {
Text( Text(
text = stringResource(R.string.bypass_compatibility_check), text = stringResource(R.string.bypass_compatibility_check),
@@ -317,15 +302,19 @@ fun Main() {
showDialog = showDialog, showDialog = showDialog,
title = stringResource(R.string.bypass_compatibility_check), title = stringResource(R.string.bypass_compatibility_check),
message = stringResource(R.string.bypass_compatiblity_check_confirmation), message = stringResource(R.string.bypass_compatiblity_check_confirmation),
confirmText = "Yes", confirmText = stringResource(R.string.yes),
dismissText = "No", dismissText = stringResource(R.string.no),
onConfirm = { onConfirm = {
showDialog.value = false showDialog.value = false
sharedPreferences.edit { if (BuildConfig.PLAY_BUILD) {
putBoolean("bypass_device_check", true) showPlayBypassVisible.value = true
val intent = Intent(context, MainActivity::class.java) } else {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) sharedPreferences.edit {
context.startActivity(intent) putBoolean("bypass_device_check", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
} }
}, },
onDismiss = { onDismiss = {
@@ -334,6 +323,26 @@ fun Main() {
hazeState = hazeState hazeState = hazeState
) )
if (BuildConfig.PLAY_BUILD) {
PlayBypassSheet(
visible = showPlayBypassVisible.value,
onDismiss = {
showPlayBypassVisible.value = false
showDialog.value = true
},
onConfirm = {
showPlayBypassVisible.value = false
sharedPreferences.edit {
putBoolean("bypass_device_check", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
backdrop = backdrop
)
}
return return
} }
@@ -347,8 +356,6 @@ fun Main() {
) )
} }
BillingManager.provider = BillingProviderFactory.create(context)
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf( listOf(
"android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_CONNECT",
@@ -484,7 +491,7 @@ fun Main() {
if (airPodsViewModel != null) VersionScreen(airPodsViewModel) if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
} }
composable("hearing_protection") { composable("hearing_protection") {
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel) if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
} }
composable("purchase_screen") { composable("purchase_screen") {
val purchaseViewModel: PurchaseViewModel = viewModel() val purchaseViewModel: PurchaseViewModel = viewModel()

View File

@@ -26,4 +26,5 @@ interface BillingProvider {
val price: StateFlow<String> val price: StateFlow<String>
fun purchase(activity: Activity) fun purchase(activity: Activity)
fun queryPurchases() fun queryPurchases()
fun restorePurchases()
} }

View File

@@ -31,13 +31,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import me.kavishdevar.librepods.R
class FOSSBillingProvider(context: Context): BillingProvider { class FOSSBillingProvider(context: Context): BillingProvider {
private val _isPremium = MutableStateFlow(false) private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("Any") private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
override val price: StateFlow<String> = _price override val price: StateFlow<String> = _price
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -69,4 +69,9 @@ class FOSSBillingProvider(context: Context): BillingProvider {
_isPremium.value = stored _isPremium.value = stored
} }
} }
override fun restorePurchases() {
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
} }

View File

@@ -162,21 +162,19 @@ class PlayBillingProvider(
it.purchaseState == Purchase.PurchaseState.PURCHASED it.purchaseState == Purchase.PurchaseState.PURCHASED
} }
// val purchase = purchases.find {
// val navigateToPurchase = purchases.find {
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED // it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
// } // }
// //
// if (navigateToPurchase != null) { // if (purchase != null) {
// val consumeParams = ConsumeParams.newBuilder() // val consumeParams = ConsumeParams.newBuilder()
// .setPurchaseToken(navigateToPurchase.purchaseToken) // .setPurchaseToken(purchase.purchaseToken)
// .build() // .build()
// scope.launch { // scope.launch {
// billingClient.consumeAsync(consumeParams) { _, _ ->} // billingClient.consumeAsync(consumeParams) { _, _ ->}
// } // }
// } // }
_isPremium.value = hasPremium _isPremium.value = hasPremium
scope.launch { scope.launch {
@@ -201,4 +199,8 @@ class PlayBillingProvider(
queryExistingPurchases() queryExistingPurchases()
} }
} }
override fun restorePurchases() {
queryPurchases()
}
} }

View File

@@ -363,7 +363,13 @@ class AACPManager {
} }
val key = ByteArray(keyLength) val key = ByteArray(keyLength)
System.arraycopy(data, offset, key, 0, keyLength) System.arraycopy(data, offset, key, 0, keyLength)
keys[ProximityKeyType.fromByte(keyType)] = key try {
keys[ProximityKeyType.fromByte(keyType)] = key
} catch (e: Exception) {
Log.e(
TAG, "incorrect key type received: $keyType, ${key.toHexString()}"
)
}
offset += keyLength offset += keyLength
Log.d( Log.d(
TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
@@ -908,7 +914,7 @@ class AACPManager {
) )
buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
buffer.put("PlayingApp".toByteArray()) buffer.put("PlayingApp".toByteArray())
buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator
buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
buffer.put(byteArrayOf(0x52)) // 'R' buffer.put(byteArrayOf(0x52)) // 'R'
buffer.put("HostStreamingState".toByteArray()) buffer.put("HostStreamingState".toByteArray())

View File

@@ -72,6 +72,7 @@ enum class NoiseControlMode {
class AirPodsNotifications { class AirPodsNotifications {
companion object { companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED" const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA" const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA" const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA" const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"

View File

@@ -0,0 +1,171 @@
package me.kavishdevar.librepods.presentation.components
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
@Composable
fun DeviceInfoCard() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column (
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.device_info), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(start = 16.dp)
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.manufacturer), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.MANUFACTURER, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_number), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.MODEL, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.build_id), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.DISPLAY, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}

View File

@@ -0,0 +1,254 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlayBypassSheet(
visible: Boolean,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
backdrop: LayerBackdrop
) {
if (!visible) return
val dark = isSystemInDarkTheme()
val contentColor = if (dark) Color.White else Color.Black
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var acknowledged by remember { mutableStateOf(false) }
val inputState = rememberTextFieldState("")
val isValid = acknowledged && inputState.text.trim() == "OK"
val sfPro = FontFamily(Font(R.font.sf_pro))
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = Color.Transparent,
dragHandle = { },
shape = RoundedCornerShape(48.dp),
scrimColor = Color.Transparent,
modifier = Modifier.padding(16.dp)
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(48.dp))
.drawBackdrop(
backdrop = backdrop,
exportedBackdrop = innerBackdrop,
shape = { RoundedCornerShape(48.dp) },
effects = {
vibrancy()
blur(6f.dp.toPx())
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
},
onDrawSurface = {
drawRect(
if (dark) Color.DarkGray.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.6f)
)
}
)
.padding(24.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.bypass_compatibility_check),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
Text(
text = stringResource(R.string.compatibility_play_dialog_confirmation),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledSelectList(
items = listOf(
SelectItem(
name = stringResource(R.string.read_compatibility_requirements),
selected = acknowledged,
onClick = { acknowledged = !acknowledged }
)
)
)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
val backgroundColor = if (dark) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (dark) Color.White else Color.Black
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(58.dp)
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
BasicTextField(
state = inputState,
textStyle = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
cursorBrush = SolidColor(textColor),
decorator = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
) {
Box {
if (inputState.text == "") {
Text(
text = stringResource(R.string.type_ok_to_continue, "OK"),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
fontFamily = sfPro,
color = textColor.copy(alpha = 0.8f)
)
)
}
innerTextField()
}
}
IconButton(
onClick = {
inputState.clearText()
}
) {
Text(
text = "􀁡",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (dark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
),
)
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.focusRequester(focusRequester)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
StyledButton(
onClick = onDismiss,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(R.string.no),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
)
)
}
StyledButton(
onClick = onConfirm,
backdrop = innerBackdrop,
isInteractive = isValid,
modifier = Modifier.weight(1f),
enabled = isValid,
surfaceColor = if (dark) Color(0xFF0091FF) else Color(0xFF0088FF)
) {
Text(
text = stringResource(R.string.proceed),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = if (isValid) contentColor else contentColor.copy(alpha = 0.4f)
)
)
}
}
}
}
}
}

View File

@@ -77,8 +77,10 @@ fun StyledButton(
tint: Color = Color.Unspecified, tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified,
maxScale: Float = 0.1f, maxScale: Float = 0.1f,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val isInteractive = enabled && isInteractive
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
@@ -125,8 +127,8 @@ half4 main(float2 coord) {
} else { } else {
drawRect(Color.White.copy(0.1f)) drawRect(Color.White.copy(0.1f))
} }
if (surfaceColor.isSpecified) { if (surfaceColor.isSpecified && enabled) {
val color = if (!isInteractive && isPressed) { val color = if (isPressed) {
Color( Color(
red = surfaceColor.red * 0.5f, red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f, green = surfaceColor.green * 0.5f,
@@ -137,6 +139,11 @@ half4 main(float2 coord) {
surfaceColor surfaceColor
} }
drawRect(color) drawRect(color)
} else {
if (isPressed) {
drawRect(Color.Black.copy(alpha = 0.4f))
drawRect(Color.White.copy(alpha = 0.2f))
}
} }
}, },
onDrawFront = null, onDrawFront = null,
@@ -245,8 +252,10 @@ half4 main(float2 coord) {
indication = null, indication = null,
role = Role.Button, role = Role.Button,
onClick = { onClick = {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick) if (enabled) {
onClick() haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
} }
) )
.then( .then(
@@ -302,8 +311,10 @@ half4 main(float2 coord) {
isPressed = false isPressed = false
}, },
onTap = { onTap = {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick) if (enabled) {
onClick() haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
} }
) )
} }

View File

@@ -39,7 +39,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -74,7 +73,6 @@ fun StyledSelectList(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
Column( Column(

View File

@@ -20,6 +20,7 @@ package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder // import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -39,6 +40,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
@@ -71,6 +73,10 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
@@ -85,8 +91,8 @@ import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
//private var phoneMediaDebounceJob: Job? = null private var phoneMediaDebounceJob: Job? = null
//private var toneVolumeDebounceJob: Job? = null private var toneVolumeDebounceJob: Job? = null
//private const val TAG = "AccessibilitySettings" //private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@@ -99,7 +105,13 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val hearingAidEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(1)?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0)?.toInt() == 1 val hearingAidEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
1
)
?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
0
)?.toInt() == 1
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
@@ -125,7 +137,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
@@ -149,7 +161,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(0) val selectedPressSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
0
)
var selectedPressSpeed by remember { var selectedPressSpeed by remember {
mutableStateOf( mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
@@ -162,7 +177,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressAndHoldDurationValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(0) val selectedPressAndHoldDurationValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
0
)
var selectedPressAndHoldDuration by remember { var selectedPressAndHoldDuration by remember {
mutableStateOf( mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
@@ -175,7 +193,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
2.toByte() to stringResource(R.string.longer), 2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest) 3.toByte() to stringResource(R.string.longest)
) )
val selectedVolumeSwipeSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(0) val selectedVolumeSwipeSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
0
)
var selectedVolumeSwipeSpeed by remember { var selectedVolumeSwipeSpeed by remember {
mutableStateOf( mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
@@ -183,43 +204,42 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
) )
} }
// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// phoneMediaDebounceJob?.cancel() val phoneEQEnabled = remember { mutableStateOf(false) }
// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { val mediaEQEnabled = remember { mutableStateOf(false) }
// delay(150)
// val manager = ServiceManager.getService()?.aacpManager LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
// if (manager == null) { phoneMediaDebounceJob?.cancel()
// Log.w(TAG, "Cannot write EQ: AACPManager not available") phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
// return@launch delay(150)
// } try {
// try { val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
// val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() Log.d(
// Log.d( "AccessibilitySettingsScreen",
// TAG, "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
// "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" )
// ) viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
// manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) } catch (e: Exception) {
// } catch (e: Exception) { Log.w(
// Log.w(TAG, "Error sending phone/media EQ: ${e.message}") "AccessibilitySettingsScreen",
// } "Error sending phone/media EQ: ${e.message}"
// } )
// } }
Box ( }
}
Box(
modifier = Modifier.then( modifier = Modifier.then(
if (!state.isPremium) { if (!state.isPremium) {
Modifier Modifier.pointerInput(Unit) {
.pointerInput(Unit) { awaitPointerEventScope {
awaitPointerEventScope { while (true) {
while (true) { val event = awaitPointerEvent(PointerEventPass.Initial)
val event = awaitPointerEvent(PointerEventPass.Initial) event.changes.forEach { it.consume() }
event.changes.forEach { it.consume() } }
} }
} }
} } else Modifier)) {
} else Modifier
)
) {
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.press_speed), label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description), description = stringResource(R.string.press_speed_description),
@@ -239,21 +259,18 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
) )
} }
Box ( Box(
modifier = Modifier.then( modifier = Modifier.then(
if (!state.isPremium) { if (!state.isPremium) {
Modifier Modifier.pointerInput(Unit) {
.pointerInput(Unit) { awaitPointerEventScope {
awaitPointerEventScope { while (true) {
while (true) { val event = awaitPointerEvent(PointerEventPass.Initial)
val event = awaitPointerEvent(PointerEventPass.Initial) event.changes.forEach { it.consume() }
event.changes.forEach { it.consume() } }
} }
} }
} } else Modifier)) {
} else Modifier
)
) {
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration), label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description), description = stringResource(R.string.press_and_hold_duration_description),
@@ -278,8 +295,14 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
label = stringResource(R.string.noise_cancellation_single_airpod), label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description), description = stringResource(R.string.noise_cancellation_single_airpod_description),
independent = true, independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) == 0x01.toByte(), checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) }, 0
) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
)
},
enabled = state.isPremium enabled = state.isPremium
) )
@@ -288,7 +311,12 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled, checked = state.loudSoundReductionEnabled,
onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) }, onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
)
},
enabled = state.isPremium enabled = state.isPremium
) )
} }
@@ -302,13 +330,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
) )
} }
val toneVolumeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f val toneVolumeValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
0
)?.toFloat() ?: 75f
StyledSlider( StyledSlider(
label = stringResource(R.string.tone_volume), label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description), description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue, value = toneVolumeValue,
onValueChange = { onValueChange = {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50)) viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50)
)
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
snapPoints = listOf(75f), snapPoints = listOf(75f),
@@ -319,30 +353,34 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
) )
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(0)?.toInt() == 0x01 val volumeSwipeEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
0
)?.toInt() == 0x01
StyledToggle( StyledToggle(
label = stringResource(R.string.volume_control), label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description), description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled, checked = volumeSwipeEnabled,
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) }, onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
)
},
enabled = state.isPremium enabled = state.isPremium
) )
Box ( Box(
modifier = Modifier.then( modifier = Modifier.then(
if (!state.isPremium) { if (!state.isPremium) {
Modifier Modifier.pointerInput(Unit) {
.pointerInput(Unit) { awaitPointerEventScope {
awaitPointerEventScope { while (true) {
while (true) { val event = awaitPointerEvent(PointerEventPass.Initial)
val event = awaitPointerEvent(PointerEventPass.Initial) event.changes.forEach { it.consume() }
event.changes.forEach { it.consume() } }
} }
} }
} } else Modifier)) {
} else Modifier
)
) {
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed), label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description), description = stringResource(R.string.volume_swipe_speed_description),
@@ -364,21 +402,22 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
} }
} }
// if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") { // if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") {
// Text( // Text(
// text = stringResource(R.string.apply_eq_to), // text = stringResource(R.string.apply_eq_to), style = TextStyle(
// style = TextStyle(
// fontSize = 14.sp, // fontSize = 14.sp,
// fontWeight = FontWeight.Bold, // fontWeight = FontWeight.Bold,
// color = textColor.copy(alpha = 0.6f), // color = textColor.copy(alpha = 0.6f),
// fontFamily = FontFamily(Font(R.font.sf_pro)) // fontFamily = FontFamily(Font(R.font.sf_pro))
// ), // ), modifier = Modifier.padding(8.dp, bottom = 0.dp)
// modifier = Modifier.padding(8.dp, bottom = 0.dp)
// ) // )
// Column( // Column(
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth() // .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp)) // .background(
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// RoundedCornerShape(28.dp)
// )
// .padding(vertical = 0.dp) // .padding(vertical = 0.dp)
// ) { // ) {
// val darkModeLocal = isSystemInDarkTheme() // val darkModeLocal = isSystemInDarkTheme()
@@ -405,17 +444,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// detectTapGestures( // detectTapGestures(
// onPress = { // onPress = {
// phoneBackgroundColor = // phoneBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) // if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// tryAwaitRelease() // tryAwaitRelease()
// phoneBackgroundColor = // phoneBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) // if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// 0xFFFFFFFF
// )
// phoneEQEnabled.value = !phoneEQEnabled.value // phoneEQEnabled.value = !phoneEQEnabled.value
// } // })
// )
// } // }
// .padding(horizontal = 16.dp), // .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically // verticalAlignment = Alignment.CenterVertically) {
// ) {
// Text( // Text(
// stringResource(R.string.phone), // stringResource(R.string.phone),
// fontSize = 16.sp, // fontSize = 16.sp,
@@ -441,8 +482,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// } // }
// //
// HorizontalDivider( // HorizontalDivider(
// thickness = 1.dp, // thickness = 1.dp, color = Color(0x40888888)
// color = Color(0x40888888)
// ) // )
// //
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) // val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
@@ -467,17 +507,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// detectTapGestures( // detectTapGestures(
// onPress = { // onPress = {
// mediaBackgroundColor = // mediaBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) // if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// tryAwaitRelease() // tryAwaitRelease()
// mediaBackgroundColor = // mediaBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) // if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// 0xFFFFFFFF
// )
// mediaEQEnabled.value = !mediaEQEnabled.value // mediaEQEnabled.value = !mediaEQEnabled.value
// } // })
// )
// } // }
// .padding(horizontal = 16.dp), // .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically // verticalAlignment = Alignment.CenterVertically) {
// ) {
// Text( // Text(
// stringResource(R.string.media), // stringResource(R.string.media),
// fontSize = 16.sp, // fontSize = 16.sp,
@@ -502,90 +544,97 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// ) // )
// } // }
// } // }
//
// EQ Settings. Don't seem to have an effect? //// EQ Settings. Don't seem to have an effect?
// Column( // Column(
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth() // .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp)) // .background(
// .padding(12.dp), // if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// horizontalAlignment = Alignment.CenterHorizontally // RoundedCornerShape(28.dp)
// ) { // )
// for (i in 0 until 8) { // .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally
// val eqPhoneValue = // ) {
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } // val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
// Row( // val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
// horizontalArrangement = Arrangement.SpaceBetween, // val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
// verticalAlignment = Alignment.CenterVertically, //
// modifier = Modifier // for (i in 0 until 8) {
// .fillMaxWidth() // val eqPhoneValue =
// .height(38.dp) // remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
// ) { // Row(
// Text( // horizontalArrangement = Arrangement.SpaceBetween,
// text = String.format("%.2f", eqPhoneValue.floatValue), // verticalAlignment = Alignment.CenterVertically,
// fontSize = 12.sp, // modifier = Modifier
// color = textColor, // .fillMaxWidth()
// modifier = Modifier.padding(bottom = 4.dp) // .height(38.dp)
// ) // ) {
// Text(
// Slider( // text = String.format("%.2f", eqPhoneValue.floatValue),
// value = eqPhoneValue.floatValue, // fontSize = 12.sp,
// onValueChange = { newVal -> // color = textColor,
// eqPhoneValue.floatValue = newVal // modifier = Modifier.padding(bottom = 4.dp)
// val newEQ = phoneMediaEQ.value.copyOf() // )
// newEQ[i] = eqPhoneValue.floatValue //
// phoneMediaEQ.value = newEQ // Slider(
// }, // value = eqPhoneValue.floatValue,
// valueRange = 0f..100f, // onValueChange = { newVal ->
// modifier = Modifier // eqPhoneValue.floatValue = newVal
// .fillMaxWidth(0.9f) // val newEQ = phoneMediaEQ.value.copyOf()
// .height(36.dp), // newEQ[i] = eqPhoneValue.floatValue
// colors = SliderDefaults.colors( // phoneMediaEQ.value = newEQ
// thumbColor = thumbColor, // },
// activeTrackColor = activeTrackColor, // valueRange = 0f..100f,
// inactiveTrackColor = trackColor // modifier = Modifier
// ), // .fillMaxWidth(0.9f)
// thumb = { // .height(36.dp),
// Box( // colors = SliderDefaults.colors(
// modifier = Modifier // thumbColor = thumbColor,
// .size(24.dp) // activeTrackColor = activeTrackColor,
// .shadow(4.dp, CircleShape) // inactiveTrackColor = trackColor
// .background(thumbColor, CircleShape) // ),
// ) // thumb = {
// }, // Box(
// track = { // modifier = Modifier
// Box( // .size(24.dp)
// modifier = Modifier // .shadow(4.dp, CircleShape)
// .fillMaxWidth() // .background(thumbColor, CircleShape)
// .height(12.dp), // )
// contentAlignment = Alignment.CenterStart // },
// ) // track = {
// { // Box(
// Box( // modifier = Modifier
// modifier = Modifier // .fillMaxWidth()
// .fillMaxWidth() // .height(12.dp),
// .height(4.dp) // contentAlignment = Alignment.CenterStart
// .background(trackColor, RoundedCornerShape(4.dp)) // ) {
// ) // Box(
// Box( // modifier = Modifier
// modifier = Modifier // .fillMaxWidth()
// .fillMaxWidth(eqPhoneValue.floatValue / 100f) // .height(4.dp)
// .height(4.dp) // .background(trackColor, RoundedCornerShape(4.dp))
// .background(activeTrackColor, RoundedCornerShape(4.dp)) // )
// ) // Box(
// } // modifier = Modifier
// } // .fillMaxWidth(eqPhoneValue.floatValue / 100f)
// ) // .height(4.dp)
// .background(
// Text( // activeTrackColor, RoundedCornerShape(4.dp)
// text = stringResource(R.string.band_label, i + 1), // )
// fontSize = 12.sp, // )
// color = textColor, // }
// modifier = Modifier.padding(top = 4.dp) // })
// ) //
// } // Text(
// } // text = stringResource(R.string.band_label, i + 1),
// } // fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(top = 4.dp)
// )
// }
// }
// }
// }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -616,7 +665,7 @@ private fun DropdownMenuComponent(
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxWidth()){ Column(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -630,14 +679,14 @@ private fun DropdownMenuComponent(
} else Modifier } else Modifier
) )
.background( .background(
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent, if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(
0xFFFFFFFF
)) else Color.Transparent,
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp) if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
) ) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip(
then( if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
if (independent) Modifier.padding(horizontal = 4.dp) else Modifier )
) ) {
.clip(if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp))
){
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -658,98 +707,94 @@ private fun DropdownMenuComponent(
} }
} }
.pointerInput(Unit) { .pointerInput(Unit) {
detectDragGesturesAfterLongPress( detectDragGesturesAfterLongPress(onDragStart = { offset ->
onDragStart = { offset -> val now = System.currentTimeMillis()
val now = System.currentTimeMillis() touchOffset = offset
touchOffset = offset if (!expanded && now - lastDismissTime > 250L) {
if (!expanded && now - lastDismissTime > 250L) { expanded = true
expanded = true
}
lastDismissTime = now
parentDragActive = true
parentHoveredIndex = 0
},
onDrag = { change, _ ->
val current = change.position
val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndex = idx
previousIdx = idx
},
onDragEnd = {
parentDragActive = false
parentHoveredIndex?.let { idx ->
if (idx in options.indices) {
onOptionSelected(options[idx])
expanded = false
lastDismissTime = System.currentTimeMillis()
}
}
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndex = null
},
onDragCancel = {
parentDragActive = false
parentHoveredIndex = null
} }
) lastDismissTime = now
parentDragActive = true
parentHoveredIndex = 0
}, onDrag = { change, _ ->
val current = change.position
val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick
)
}
}
parentHoveredIndex = idx
previousIdx = idx
}, onDragEnd = {
parentDragActive = false
parentHoveredIndex?.let { idx ->
if (idx in options.indices) {
onOptionSelected(options[idx])
expanded = false
lastDismissTime = System.currentTimeMillis()
}
}
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd
)
}
}
parentHoveredIndex = null
}, onDragCancel = {
parentDragActive = false
parentHoveredIndex = null
})
}, },
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column( Column(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
){ ) {
Text( Text(
text = label, text = label,
fontSize = 16.sp, fontSize = 16.sp,
color = textColor, color = textColor,
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(bottom = 4.dp)
) )
if (!independent && description != null){ if (!independent && description != null) {
Text( Text(
text = description, text = description, style = TextStyle(
style = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ), modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
) )
} }
} }
Box( Box(
modifier = Modifier.onGloballyPositioned { coordinates -> modifier = Modifier.onGloballyPositioned { coordinates ->
boxPosition = coordinates.positionInParent() boxPosition = coordinates.positionInParent()
} }) {
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = selectedOption, text = selectedOption, style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f), color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
Text( Text(
text = "􀆏", text = "􀆏", style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ), modifier = Modifier.padding(start = 6.dp)
modifier = Modifier
.padding(start = 6.dp)
) )
} }
@@ -774,19 +819,22 @@ private fun DropdownMenuComponent(
} }
} }
} }
if (independent && description != null){ if (independent && description != null) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(
){ if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)
)
) {
Text( Text(
text = description, text = description, style = TextStyle(
style = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
alpha = 0.6f
),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )

View File

@@ -81,7 +81,7 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavContro
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),

View File

@@ -239,13 +239,11 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") { item(key = "call_control") {
val flipped = val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take( val flipped = bytes[1] == 0x02.toByte()
2
)?.equals(byteArrayOf(0x00.toByte(), 0x02.toByte()))
CallControlSettings( CallControlSettings(
hazeState = hazeState, hazeState = hazeState,
flipped = flipped == true, flipped = flipped,
onCallControlValueChanged = { onCallControlValueChanged = {
viewModel.setControlCommandValue( viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG, AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
@@ -277,7 +275,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color( surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
0xFFE59900 0xFFE59900
) )
) { ) {

View File

@@ -19,6 +19,7 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -71,13 +72,13 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.NavigationButton import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import java.util.Locale.getDefault
@Composable @Composable
fun AppSettingsScreen( fun AppSettingsScreen(
@@ -106,7 +107,7 @@ fun AppSettingsScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
if (!state.isPremium) { if (!state.isPremium && state.connectionSuccessful) {
StyledButton( StyledButton(
onClick = { onClick = {
navController.navigate("purchase_screen") navController.navigate("purchase_screen")
@@ -114,7 +115,7 @@ fun AppSettingsScreen(
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
@@ -128,246 +129,270 @@ fun AppSettingsScreen(
} }
} }
StyledToggle( if (state.connectionSuccessful) {
title = stringResource(R.string.widget),
label = stringResource(R.string.show_phone_battery_in_widget),
description = stringResource(R.string.show_phone_battery_in_widget_description),
checked = state.showPhoneBatteryInWidget,
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
enabled = state.isPremium
)
Text(
text = stringResource(R.string.conversational_awareness), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music), title = stringResource(R.string.widget),
description = stringResource(R.string.conversational_awareness_pause_music_description), label = stringResource(R.string.show_phone_battery_in_widget),
checked = state.conversationalAwarenessPauseMusicEnabled, description = stringResource(R.string.show_phone_battery_in_widget_description),
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled, checked = state.showPhoneBatteryInWidget,
independent = false, onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
enabled = state.isPremium enabled = state.isPremium
) )
HorizontalDivider( Text(
thickness = 1.dp, text = stringResource(R.string.conversational_awareness), style = TextStyle(
color = Color(0x40888888), fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 12.dp) fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
) )
StyledToggle( Spacer(modifier = Modifier.height(2.dp))
label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description),
checked = state.relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
independent = false,
enabled = state.isPremium,
)
}
Spacer(modifier = Modifier.height(16.dp)) Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description),
checked = state.conversationalAwarenessPauseMusicEnabled,
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
independent = false,
enabled = state.isPremium
)
val conversationalAwarenessVolume = state.conversationalAwarenessVolume HorizontalDivider(
LaunchedEffect(conversationalAwarenessVolume) { thickness = 1.dp,
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume) color = Color(0x40888888),
} modifier = Modifier.padding(horizontal = 12.dp)
)
StyledSlider( StyledToggle(
label = stringResource(R.string.conversational_awareness_volume), label = stringResource(R.string.relative_conversational_awareness_volume),
value = conversationalAwarenessVolume, description = stringResource(R.string.relative_conversational_awareness_volume_description),
valueRange = 10f..85f, checked = state.relativeConversationalAwarenessVolumeEnabled,
snapPoints = listOf(44f), onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
startLabel = "10%", independent = false,
endLabel = "85%", enabled = state.isPremium,
onValueChange = { newValue -> viewModel.setConversationalAwarenessVolume(newValue) }, )
independent = true, }
enabled = state.isPremium
)
if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton( val conversationalAwarenessVolume = state.conversationalAwarenessVolume
to = "", LaunchedEffect(conversationalAwarenessVolume) {
title = stringResource(R.string.camera_control), viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
name = stringResource(R.string.set_custom_camera_package), }
navController = navController,
onClick = { StyledSlider(
if (state.isPremium) viewModel.setShowCameraDialog(true) label = stringResource(R.string.conversational_awareness_volume),
value = conversationalAwarenessVolume,
valueRange = 10f..85f,
snapPoints = listOf(44f),
startLabel = "10%",
endLabel = "85%",
onValueChange = { newValue ->
viewModel.setConversationalAwarenessVolume(
newValue
)
}, },
independent = true, independent = true,
description = stringResource(R.string.camera_control_app_description)
)
}
Spacer(modifier = Modifier.height(16.dp))
if (!BuildConfig.PLAY_BUILD) {
StyledToggle(
title = stringResource(R.string.ear_detection),
label = stringResource(R.string.disconnect_when_not_wearing),
description = stringResource(R.string.disconnect_when_not_wearing_description),
checked = state.disconnectWhenNotWearing,
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
enabled = state.isPremium enabled = state.isPremium
) )
}
Text( // if (!BuildConfig.PLAY_BUILD) {
text = stringResource(R.string.takeover_airpods_state), style = TextStyle( // Spacer(modifier = Modifier.height(16.dp))
fontSize = 14.sp, //
fontWeight = FontWeight.Bold, // NavigationButton(
color = textColor.copy(alpha = 0.6f), // to = "",
fontFamily = FontFamily(Font(R.font.sf_pro)) // title = stringResource(R.string.camera_control),
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) // name = stringResource(R.string.set_custom_camera_package),
) // navController = navController,
// onClick = {
// if (state.isPremium) viewModel.setShowCameraDialog(true)
// },
// independent = true,
// description = stringResource(R.string.camera_control_app_description)
// )
// }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(16.dp))
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
Column( StyledToggle(
modifier = Modifier title = stringResource(R.string.ear_detection),
.fillMaxWidth() label = stringResource(R.string.disconnect_when_not_wearing),
.background( description = stringResource(R.string.disconnect_when_not_wearing_description),
backgroundColor, RoundedCornerShape(28.dp) checked = state.disconnectWhenNotWearing,
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
enabled = state.isPremium
) )
.padding(vertical = 4.dp) }
) {
StyledToggle( Text(
label = stringResource(R.string.takeover_disconnected), text = stringResource(R.string.takeover_airpods_state), style = TextStyle(
description = stringResource(R.string.takeover_disconnected_desc), fontSize = 14.sp,
checked = state.takeoverWhenDisconnected, fontWeight = FontWeight.Bold,
onCheckedChange = viewModel::setTakeoverWhenDisconnected, color = textColor.copy(alpha = 0.6f),
independent = false, fontFamily = FontFamily(Font(R.font.sf_pro))
enabled = state.isPremium ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
) )
StyledToggle( Spacer(modifier = Modifier.height(4.dp))
label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc),
checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle( Column(
label = stringResource(R.string.takeover_music), modifier = Modifier
description = stringResource(R.string.takeover_music_desc), .fillMaxWidth()
checked = state.takeoverWhenMusic, .background(
onCheckedChange = viewModel::setTakeoverWhenMusic, backgroundColor, RoundedCornerShape(28.dp)
independent = false, )
enabled = state.isPremium .padding(vertical = 4.dp)
) ) {
HorizontalDivider( StyledToggle(
thickness = 1.dp, label = stringResource(R.string.takeover_disconnected),
color = Color(0x40888888), description = stringResource(R.string.takeover_disconnected_desc),
modifier = Modifier.padding(horizontal = 12.dp) checked = state.takeoverWhenDisconnected,
) onCheckedChange = viewModel::setTakeoverWhenDisconnected,
independent = false,
StyledToggle( enabled = state.isPremium
label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc),
checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall,
independent = false,
enabled = state.isPremium
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.takeover_phone_state), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
) )
.padding(vertical = 4.dp) HorizontalDivider(
) { thickness = 1.dp,
StyledToggle( color = Color(0x40888888),
label = stringResource(R.string.takeover_ringing_call), modifier = Modifier.padding(horizontal = 12.dp)
description = stringResource(R.string.takeover_ringing_call_desc), )
checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall, StyledToggle(
independent = false, label = stringResource(R.string.takeover_idle),
enabled = state.isPremium description = stringResource(R.string.takeover_idle_desc),
checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc),
checked = state.takeoverWhenMusic,
onCheckedChange = viewModel::setTakeoverWhenMusic,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc),
checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall,
independent = false,
enabled = state.isPremium
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.takeover_phone_state), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(horizontal = 16.dp)
) )
HorizontalDivider( Spacer(modifier = Modifier.height(4.dp))
thickness = 1.dp, Column(
color = Color(0x40888888), modifier = Modifier
modifier = Modifier.padding(horizontal = 12.dp) .fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc),
checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc),
checked = state.takeoverWhenMediaStart,
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
independent = false,
enabled = state.isPremium
)
}
Text(
text = stringResource(R.string.advanced_options), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
) )
Spacer(modifier = Modifier.height(2.dp))
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_media_start), label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.takeover_media_start_desc), description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = state.takeoverWhenMediaStart, checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setTakeoverWhenMediaStart, onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
independent = false, independent = true,
enabled = state.isPremium enabled = state.isPremium
) )
} else {
Text(
text = stringResource(R.string.customizations_unavailable),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
),
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
)
} }
Text(
text = stringResource(R.string.advanced_options), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
StyledToggle(
label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
independent = true,
enabled = state.isPremium
)
if (BuildConfig.FLAVOR == "xposed") { if (BuildConfig.FLAVOR == "xposed") {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth) val restartBluetoothText =
stringResource(R.string.found_offset_restart_bluetooth)
StyledToggle( StyledToggle(
label = stringResource(R.string.act_as_an_apple_device) + " (${stringResource(R.string.requires_xposed)})", label = stringResource(R.string.act_as_an_apple_device) + " (${
stringResource(
R.string.requires_xposed
)
})",
description = stringResource(R.string.act_as_an_apple_device_description), description = stringResource(R.string.act_as_an_apple_device_description),
checked = state.vendorIdHook, checked = state.vendorIdHook,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
@@ -379,8 +404,8 @@ fun AppSettingsScreen(
) )
} }
if (!BuildConfig.PLAY_BUILD) { if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp))
NavigationButton( NavigationButton(
to = "troubleshooting", to = "troubleshooting",
name = stringResource(R.string.troubleshooting), name = stringResource(R.string.troubleshooting),
@@ -418,15 +443,16 @@ fun AppSettingsScreen(
val intent = Intent(Intent.ACTION_SENDTO).apply { val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri() data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz")) putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ") putExtra(Intent.EXTRA_SUBJECT, "LibrePods: <SUBJECT>")
putExtra( putExtra(
Intent.EXTRA_TEXT, Intent.EXTRA_TEXT,
"\n\n\n----------" + "Describe your issue here:" +
"\n\n\n\n----------" +
"\nPhone details:" + "\nPhone details:" +
"\nDEVICE: ${Build.DEVICE}" + "\nMANUFACTURER: ${Build.MANUFACTURER}" +
"\nMANUFACTURER: ${Build.MANUFACTURER} (${Build.BRAND})" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" + "\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nVERSION: ${Build.DISPLAY} (${Build.VERSION.SDK_INT_FULL})" + "\nDISPLAY_VERSION: ${Build.DISPLAY} (${Build.PRODUCT})" +
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
"\n\nApp details:" + "\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" + "\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" + "\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
@@ -478,7 +504,8 @@ fun AppSettingsScreen(
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(20.dp))
DeviceInfoCard()
Text( Text(
text = stringResource(R.string.about), style = TextStyle( text = stringResource(R.string.about), style = TextStyle(
@@ -486,7 +513,7 @@ fun AppSettingsScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) ), modifier = Modifier.padding(start = 16.dp, bottom = 2.dp, top = 24.dp)
) )
val rowHeight = remember { mutableStateOf(0.dp) } val rowHeight = remember { mutableStateOf(0.dp) }

View File

@@ -175,7 +175,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),

View File

@@ -18,29 +18,39 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
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 androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel) { fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
StyledScaffold( StyledScaffold(
@@ -53,7 +63,27 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
if (state.vendorIdHook) { if (state.vendorIdHook) {
StyledToggle( StyledToggle(
title = stringResource(R.string.environmental_noise), title = stringResource(R.string.environmental_noise),

View File

@@ -130,7 +130,7 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),

View File

@@ -99,7 +99,7 @@ fun PurchaseScreen(
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
) { ) {
Text( Text(
text = "Free features", text = stringResource(R.string.free_features),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -242,7 +242,7 @@ fun PurchaseScreen(
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
) { ) {
Text( Text(
text = "Advanced features", text = stringResource(R.string.advanced_features),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -288,6 +288,36 @@ fun PurchaseScreen(
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
) )
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.digital_assistant_on_long_press),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.digital_assistant_on_long_press_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -456,7 +486,8 @@ fun PurchaseScreen(
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF)
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.buy_price, state.price), stringResource(R.string.buy_price, state.price),
@@ -478,6 +509,7 @@ fun PurchaseScreen(
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
isInteractive = false
) { ) {
Text( Text(
stringResource(R.string.restore_purchases), stringResource(R.string.restore_purchases),

View File

@@ -56,8 +56,6 @@ import androidx.compose.ui.text.input.TextFieldValue
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 androidx.core.content.edit import androidx.core.content.edit
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
@@ -79,15 +77,12 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
name.value = name.value.copy(selection = TextRange(name.value.text.length)) name.value = name.value.copy(selection = TextRange(name.value.text.length))
} }
val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.name), title = stringResource(R.string.name),
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))

View File

@@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
@@ -110,8 +109,6 @@ class AirPodsViewModel(
private var isDemoMode = false private var isDemoMode = false
val demoActivated = MutableSharedFlow<Unit>() val demoActivated = MutableSharedFlow<Unit>()
private var billingFirstCollectDone = false
private val listeners = private val listeners =
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>() mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
@@ -163,12 +160,12 @@ class AirPodsViewModel(
private fun observeBilling() { private fun observeBilling() {
if (isDemoMode) return if (isDemoMode) return
viewModelScope.launch { viewModelScope.launch {
if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events // if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
BillingManager.provider.isPremium.collect { premium -> BillingManager.provider.isPremium.collect { premium ->
if (!billingFirstCollectDone) { // if (!billingFirstCollectDone) {
billingFirstCollectDone = true // billingFirstCollectDone = true
return@collect // return@collect
} // }
if (!premium) { if (!premium) {
setControlCommandBoolean( setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
@@ -184,8 +181,9 @@ class AirPodsViewModel(
private fun observeBroadcasts() { private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() { broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (!isDemoMode) when (intent?.action) { val action = intent?.action ?: return
AirPodsNotifications.AIRPODS_CONNECTED -> { if (!isDemoMode) when (action) {
AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> {
_uiState.update { _uiState.update {
it.copy(isLocallyConnected = true) it.copy(isLocallyConnected = true)
} }
@@ -198,10 +196,8 @@ class AirPodsViewModel(
} }
AirPodsNotifications.BATTERY_DATA -> { AirPodsNotifications.BATTERY_DATA -> {
val data = intent.getParcelableArrayListExtra("data", Battery::class.java)
?.toList() ?: emptyList()
_uiState.update { _uiState.update {
it.copy(battery = data) it.copy(battery = service.getBattery())
} }
} }
@@ -276,7 +272,7 @@ class AirPodsViewModel(
} }
} }
listeners[identifier] = listener as AACPManager.ControlCommandListener listeners[identifier] = listener
} }
// I'm lazy, sorry. // I'm lazy, sorry.
@@ -435,7 +431,15 @@ class AirPodsViewModel(
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) } _uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
service.attManager?.write(handle, value) try {
service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(250)
}
service.attManager?.write(handle, value)
} catch (e: Exception) {
e.printStackTrace()
}
} }
} }
@@ -458,13 +462,16 @@ class AirPodsViewModel(
fun observeATT() { fun observeATT() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
service.attManager?.connect() service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(1000)
}
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION) service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY) service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID) service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) { while (true) {
refreshATT() refreshATT()
delay(10000) delay(15000)
} }
} }
} }
@@ -489,10 +496,6 @@ class AirPodsViewModel(
} }
} }
// fun purchase(context: Context) {
// BillingManager.provider.purchase(context as Activity)
// }
fun activateDemoMode() { fun activateDemoMode() {
isDemoMode = true isDemoMode = true
viewModelScope.launch { viewModelScope.launch {
@@ -525,8 +528,17 @@ class AirPodsViewModel(
modelName = fakeInstance.model.displayName, modelName = fakeInstance.model.displayName,
actualModel = fakeInstance.actualModelNumber, actualModel = fakeInstance.actualModelNumber,
serialNumbers = listOf("DEMO", "DEMO", "DEMO"), serialNumbers = listOf("DEMO", "DEMO", "DEMO"),
version3 = "Demo Firmware" version3 = "Demo Firmware",
// isPremium = true
) )
} }
} }
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
}
fun disconnect() {
service.disconnectAirPods()
}
} }

View File

@@ -2,6 +2,7 @@ package me.kavishdevar.librepods.presentation.viewmodel
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -32,7 +33,8 @@ data class AppSettingsUiState(
val cameraPackageValue: String = "", val cameraPackageValue: String = "",
val cameraPackageError: String? = null, val cameraPackageError: String? = null,
val vendorIdHook: Boolean = false, val vendorIdHook: Boolean = false,
val isPremium: Boolean = false val isPremium: Boolean = false,
val connectionSuccessful: Boolean = false
) )
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) { class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -43,9 +45,22 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
private val xposedRemotePref = XposedRemotePrefProvider.create() private val xposedRemotePref = XposedRemotePrefProvider.create()
val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key ->
if (key == "connection_successful") {
_uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) }
}
}
init { init {
loadSettings() loadSettings()
observeBilling() observeBilling()
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener)
}
override fun onCleared() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
super.onCleared()
} }
private fun observeBilling() { private fun observeBilling() {
@@ -72,7 +87,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true), useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(), conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "", cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
) )
} }
if (BuildConfig.FLAVOR == "xposed") { if (BuildConfig.FLAVOR == "xposed") {

View File

@@ -42,6 +42,6 @@ class PurchaseViewModel(application: Application) : AndroidViewModel(application
} }
fun restorePurchases() { fun restorePurchases() {
BillingManager.provider.queryPurchases() BillingManager.provider.restorePurchases()
} }
} }

View File

@@ -16,7 +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/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:Suppress("DEPRECATION") @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.services package me.kavishdevar.librepods.services
@@ -58,7 +58,7 @@ import android.os.ParcelUuid
import android.os.UserHandle import android.os.UserHandle
import android.provider.Settings import android.provider.Settings
import android.telecom.TelecomManager import android.telecom.TelecomManager
import android.telephony.PhoneStateListener import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import android.util.Log import android.util.Log
import android.util.TypedValue import android.util.TypedValue
@@ -69,14 +69,7 @@ import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat.START_STICKY
import androidx.core.app.ServiceCompat.startForeground
import androidx.core.content.ContextCompat.RECEIVER_EXPORTED
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.content.ContextCompat.registerReceiver
import androidx.core.content.ContextCompat.startActivity
import androidx.core.content.edit import androidx.core.content.edit
import com.google.android.datatransport.runtime.scheduling.persistence.EventStoreModule_PackageNameFactory.packageName
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -135,7 +128,6 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.jvm.java
private const val TAG = "AirPodsService" private const val TAG = "AirPodsService"
@@ -230,7 +222,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow
private lateinit var telephonyManager: TelephonyManager private lateinit var telephonyManager: TelephonyManager
private lateinit var phoneStateListener: PhoneStateListener private lateinit var phoneStateListener: TelephonyCallback
private val maxLogEntries = 1000 private val maxLogEntries = 1000
private val inMemoryLogs = mutableSetOf<String>() private val inMemoryLogs = mutableSetOf<String>()
@@ -369,7 +361,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag", "HardwareIds")
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.i(TAG, "lib exempt worked: ${isBluetoothSocketExempted()}") Log.i(TAG, "lib exempt worked: ${isBluetoothSocketExempted()}")
@@ -391,7 +383,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
localMac = config.selfMacAddress localMac = config.selfMacAddress
if (localMac.isEmpty()) { if (localMac.isEmpty()) {
if (BuildConfig.FLAVOR == "xposed") { if (checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED) {
val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager.adapter
localMac = bluetoothAdapter.address
} else {
localMac = try { localMac = try {
val process = Runtime.getRuntime().exec( val process = Runtime.getRuntime().exec(
arrayOf("su", "-c", "settings get secure bluetooth_address") arrayOf("su", "-c", "settings get secure bluetooth_address")
@@ -602,10 +598,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
macAddress = sharedPreferences.getString("mac_address", "") ?: "" macAddress = sharedPreferences.getString("mac_address", "") ?: ""
telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
phoneStateListener = object : PhoneStateListener() { phoneStateListener = object: TelephonyCallback(), TelephonyCallback.CallStateListener {
@Deprecated("Deprecated in Java") override fun onCallStateChanged(state: Int) {
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
super.onCallStateChanged(state, phoneNumber)
when (state) { when (state) {
TelephonyManager.CALL_STATE_RINGING -> { TelephonyManager.CALL_STATE_RINGING -> {
val leAvailableForAudio = val leAvailableForAudio =
@@ -615,7 +609,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
takeOver("call") takeOver("call")
} }
if (config.headGestures) { if (config.headGestures) {
callNumber = phoneNumber
handleIncomingCall() handleIncomingCall()
} }
} }
@@ -634,13 +627,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
TelephonyManager.CALL_STATE_IDLE -> { TelephonyManager.CALL_STATE_IDLE -> {
isInCall = false isInCall = false
callNumber = null
gestureDetector?.stopDetection() gestureDetector?.stopDetection()
} }
} }
} }
} }
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE) if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
telephonyManager.registerTelephonyCallback(mainExecutor, phoneStateListener)
}
if (config.showPhoneBatteryInWidget) { if (config.showPhoneBatteryInWidget) {
widgetMobileBatteryEnabled = true widgetMobileBatteryEnabled = true
@@ -850,7 +844,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS) ) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS)
Log.d( Log.d(
TAG, TAG,
"Setting up stem actions: " + "Single Press Customized: $singlePressCustomized, " + "Double Press Customized: $doublePressCustomized, " + "Triple Press Customized: $triplePressCustomized, " + "Long Press Customized: $longPressCustomized" "Setting up stem actions: Single Press Customized: $singlePressCustomized, Double Press Customized: $doublePressCustomized, Triple Press Customized: $triplePressCustomized, Long Press Customized: $longPressCustomized"
) )
aacpManager.sendStemConfigPacket( aacpManager.sendStemConfigPacket(
singlePressCustomized, singlePressCustomized,
@@ -1070,6 +1064,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
version2 = config.airpodsVersion2, version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3, version3 = config.airpodsVersion3,
) )
if (device != null) setMetadatas(device!!)
} }
sendBroadcast( sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage( Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage(
@@ -1722,7 +1717,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val disconnectedNotificationChannel = NotificationChannel( val disconnectedNotificationChannel = NotificationChannel(
"background_service_status", "background_service_status",
"Background Service Status", "Background Service Status",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_NONE
) )
val connectedNotificationChannel = NotificationChannel( val connectedNotificationChannel = NotificationChannel(
@@ -1813,6 +1808,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
fun sendBatteryBroadcast() { fun sendBatteryBroadcast() {
broadcastBatteryInformation()
sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
setPackage(packageName) setPackage(packageName)
@@ -1829,47 +1825,51 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
fun setBatteryMetadata() { fun setBatteryMetadata() {
if (BuildConfig.FLAVOR != "xposed") return if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
device?.let { it -> device?.let { it ->
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
it, it,
it.METADATA_UNTETHERED_CASE_BATTERY, it.METADATA_UNTETHERED_CASE_BATTERY,
batteryNotification.getBattery() batteryNotification.getBattery()
.find { it.component == BatteryComponent.CASE }?.level.toString().toByteArray() .find { it.component == BatteryComponent.CASE }?.level.toString()
) .toByteArray()
SystemApisUtils.setMetadata( )
it, SystemApisUtils.setMetadata(
it.METADATA_UNTETHERED_CASE_CHARGING, it,
(if (batteryNotification.getBattery() it.METADATA_UNTETHERED_CASE_CHARGING,
.find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING (if (batteryNotification.getBattery()
) "1".toByteArray() else "0".toByteArray()) .find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING
) ) "1".toByteArray() else "0".toByteArray())
SystemApisUtils.setMetadata( )
it, SystemApisUtils.setMetadata(
it.METADATA_UNTETHERED_LEFT_BATTERY, it,
batteryNotification.getBattery() it.METADATA_UNTETHERED_LEFT_BATTERY,
.find { it.component == BatteryComponent.LEFT }?.level.toString().toByteArray() batteryNotification.getBattery()
) .find { it.component == BatteryComponent.LEFT }?.level.toString()
SystemApisUtils.setMetadata( .toByteArray()
it, )
it.METADATA_UNTETHERED_LEFT_CHARGING, SystemApisUtils.setMetadata(
(if (batteryNotification.getBattery() it,
.find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING it.METADATA_UNTETHERED_LEFT_CHARGING,
) "1".toByteArray() else "0".toByteArray()) (if (batteryNotification.getBattery()
) .find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING
SystemApisUtils.setMetadata( ) "1".toByteArray() else "0".toByteArray())
it, )
it.METADATA_UNTETHERED_RIGHT_BATTERY, SystemApisUtils.setMetadata(
batteryNotification.getBattery() it,
.find { it.component == BatteryComponent.RIGHT }?.level.toString().toByteArray() it.METADATA_UNTETHERED_RIGHT_BATTERY,
) batteryNotification.getBattery()
SystemApisUtils.setMetadata( .find { it.component == BatteryComponent.RIGHT }?.level.toString()
it, .toByteArray()
it.METADATA_UNTETHERED_RIGHT_CHARGING, )
(if (batteryNotification.getBattery() SystemApisUtils.setMetadata(
.find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING it,
) "1".toByteArray() else "0".toByteArray()) it.METADATA_UNTETHERED_RIGHT_CHARGING,
) (if (batteryNotification.getBattery()
.find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING
) "1".toByteArray() else "0".toByteArray())
)
}
} }
} }
@@ -2020,7 +2020,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
connected: Boolean, airpodsName: String? = null, batteryList: List<Battery>? = null connected: Boolean, airpodsName: String? = null, batteryList: List<Battery>? = null
) { ) {
val notificationManager = getSystemService(NotificationManager::class.java) val notificationManager = getSystemService(NotificationManager::class.java)
var updatedNotification: Notification?
val notificationIntent = Intent(this, MainActivity::class.java) val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
@@ -2080,13 +2079,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(2, updatedNotification) notificationManager.notify(2, updatedNotification)
notificationManager.cancel(1) notificationManager.cancel(1)
} else if (!connected) { } else if (!connected) {
updatedNotification = NotificationCompat.Builder(this, "background_service_status")
.setSmallIcon(R.drawable.airpods).setContentTitle("AirPods not connected")
.setContentText("Tap to open app").setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build()
notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2) notificationManager.cancel(2)
} else if (!config.bleOnlyMode && !socket.isConnected) { } else if (!config.bleOnlyMode && !socket.isConnected) {
showSocketConnectionFailureNotification("Socket created, but not connected. Check logs") showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
@@ -2116,7 +2108,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return suspendCancellableCoroutine { continuation -> return suspendCancellableCoroutine { continuation ->
gestureDetector?.startDetection(doNotStop = true) { accepted -> gestureDetector?.startDetection(doNotStop = true) { accepted ->
if (continuation.isActive) { if (continuation.isActive) {
continuation.resume(accepted) { continuation.resume(accepted) { _, _, _ ->
gestureDetector?.stopDetection() gestureDetector?.stopDetection()
} }
} }
@@ -2129,7 +2121,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) {
telecomManager.acceptRingingCall() telecomManager.acceptRingingCall() // TODO: Switch to InCallService (needs CDM association)
} }
} else { } else {
val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
@@ -2156,7 +2148,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) {
telecomManager.endCall() telecomManager.endCall() // TODO: Switch to InCallService (needs CDM association)
} }
} else { } else {
val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
@@ -2229,9 +2221,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
@Suppress("PrivatePropertyName") @Suppress("PrivatePropertyName")
private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data" private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data"
@Suppress("MissingPermission", "unused") @SuppressLint("MissingPermission")
fun broadcastBatteryInformation() { fun broadcastBatteryInformation() {
if (device == null) return if (device == null || checkSelfPermission("android.permission.INTERACT_ACROSS_USERS") != PackageManager.PERMISSION_GRANTED) return
val batteryList = batteryNotification.getBattery() val batteryList = batteryNotification.getBattery()
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
@@ -2315,7 +2307,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
private fun setMetadatas(d: BluetoothDevice) { private fun setMetadatas(d: BluetoothDevice) {
if (BuildConfig.FLAVOR != "xposed") return if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "no permission BLUETOOTH_PRIVILEGED, returning")
return
}
Log.d(TAG, "has permission BLUETOOTH_PRIVILEGED, proceeding")
d.let { device -> d.let { device ->
val instance = airpodsInstance val instance = airpodsInstance
if (instance != null) { if (instance != null) {
@@ -2385,7 +2381,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val context = context?.applicationContext val context = context?.applicationContext
val name = context?.getSharedPreferences("settings", MODE_PRIVATE) val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("name", bluetoothDevice?.name) ?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && action != null && !action.isEmpty()) { if (bluetoothDevice != null && !action.isNullOrEmpty()) {
Log.d(TAG, "Received bluetooth connection broadcast: action=$action") Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
@@ -2698,6 +2694,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
version2 = config.airpodsVersion2, version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3, version3 = config.airpodsVersion3,
) )
setMetadatas(device)
} }
} }
@@ -2705,7 +2702,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
true, config.deviceName, batteryNotification.getBattery() true, config.deviceName, batteryNotification.getBattery()
) )
Log.d(TAG, "<LogCollector:Complete:Success> Socket connected") Log.d(TAG, "<LogCollector:Complete:Success> Socket connected")
sharedPreferences.edit { putBoolean("connection_successful", true) }
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED))
} catch (e: Exception) { } catch (e: Exception) {
// sharedPreferences.edit { putBoolean("connection_successful", false) }
Log.d( Log.d(
TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}" TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}"
) )
@@ -2870,19 +2870,36 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}) })
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED){
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
if (profile == BluetoothProfile.A2DP) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
val connectedDevices = proxy.connectedDevices if (profile == BluetoothProfile.A2DP) {
if (connectedDevices.isNotEmpty()) { val connectedDevices = proxy.connectedDevices
MediaController.sendPause() if (connectedDevices.isNotEmpty()) {
MediaController.sendPause()
}
} }
bluetoothAdapter.closeProfileProxy(profile, proxy)
} }
bluetoothAdapter.closeProfileProxy(profile, proxy)
}
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
}
if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED){
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
MediaController.sendPause()
}
}
bluetoothAdapter.closeProfileProxy(profile, proxy)
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.HEADSET)
}
Log.d(TAG, "Disconnected AirPods upon user request") Log.d(TAG, "Disconnected AirPods upon user request")
} }
@@ -2917,98 +2934,123 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun disconnectAudio(context: Context, device: BluetoothDevice?) { fun disconnectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
if (profile == BluetoothProfile.A2DP) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
try { if (profile == BluetoothProfile.A2DP) {
if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { try {
Log.d(TAG, "Already disconnected from A2DP") if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) {
return Log.d(TAG, "Already disconnected from A2DP")
return
}
val method = proxy.javaClass.getMethod(
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
)
Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0")
method.invoke(proxy, device, 0)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
} }
val method = proxy.javaClass.getMethod(
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
)
method.invoke(proxy, device, 0)
} catch (e: Exception) {
Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED")
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
} }
} }
}
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
// requires protected permission (MODIFY_PHONE_STATE) } else {
// bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission")
// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { }
// if (profile == BluetoothProfile.HEADSET) { if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
// try { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
// val method = override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
// proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) if (profile == BluetoothProfile.HEADSET) {
// method.invoke(proxy, device, 0) try {
// } catch (e: Exception) { val method =
// e.printStackTrace() proxy.javaClass.getMethod(
// } finally { "setConnectionPolicy",
// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) BluetoothDevice::class.java,
// } Int::class.java
// } )
// } Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0")
// method.invoke(proxy, device, 0)
// override fun onServiceDisconnected(profile: Int) {} } catch (e: Exception) {
// }, BluetoothProfile.HEADSET) e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.HEADSET)
} else {
Log.d(TAG, "not disconnecting HEADSET, no MODIFIY_PHONE_STATE permission")
}
} }
fun connectAudio(context: Context, device: BluetoothDevice?) { fun connectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
try { try {
val policyMethod = proxy.javaClass.getMethod( val policyMethod = proxy.javaClass.getMethod(
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
) )
policyMethod.invoke(proxy, device, 100) Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100")
val connectMethod = policyMethod.invoke(proxy, device, 100)
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) val connectMethod =
connectMethod.invoke( proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
proxy, device connectMethod.invoke(
) // reduces the slight delay between allowing and actually connecting proxy, device
} catch (e: Exception) { )
Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED") } catch (e: Exception) {
} finally { e.printStackTrace()
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) } finally {
if (MediaController.pausedWhileTakingOver) { bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
MediaController.sendPlay() if (MediaController.pausedWhileTakingOver) {
MediaController.sendPlay()
}
} }
} }
} }
}
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
// requires protected permission (MODIFY_PHONE_STATE) } else {
// bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { Log.d(TAG, "not connecting A2DP, no BLUETOOTH_PRIVILEGED permission")
// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { }
// if (profile == BluetoothProfile.HEADSET) { if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
// try { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
// val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
// policyMethod.invoke(proxy, device, 100) if (profile == BluetoothProfile.HEADSET) {
// val connectMethod = try {
// proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) val policyMethod = proxy.javaClass.getMethod(
// connectMethod.invoke(proxy, device) "setConnectionPolicy",
// } catch (e: Exception) { BluetoothDevice::class.java,
// e.printStackTrace() Int::class.java
// } finally { )
// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 100")
// } policyMethod.invoke(proxy, device, 100)
// } val connectMethod =
// } proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
// connectMethod.invoke(proxy, device)
// override fun onServiceDisconnected(profile: Int) {} } catch (e: Exception) {
// }, BluetoothProfile.HEADSET) e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.HEADSET)
} else {
Log.d(TAG, "not connecting HEADSET, no MODIFIY_PHONE_STATE permission")
}
} }
fun setName(name: String) { fun setName(name: String) {
@@ -3016,6 +3058,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (config.deviceName != name) { if (config.deviceName != name) {
config.deviceName = name config.deviceName = name
device?.alias = name
sharedPreferences.edit { putString("name", name) } sharedPreferences.edit { putString("name", name) }
} }
@@ -3055,7 +3098,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
telephonyManager.unregisterTelephonyCallback(phoneStateListener)
}
// isConnectedLocally = false // isConnectedLocally = false
// CrossDevice.isAvailable = true // CrossDevice.isAvailable = true
super.onDestroy() super.onDestroy()

View File

@@ -36,7 +36,7 @@ fun isSupported(sharedPreferences: SharedPreferences): Boolean {
} }
} }
} else if (isOppoOrOnePlus) { } else if (isOppoOrOnePlus) {
return Build.VERSION.SDK_INT == 36 return Build.VERSION.SDK_INT >= 36
} }
return sharedPreferences.getBoolean("bypass_device_check", false) return sharedPreferences.getBoolean("bypass_device_check", false)
} }

View File

@@ -239,5 +239,28 @@
<string name="bypass_compatibility_check">Bypass compatibility check</string> <string name="bypass_compatibility_check">Bypass compatibility check</string>
<string name="bypass_compatiblity_check_confirmation">Are you sure your device is supported natively/you have Xposed module enabled?</string> <string name="bypass_compatiblity_check_confirmation">Are you sure your device is supported natively/you have Xposed module enabled?</string>
<string name="not_supported">Not supported</string> <string name="not_supported">Not supported</string>
<string name="check_the_repository_for_more_info">Check the repository for more info.</string> <string name="check_the_repository_for_more_info">
Many devices are not supported due to limitations in the Android Bluetooth stack.
\nOn these devices, root access with an Xposed framework is required for full functionality.
\n\nThis limitation has been addressed in newer Android versions. The following device configurations can run the app natively:
\n• Google Pixel® running Android 16 March update and later with the lateset Play system update
\n• Google Pixel® running 17 Beta 3 and above
\n• OnePlus devices running OxygenOS 16 or later
\n• Oppo devices running ColorOS 16 or later
\n\nFor details, see the project documentation.</string>
<string name="name_your_own_price">(Name your own price)</string>
<string name="compatibility_play_dialog_confirmation">
This device may not be supported due to platform limitations and requires an Xposed framework. Tick the checkbox below and type OK to continue.
</string>
<string name="type_ok_to_continue">Type "%s" to continue</string>
<string name="proceed">Proceed</string>
<string name="read_compatibility_requirements">I have read compatibility requirements.</string>
<string name="device_info">Device information</string>
<string name="build_id">Build ID</string>
<string name="manufacturer">Manufacturer</string>
<string name="free_features">Free features</string>
<string name="advanced_features">Advanced features</string>
<string name="digital_assistant_on_long_press">Digital Assistant on Long Press</string>
<string name="digital_assistant_on_long_press_description">Invoke Digital Assistant when long pressing the AirPods Pro stem.</string>
<string name="customizations_unavailable">Customizations unavailable. Connect your AirPods at least once to access.</string>
</resources> </resources>

View File

@@ -1,5 +1,21 @@
package me.kavishdevar.librepods package me.kavishdevar.librepods
import android.app.Application import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
class LibrePodsApplication: Application() class LibrePodsApplication: Application(), DefaultLifecycleObserver {
override fun onCreate() {
BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
super<Application>.onCreate()
}
override fun onResume(owner: LifecycleOwner) {
BillingManager.provider.queryPurchases()
}
}

View File

@@ -1,14 +1,27 @@
package me.kavishdevar.librepods package me.kavishdevar.librepods
import android.app.Application import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import io.github.libxposed.service.XposedService import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper import io.github.libxposed.service.XposedServiceHelper
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.utils.XposedServiceHolder import me.kavishdevar.librepods.utils.XposedServiceHolder
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener { class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
override fun onCreate() { override fun onCreate() {
super.onCreate()
XposedServiceHelper.registerListener(this) XposedServiceHelper.registerListener(this)
BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
super<Application>.onCreate()
}
override fun onResume(owner: LifecycleOwner) {
BillingManager.provider.queryPurchases()
} }
override fun onServiceBind(p0: XposedService) { override fun onServiceBind(p0: XposedService) {

View File

@@ -18,6 +18,7 @@ backdrop = "2.0.0-alpha03"
billing = "8.3.0" billing = "8.3.0"
hilt = "2.59.2" hilt = "2.59.2"
xposed = "101.0.0" xposed = "101.0.0"
lifecycleProcess = "2.10.0"
[libraries] [libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -47,6 +48,7 @@ hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" } libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" }
libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" } libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View File

@@ -1,6 +1,6 @@
{ {
"version": "v0.1.0-rc.4", "version": "v0.2.3",
"versionCode": 3, "versionCode": 36,
"zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.1.0-rc.4/LibrePods-v0.1.0-rc.4.zip", "zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.2.3/LibrePods-v0.2.3-release.zip",
"changelog": "https://raw.githubusercontent.com/kavishdevar/librepods/main/CHANGELOG.md" "changelog": "https://raw.githubusercontent.com/kavishdevar/librepods/main/CHANGELOG.md"
} }