diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt index 30120be..06949ad 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft @@ -49,11 +50,15 @@ import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -76,6 +81,8 @@ 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.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -88,10 +95,13 @@ import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R import me.kavishdevar.librepods.composables.StyledSwitch +import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.RadareOffsetFinder +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.roundToInt -@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) @Composable fun AppSettingsScreen(navController: NavController) { val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) @@ -103,6 +113,35 @@ fun AppSettingsScreen(navController: NavController) { val hazeState = remember { HazeState() } var showResetDialog by remember { mutableStateOf(false) } + var showIrkDialog by remember { mutableStateOf(false) } + var showEncKeyDialog by remember { mutableStateOf(false) } + var irkValue by remember { mutableStateOf("") } + var encKeyValue by remember { mutableStateOf("") } + var irkError by remember { mutableStateOf(null) } + var encKeyError by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + val savedIrk = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null) + val savedEncKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null) + + if (savedIrk != null) { + try { + val decoded = Base64.decode(savedIrk) + irkValue = decoded.joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + irkValue = "" + } + } + + if (savedEncKey != null) { + try { + val decoded = Base64.decode(savedEncKey) + encKeyValue = decoded.joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + encKeyValue = "" + } + } + } var showPhoneBatteryInWidget by remember { mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true)) @@ -142,6 +181,11 @@ fun AppSettingsScreen(navController: NavController) { var mDensity by remember { mutableFloatStateOf(0f) } + fun validateHexInput(input: String): Boolean { + val hexPattern = Regex("^[0-9a-fA-F]{32}$") + return hexPattern.matches(input) + } + Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { @@ -659,7 +703,6 @@ fun AppSettingsScreen(navController: NavController) { modifier = Modifier.padding(top = 12.dp, bottom = 4.dp) ) - // Disconnected Row( modifier = Modifier .fillMaxWidth() @@ -701,7 +744,6 @@ fun AppSettingsScreen(navController: NavController) { ) } - // Idle Row( modifier = Modifier .fillMaxWidth() @@ -743,7 +785,6 @@ fun AppSettingsScreen(navController: NavController) { ) } - // Music Row( modifier = Modifier .fillMaxWidth() @@ -785,7 +826,6 @@ fun AppSettingsScreen(navController: NavController) { ) } - // Call Row( modifier = Modifier .fillMaxWidth() @@ -837,7 +877,6 @@ fun AppSettingsScreen(navController: NavController) { modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) ) - // Ringing Call Row( modifier = Modifier .fillMaxWidth() @@ -879,7 +918,6 @@ fun AppSettingsScreen(navController: NavController) { ) } - // Media Start Row( modifier = Modifier .fillMaxWidth() @@ -944,6 +982,64 @@ fun AppSettingsScreen(navController: NavController) { ) .padding(horizontal = 16.dp, vertical = 4.dp) ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showIrkDialog = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + .padding(end = 4.dp) + ) { + Text( + text = "Set Identity Resolving Key (IRK)", + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Manually set the IRK value used for resolving BLE random addresses", + fontSize = 14.sp, + color = textColor.copy(0.6f), + lineHeight = 16.sp, + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showEncKeyDialog = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + .padding(end = 4.dp) + ) { + Text( + text = "Set Encryption Key", + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Manually set the ENC_KEY value used for decrypting BLE advertisements", + fontSize = 14.sp, + color = textColor.copy(0.6f), + lineHeight = 16.sp, + ) + } + } + Row( modifier = Modifier .fillMaxWidth() @@ -1073,6 +1169,184 @@ fun AppSettingsScreen(navController: NavController) { } ) } + + if (showIrkDialog) { + AlertDialog( + onDismissRequest = { showIrkDialog = false }, + title = { + Text( + "Set Identity Resolving Key (IRK)", + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium + ) + }, + text = { + Column { + Text( + "Enter 16-byte IRK as hex string (32 characters):", + fontFamily = FontFamily(Font(R.font.sf_pro)), + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = irkValue, + onValueChange = { + irkValue = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' } + irkError = null + }, + modifier = Modifier.fillMaxWidth(), + isError = irkError != null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, + capitalization = KeyboardCapitalization.None + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), + unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray + ), + supportingText = { + if (irkError != null) { + Text(irkError!!, color = MaterialTheme.colorScheme.error) + } + }, + label = { Text("IRK Hex Value") } + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (!validateHexInput(irkValue)) { + irkError = "Must be exactly 32 hex characters" + return@TextButton + } + + try { + val hexBytes = ByteArray(16) + for (i in 0 until 16) { + val hexByte = irkValue.substring(i * 2, i * 2 + 2) + hexBytes[i] = hexByte.toInt(16).toByte() + } + + val base64Value = Base64.encode(hexBytes) + sharedPreferences.edit().putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value).apply() + + Toast.makeText(context, "IRK has been set successfully", Toast.LENGTH_SHORT).show() + showIrkDialog = false + } catch (e: Exception) { + irkError = "Error converting hex: ${e.message}" + } + } + ) { + Text( + "Save", + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium + ) + } + }, + dismissButton = { + TextButton( + onClick = { showIrkDialog = false } + ) { + Text( + "Cancel", + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium + ) + } + } + ) + } + + if (showEncKeyDialog) { + AlertDialog( + onDismissRequest = { showEncKeyDialog = false }, + title = { + Text( + "Set Encryption Key", + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium + ) + }, + text = { + Column { + Text( + "Enter 16-byte ENC_KEY as hex string (32 characters):", + fontFamily = FontFamily(Font(R.font.sf_pro)), + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = encKeyValue, + onValueChange = { + encKeyValue = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' } + encKeyError = null + }, + modifier = Modifier.fillMaxWidth(), + isError = encKeyError != null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, + capitalization = KeyboardCapitalization.None + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5), + unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray + ), + supportingText = { + if (encKeyError != null) { + Text(encKeyError!!, color = MaterialTheme.colorScheme.error) + } + }, + label = { Text("ENC_KEY Hex Value") } + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (!validateHexInput(encKeyValue)) { + encKeyError = "Must be exactly 32 hex characters" + return@TextButton + } + + try { + val hexBytes = ByteArray(16) + for (i in 0 until 16) { + val hexByte = encKeyValue.substring(i * 2, i * 2 + 2) + hexBytes[i] = hexByte.toInt(16).toByte() + } + + val base64Value = Base64.encode(hexBytes) + sharedPreferences.edit().putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value).apply() + + Toast.makeText(context, "Encryption key has been set successfully", Toast.LENGTH_SHORT).show() + showEncKeyDialog = false + } catch (e: Exception) { + encKeyError = "Error converting hex: ${e.message}" + } + } + ) { + Text( + "Save", + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium + ) + } + }, + dismissButton = { + TextButton( + onClick = { showEncKeyDialog = false } + ) { + Text( + "Cancel", + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium + ) + } + } + ) + } } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt index fa66d52..1f97da7 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt @@ -263,6 +263,9 @@ class IslandWindow(private val context: Context) { if (abs(deltaY) > 5 || isBeingDragged) { isBeingDragged = true + // Cancel auto close timer when dragging starts + autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false) + val dampedDeltaY = if (deltaY > 0) { initialY + (deltaY * 0.6f) } else { @@ -417,6 +420,7 @@ class IslandWindow(private val context: Context) { } private fun resetAutoCloseTimer() { + autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return) autoCloseHandler = Handler(Looper.getMainLooper()) autoCloseRunnable = Runnable { close() } autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500) @@ -501,7 +505,7 @@ class IslandWindow(private val context: Context) { } flingAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - close() + forceClose() } }) @@ -556,7 +560,7 @@ class IslandWindow(private val context: Context) { normalizeAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { ServiceManager.getService()?.startMainActivity() - close() + forceClose() } }) @@ -611,7 +615,12 @@ class IslandWindow(private val context: Context) { resetStretchEffects(0f) val videoView = islandView.findViewById(R.id.island_video_view) - videoView.stopPlayback() + try { + videoView.stopPlayback() + } catch (e: Exception) { + e.printStackTrace() + } + val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f) val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f) val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f) @@ -620,19 +629,56 @@ class IslandWindow(private val context: Context) { interpolator = AnticipateOvershootInterpolator() addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - containerView.visibility = View.GONE - try { - windowManager.removeView(containerView) - } catch (e: Exception) { - e("IslandWindow", "Error removing view: $e") - } - isClosing = false + cleanupAndRemoveView() } }) start() } } catch (e: Exception) { e.printStackTrace() + // Even if animation fails, ensure we cleanup + cleanupAndRemoveView() + } + } + + private fun cleanupAndRemoveView() { + containerView.visibility = View.GONE + try { + if (containerView.parent != null) { + windowManager.removeView(containerView) + } + } catch (e: Exception) { + e("IslandWindow", "Error removing view: $e") + } + isClosing = false + // Make sure all animations are canceled + springAnimation.cancel() + flingAnimator.cancel() + } + + fun forceClose() { + try { + if (isClosing) return + isClosing = true + + try { + context.unregisterReceiver(batteryReceiver) + } catch (e: Exception) { + // Silent catch - receiver might already be unregistered + } + + ServiceManager.getService()?.islandOpen = false + autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return) + + // Cancel all ongoing animations + springAnimation.cancel() + flingAnimator.cancel() + + // Immediately remove the view without animations + cleanupAndRemoveView() + } catch (e: Exception) { + e.printStackTrace() + isClosing = false } } }