diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c40d78a..b61702c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -15,7 +15,7 @@ android { minSdk = 28 targetSdk = 36 versionCode = 8 - versionName = "0.2.0-alpha" + versionName = "0.2.0-beta.1" } buildTypes { @@ -86,4 +86,4 @@ aboutLibraries { excludeFields = listOf("generated") outputFile = file("src/main/res/raw/aboutlibraries.json") } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt index e3fca0f..0a37dfd 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt @@ -20,8 +20,6 @@ package me.kavishdevar.librepods.screens import android.annotation.SuppressLint import android.util.Log -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures @@ -35,17 +33,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -59,8 +50,6 @@ 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.draw.scale -import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -88,7 +77,6 @@ import kotlinx.coroutines.launch import me.kavishdevar.librepods.R import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.StyledDropdown -import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledToggle diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index adba385..e2783cf 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -67,7 +67,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController -import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.highlight.Highlight @@ -231,7 +230,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, val instance = service.airpodsInstance if (instance == null) { Text("Error: AirPods instance is null") - return@StyledScaffold + return@StyledScaffold } val capabilities = instance.model.capabilities LazyColumn( @@ -267,7 +266,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "noise_control") { NoiseControlSettings(service = service) } } - + if (capabilities.contains(Capability.STEM_CONFIG)) { item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "press_hold") { PressAndHoldSettings(navController = navController) } @@ -370,7 +369,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, Spacer(Modifier.height(32.dp)) StyledButton( onClick = { navController.navigate("troubleshooting") }, - backdrop = backdrop + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth(0.9f) ) { Text( text = "Troubleshoot Connection", @@ -378,7 +379,26 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, fontSize = 16.sp, fontWeight = FontWeight.Medium, fontFamily = FontFamily(Font(R.font.sf_pro)), - color = if (isSystemInDarkTheme()) Color.White else Color.Black + color = if (isSystemInDarkTheme()) Color.White else Color.Black + ) + ) + } + Spacer(Modifier.height(16.dp)) + StyledButton( + onClick = { + service.reconnectFromSavedMac() + }, + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth(0.9f) + ) { + Text( + text = stringResource(R.string.reconnect_to_last_device), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = if (isSystemInDarkTheme()) Color.White else Color.Black ) ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt index 8ad2423..8e067c0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt @@ -54,9 +54,9 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -97,7 +97,7 @@ fun HearingAidScreen(navController: NavController) { mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) } - var hazeStateS = rememberHazeState() + val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold StyledScaffold( title = stringResource(R.string.hearing_aid), @@ -112,7 +112,7 @@ fun HearingAidScreen(navController: NavController) { .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - hazeStateS = hazeState + hazeStateS.value = hazeState Spacer(modifier = Modifier.height(spacerHeight)) val hearingAidListener = remember { @@ -288,7 +288,7 @@ fun HearingAidScreen(navController: NavController) { } } }, - hazeState = hazeStateS, + hazeState = hazeStateS.value, // backdrop = backdrop ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt index e1598ef..6b1dc92 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt @@ -193,7 +193,7 @@ fun TroubleshootingScreen(navController: NavController) { LaunchedEffect(currentStep) { instructionText = when (currentStep) { 0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings." - 1 -> "Please put your AirPods in the case and close it, so they disconnect completely." + 1 -> "Please put your AirPods in the case and close it, so they disconnectForCD completely." 2 -> "Preparing to collect logs... Please wait." 3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done." 4 -> "Log collection complete! You can now save or share the logs." diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index aa4afde..d8d16d3 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -88,9 +88,9 @@ import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.constants.isHeadTrackingData import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType +import me.kavishdevar.librepods.utils.ATTManager import me.kavishdevar.librepods.utils.AirPodsInstance import me.kavishdevar.librepods.utils.AirPodsModels -import me.kavishdevar.librepods.utils.ATTManager import me.kavishdevar.librepods.utils.BLEManager import me.kavishdevar.librepods.utils.BluetoothConnectionManager import me.kavishdevar.librepods.utils.CrossDevice @@ -229,6 +229,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private var handleIncomingCallOnceConnected = false lateinit var bleManager: BLEManager + + private lateinit var socket: BluetoothSocket + private val bleStatusListener = object : BLEManager.AirPodsStatusListener { @SuppressLint("NewApi") override fun onDeviceStatusChanged( @@ -973,7 +976,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList config.airpodsVersion3 = deviceInformation.version3 config.airpodsHardwareRevision = deviceInformation.hardwareRevision config.airpodsUpdaterIdentifier = deviceInformation.updaterIdentifier - + val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) if (model != null) { airpodsInstance = AirPodsInstance( @@ -1032,8 +1035,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList byteArrayOf(0x00) ) // this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes - Log.d(TAG, "Another device started playing audio, listening for audio config changes again") - MediaController.pausedForOtherDevice = false +// Log.d(TAG, "Another device started playing audio, listening for audio config changes again") +// MediaController.pausedForOtherDevice = false +// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change??? } } @@ -1842,7 +1846,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.notify(1, updatedNotification) notificationManager.cancel(2) } else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) { - Log.d(TAG, " Socket not connected") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") } } @@ -2135,10 +2138,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val name = context?.getSharedPreferences("settings", MODE_PRIVATE) ?.getString("name", bluetoothDevice?.name) if (bluetoothDevice != null && action != null && !action.isEmpty()) { - Log.d(TAG, "Received bluetooth connection broadcast") + Log.d(TAG, "Received bluetooth connection broadcast: action=$action") if (ServiceManager.getService()?.isConnectedLocally == true) { - Log.d(TAG, "Device is already connected locally, ignoring broadcast") - ServiceManager.getService()?.manuallyCheckForAudioSource() + Log.d(TAG, "Device is already connected locally, checking if we should keep audio connected") + if (ServiceManager.getService()?.socket?.isConnected == true) ServiceManager.getService()?.manuallyCheckForAudioSource() else Log.d(TAG, "We're not connected, ignoring") return } if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { @@ -2175,14 +2178,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return START_STICKY } - private lateinit var socket: BluetoothSocket - fun manuallyCheckForAudioSource() { - val shouldResume = MediaController.getMusicActive() - if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) { + val shouldResume = MediaController.getMusicActive() // todo: for some reason we lose this info after disconnecting, probably android dispatches some event. haven't investigated yet. + if (airpodsInstance == null) return + Log.d(TAG, "disconnectedBecauseReversed: $disconnectedBecauseReversed, otherDeviceTookOver: $otherDeviceTookOver") + if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) { Log.d( TAG, - "For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!" + "For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again! I will resume: $shouldResume" ) disconnectAudio(this, device, shouldResume = shouldResume) } @@ -2378,7 +2381,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") - fun connectToSocket(device: BluetoothDevice) { + fun connectToSocket(device: BluetoothDevice, manual: Boolean = false) { Log.d(TAG, " Connecting to socket") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") @@ -2387,7 +2390,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList createBluetoothSocket(device, uuid) } catch (e: Exception) { Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") - showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.message}") + showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") return } @@ -2431,15 +2434,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) Log.d(TAG, " Socket connected") } catch (e: Exception) { - Log.d(TAG, " Socket not connected") - showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") - throw e + Log.d(TAG, " Socket not connected, ${e.message}") + if (manual) { + sendToast( + "Couldn't connect to socket: ${e.localizedMessage}" + ) + } else { + showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}") + } + return@withTimeout +// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history } } - if (!socket.isConnected) { - Log.d(TAG, " Socket not connected") - showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") + } + if (!socket.isConnected) { + Log.d(TAG, " Socket not connected") + if (manual) { + sendToast( + "Couldn't connect to socket: timeout." + ) + } else { + showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout") } + return } this@AirPodsService.device = device socket.let { @@ -2519,7 +2536,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } catch (e: Exception) { e.printStackTrace() Log.d(TAG, "Failed to connect to socket: ${e.message}") - showSocketConnectionFailureNotification("Failed to establish connection: ${e.message}") + showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") isConnectedLocally = false this@AirPodsService.device = device updateNotificationContent(false) @@ -2527,7 +2544,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } - fun disconnect() { + fun disconnectForCD() { if (!this::socket.isInitialized) return socket.close() MediaController.pausedWhileTakingOver = false @@ -2552,6 +2569,33 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList CrossDevice.isAvailable = true } + fun disconnectAirPods() { + if (!this::socket.isInitialized) return + socket.close() + isConnectedLocally = false + aacpManager.disconnected() + attManager?.disconnect() + updateNotificationContent(false) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) + + val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter + bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + val connectedDevices = proxy.connectedDevices + if (connectedDevices.isNotEmpty()) { + MediaController.sendPause() + } + } + bluetoothAdapter.closeProfileProxy(profile, proxy) + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.A2DP) + Log.d(TAG, "Disconnected AirPods upon user request") + + } + val earDetectionNotification = AirPodsNotifications.EarDetection() val ancNotification = AirPodsNotifications.ANC() val batteryNotification = AirPodsNotifications.BatteryNotification() @@ -2750,6 +2794,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList isHeadTrackingActive = false } + @SuppressLint("MissingPermission") + fun reconnectFromSavedMac(){ + val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter + device = bluetoothAdapter.bondedDevices.find { + it.address == macAddress + } + if (device != null) { + CoroutineScope(Dispatchers.IO).launch { + connectToSocket(device!!, manual = true) + } + } + } + } private fun Int.dpToPx(): Int { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt index 8c9ee97..3e91c28 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt @@ -200,7 +200,7 @@ object CrossDevice { notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!) break } else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) { - ServiceManager.getService()?.disconnect() + ServiceManager.getService()?.disconnectForCD() disconnectionRequested = true CoroutineScope(Dispatchers.IO).launch { delay(1000) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a3da73f..d9f3761 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -204,4 +204,6 @@ EN 352 Protection EN 352 Protection limits the maximum level of media to 82 dBA, and meets applicable EN 352 Standard requirements for personal hearing protection. Environmental Noise + Reconnect to last connected device + Disconnect