diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 871a1c3b..4d2a6a70 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -33,22 +33,55 @@ jobs: distribution: zulu java-version: 21 - uses: gradle/actions/setup-gradle@v4 - - name: Decode keystore - run: echo "${{ secrets.RELEASE_KEYSTORE_FILE }}" | base64 --decode > android/release.keystore + - name: Prepare signing config + env: + RELEASE_KEYSTORE_FILE: ${{ secrets.RELEASE_KEYSTORE_FILE }} + RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + run: | + set -euo pipefail + + store_password="${RELEASE_STORE_PASSWORD:-}" + key_alias="${RELEASE_KEY_ALIAS:-}" + key_password="${RELEASE_KEY_PASSWORD:-}" + + if [ -n "${RELEASE_KEYSTORE_FILE:-}" ] && + [ -n "$store_password" ] && + [ -n "$key_alias" ] && + [ -n "$key_password" ] && + echo "$RELEASE_KEYSTORE_FILE" | base64 --decode > android/release.keystore && + keytool -list -keystore android/release.keystore -storepass "$store_password" -alias "$key_alias" >/dev/null 2>&1; then + echo "Using release signing key from repository secrets" + else + echo "Release signing secrets are missing or invalid; generating disposable CI signing key" + rm -f android/release.keystore + store_password="ci-release-password" + key_alias="ci-release-key" + key_password="ci-release-password" + keytool -genkeypair \ + -keystore android/release.keystore \ + -storepass "$store_password" \ + -alias "$key_alias" \ + -keypass "$key_password" \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -dname "CN=LibrePods CI, OU=CI, O=LibrePods, L=CI, S=CI, C=US" + fi + + cat < android/local.properties + RELEASE_STORE_FILE=../release.keystore + RELEASE_STORE_PASSWORD=$store_password + RELEASE_KEY_ALIAS=$key_alias + RELEASE_KEY_PASSWORD=$key_password + EOF - name: Setup Android SDK uses: android-actions/setup-android@v3 - name: Accept Licenses run: yes | sdkmanager --licenses - name: Install NDK run: sdkmanager "ndk;30.0.14904198" - - name: Create local.properties - run: | - cat < android/local.properties - RELEASE_STORE_FILE=../release.keystore - RELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }} - RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }} - RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }} - EOF - name: Build run: ./gradlew packageReleaseArtifacts working-directory: android diff --git a/.gitignore b/.gitignore index c63c246a..846e76b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.worktrees/ release .vscode .DS_Store diff --git a/README.md b/README.md index 4d2ff876..4f3ea0f6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - + @@ -85,6 +85,27 @@ LibrePods **may** require root depending on your device/OS and what features you > [!IMPORTANT] > This workaround with Xposed is not guaranteed to work on all devices. +### Setup for OxygenOS/ColorOS 16 (Non-rooted) + +For multi-device audio switching to work properly on non-rooted OxygenOS 16, you need to inject your phone's Bluetooth MAC address into the app's settings. This is a one-time setup. + +> [!IMPORTANT] +> The `run-as` command only works with **debug builds** (e.g., the nightly APK from CI). If you installed a release build, reinstall with the debug APK first. + +1. **Get your phone's Bluetooth MAC address:** + - Go to Settings -> About -> Device Details -> Bluetooth Address + +2. **Inject the MAC address via adb:** + ```bash + adb shell "run-as me.kavishdevar.librepods sed -i 's||XX:XX:XX:XX:XX:XX|' shared_prefs/settings.xml" + ``` + Replace `XX:XX:XX:XX:XX:XX` with your actual Bluetooth MAC address (e.g., `AC:C0:48:67:E6:EA`) + +3. **Restart the app** for the changes to take effect + +> [!NOTE] +> This is needed because non-rooted apps on SDK 36+ cannot access the system's `bluetooth_address` setting. Without this, audio source switching between devices won't work correctly, and the app will lose ANC/transparency control when you switch to another device. + ### Troubleshooting steps for common errors - Ensure the correct scope is set in LSPosed/Vector. - Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app. @@ -114,7 +135,7 @@ Upto two devices can be simultaneously connected to AirPods, for audio and contr Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured. -All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. +All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. # Supporters diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index b0a2aaf9..9ac94c36 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -74,6 +74,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -185,14 +186,10 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { try { - unbindService(serviceConnection) - Log.d("MainActivity", "Unbound service") - } catch (e: Exception) { - Log.e("MainActivity", "Error while unbinding service: $e") - } - try { - unregisterReceiver(connectionStatusReceiver) - Log.d("MainActivity", "Unregistered receiver") + if (::connectionStatusReceiver.isInitialized) { + unregisterReceiver(connectionStatusReceiver) + Log.d("MainActivity", "Unregistered receiver") + } } catch (e: Exception) { Log.e("MainActivity", "Error while unregistering receiver: $e") } @@ -202,14 +199,18 @@ class MainActivity : ComponentActivity() { override fun onStop() { try { - unbindService(serviceConnection) - Log.d("MainActivity", "Unbound service") + if (::serviceConnection.isInitialized) { + unbindService(serviceConnection) + Log.d("MainActivity", "Unbound service") + } } catch (e: Exception) { Log.e("MainActivity", "Error while unbinding service: $e") } try { - unregisterReceiver(connectionStatusReceiver) - Log.d("MainActivity", "Unregistered receiver") + if (::connectionStatusReceiver.isInitialized) { + unregisterReceiver(connectionStatusReceiver) + Log.d("MainActivity", "Unregistered receiver") + } } catch (e: Exception) { Log.e("MainActivity", "Error while unregistering receiver: $e") } @@ -511,7 +512,11 @@ fun Main() { canDrawOverlays = Settings.canDrawOverlays(context) } - if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { + val bluetoothPermissionsGranted = permissionState.permissions.filter { + it.permission.contains("BLUETOOTH") || it.permission.contains("LOCATION") + }.all { it.status.isGranted } + + if (bluetoothPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { val navController = rememberNavController() @@ -550,7 +555,16 @@ fun Main() { ) }) { composable("settings") { - if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController) + if (airPodsViewModel != null) { + AirPodsSettingsScreen(airPodsViewModel, navController) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } } composable("debug") { DebugScreen(navController = navController) @@ -692,7 +706,9 @@ fun PermissionsScreen( val scrollState = rememberScrollState() - val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted } + val basicPermissionsGranted = permissionState.permissions.filter { + it.permission.contains("BLUETOOTH") || it.permission.contains("LOCATION") + }.all { it.status.isGranted } val infiniteTransition = rememberInfiniteTransition(label = "pulse") val pulseScale by infiniteTransition.animateFloat( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index 726430ea..139c45c8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -174,9 +174,9 @@ class AACPManager { } var controlCommandStatusList: MutableList = - mutableListOf() + java.util.concurrent.CopyOnWriteArrayList() var controlCommandListeners: MutableMap> = - mutableMapOf() + java.util.concurrent.ConcurrentHashMap>() var owns: Boolean = false private set @@ -260,7 +260,7 @@ class AACPManager { fun registerControlCommandListener( identifier: ControlCommandIdentifiers, callback: ControlCommandListener ) { - controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) + controlCommandListeners.getOrPut(identifier) { java.util.concurrent.CopyOnWriteArrayList() }.add(callback) } fun unregisterControlCommandListener( 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 464eddde..09a9620d 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 @@ -1098,12 +1098,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onAudioSourceReceived(audioSource: ByteArray) { Log.d( "AirPodsParser", - "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}" + "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}, localMac: $localMac" ) - if (localMac!="" && (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac)) { + if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && + localMac.isNotEmpty() && + aacpManager.audioSource?.mac != localMac + ) { Log.d( "AirPodsParser", - "Audio source is another device, better to give up aacp control" + "Audio source is another device, giving up AACP control" ) aacpManager.sendControlCommand( AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, @@ -1113,6 +1116,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // 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??? + } else if (localMac.isNotEmpty() && aacpManager.audioSource?.mac == localMac) { + Log.d("AirPodsParser", "Audio source is local device, reclaiming AACP control") + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, + byteArrayOf(0x01) + ) } } @@ -1684,7 +1693,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList startActivity(intent) } - // var isConnectedLocally = false + private val isConnecting = java.util.concurrent.atomic.AtomicBoolean(false) + @Volatile + private var socketConnectedAt: Long = 0 var device: BluetoothDevice? = null private lateinit var earReceiver: BroadcastReceiver @@ -1817,6 +1828,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun setBatteryMetadata() { + if (::sharedPreferences.isInitialized && sharedPreferences.getBoolean("skip_setup", false)) return if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { device?.let { it -> SystemApisUtils.setMetadata( @@ -2068,10 +2080,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val updatedNotification = updatedNotificationBuilder.build() - notificationManager.notify(2, updatedNotification) - notificationManager.cancel(1) + notificationManager.notify(1, updatedNotification) } else if (!connected) { - notificationManager.cancel(2) + val 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) } else if (!config.bleOnlyMode && !socket.isConnected) { showSocketConnectionFailureNotification("Socket created, but not connected. Check logs") } @@ -2405,6 +2426,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList takeOver("music", manualTakeOverAfterReversed = true) } + if (!isConnected() && ::sharedPreferences.isInitialized) { + val savedMac = sharedPreferences.getString("mac_address", "") + if (!savedMac.isNullOrEmpty()) { + Log.d(TAG, "Service restarted, attempting L2CAP reconnect to $savedMac") + val bluetoothManager = getSystemService(BluetoothManager::class.java) + val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(savedMac) + CoroutineScope(Dispatchers.IO).launch { + connectToSocket(bluetoothManager.adapter, bluetoothDevice) + } + } + } + return START_STICKY } @@ -2644,14 +2677,42 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun connectToSocket( adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false ) { + if (!isConnecting.compareAndSet(false, true)) { + Log.d(TAG, "Already connecting to socket, skipping duplicate attempt") + return + } + Log.d(TAG, " Connecting to socket") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") -// if (!isConnectedLocally) { + + val inHandshakeWindow = System.currentTimeMillis() - socketConnectedAt < 10_000 + val socketActuallyAlive = this::socket.isInitialized && + socket.isConnected && + (aacpManager.connectedDevices.isNotEmpty() || inHandshakeWindow) + + if (socketActuallyAlive) { + isConnecting.set(false) + Log.d( + TAG, + "Already connected locally, skipping socket connection (socket.isConnected = ${socket.isConnected})" + ) + return + } + + if (this::socket.isInitialized) { + try { + if (socket.isConnected) Log.d(TAG, "Socket looks stale, closing before reconnect") + socket.close() + } catch (_: Exception) { + } + } + socket = try { createBluetoothSocket(adapter, device, uuid) } catch (e: Exception) { Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") + isConnecting.set(false) return } @@ -2660,7 +2721,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList withTimeout(5000L) { try { socket.connect() -// isConnectedLocally = true + socketConnectedAt = System.currentTimeMillis() this@AirPodsService.device = device BluetoothConnectionManager.setCurrentConnection(socket, device) @@ -2695,7 +2756,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) Log.d(TAG, " Socket connected") sharedPreferences.edit { putBoolean("connection_successful", true) } - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED)) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED).apply { + setPackage(packageName) + }) } catch (e: Exception) { // sharedPreferences.edit { putBoolean("connection_successful", false) } Log.d( @@ -2722,110 +2785,102 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else { showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout") } + isConnecting.set(false) return } this@AirPodsService.device = device - socket.let { + val activeSocket = socket + activeSocket.let { aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() Log.d(TAG, "Requesting proximity keys") aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) CoroutineScope(Dispatchers.IO).launch { - aacpManager.sendPacket(aacpManager.createHandshakePacket()) - delay(200) - aacpManager.sendSetFeatureFlagsPacket() - delay(200) - aacpManager.sendNotificationRequest() - delay(200) - aacpManager.sendSomePacketIDontKnowWhatItIs() - delay(200) - aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) - if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall() - Handler(Looper.getMainLooper()).postDelayed({ + try { aacpManager.sendPacket(aacpManager.createHandshakePacket()) + delay(200) aacpManager.sendSetFeatureFlagsPacket() + delay(200) aacpManager.sendNotificationRequest() - aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) - if (!handleIncomingCallOnceConnected) stopHeadTracking() - }, 5000) - - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device) - .apply { - setPackage(packageName) - }) - - setupStemActions() - - while (socket.isConnected) { - socket.let { it -> - try { - val buffer = ByteArray(1024) - val bytesRead = it.inputStream.read(buffer) - var data: ByteArray - if (bytesRead > 0) { - data = buffer.copyOfRange(0, bytesRead) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { - putExtra("data", buffer.copyOfRange(0, bytesRead)) - setPackage(packageName) - }) - val bytes = buffer.copyOfRange(0, bytesRead) - val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } -// CrossDevice.sendReceivedPacket(bytes) - updateNotificationContent( - true, - sharedPreferences.getString("name", device.name), - batteryNotification.getBattery() - ) - - aacpManager.receivePacket(data) - - if (!isHeadTrackingData(data)) { - Log.d("AirPodsData", "Data received: $formattedHex") - logPacket(data, "AirPods") - } + delay(200) + aacpManager.sendSomePacketIDontKnowWhatItIs() + delay(200) + aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) + if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall() + Handler(Looper.getMainLooper()).postDelayed({ + aacpManager.sendPacket(aacpManager.createHandshakePacket()) + aacpManager.sendSetFeatureFlagsPacket() + aacpManager.sendNotificationRequest() + aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) + if (!handleIncomingCallOnceConnected) stopHeadTracking() + }, 5000) + + sendBroadcast( + Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device) + .apply { + setPackage(packageName) + }) - } else if (bytesRead == -1) { - Log.d("AirPods Service", "Socket closed (bytesRead = -1)") - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - aacpManager.disconnected() - return@launch - } - } catch (e: Exception) { - Log.w(TAG, "Error reading data, we have probably disconnected.") - e.printStackTrace() - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { + setupStemActions() + + while (activeSocket.isConnected) { + val buffer = ByteArray(1024) + val bytesRead = activeSocket.inputStream.read(buffer) + if (bytesRead > 0) { + val data = buffer.copyOfRange(0, bytesRead) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { + putExtra("data", data) setPackage(packageName) }) - aacpManager.disconnected() - return@launch + val formattedHex = data.joinToString(" ") { "%02X".format(it) } +// CrossDevice.sendReceivedPacket(bytes) + updateNotificationContent( + true, + sharedPreferences.getString("name", device.name), + batteryNotification.getBattery() + ) + + aacpManager.receivePacket(data) + + if (!isHeadTrackingData(data)) { + Log.d("AirPodsData", "Data received: $formattedHex") + logPacket(data, "AirPods") + } + } else if (bytesRead == -1) { + Log.d("AirPods Service", "Socket closed (bytesRead = -1)") + break } } + Log.d("AirPods Service", "Socket closed") + } catch (e: Exception) { + Log.w(TAG, "Error reading data, we have probably disconnected.") + e.printStackTrace() + } finally { + socketConnectedAt = 0 + isConnecting.set(false) + try { + activeSocket.close() + } catch (_: Exception) { + } + aacpManager.disconnected() + updateNotificationContent(false) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { + setPackage(packageName) + }) } - Log.d("AirPods Service", "Socket closed") -// isConnectedLocally = false - socket.close() - aacpManager.disconnected() - updateNotificationContent(false) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) } } + isConnecting.set(false) } catch (e: Exception) { e.printStackTrace() Log.d(TAG, "Failed to connect to socket: ${e.message}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") -// isConnectedLocally = false + isConnecting.set(false) + socketConnectedAt = 0 this@AirPodsService.device = device updateNotificationContent(false) } -// } else { -// Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})") -// } } fun disconnectForCD() { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt index e5a1e7bd..ea25abcf 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext import me.kavishdevar.librepods.services.ServiceManager +import android.os.Build import java.io.BufferedReader import java.io.File import java.io.FileOutputStream @@ -60,6 +61,12 @@ class RadareOffsetFinder(context: Context) { "/system_ext/lib64/libbluetooth_qti.so" ) + fun isOxygenOSOrColorOS16OrAbove(): Boolean { + val manufacturer = Build.MANUFACTURER.lowercase() + if (manufacturer != "oneplus" && manufacturer != "oppo" && manufacturer != "realme") return false + return Build.VERSION.SDK_INT >= 36 + } + fun findBluetoothLibraryPath(): String? { for (path in LIBRARY_PATHS) { if (File(path).exists()) { @@ -115,6 +122,10 @@ class RadareOffsetFinder(context: Context) { } fun isSdpOffsetAvailable(): Boolean { + if (isOxygenOSOrColorOS16OrAbove()) { + Log.d(TAG, "OxygenOS/ColorOS 16+ detected, L2CAP works without SDP hook.") + return true + } val sharedPreferences = ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE) // ik not good practice- too lazy if (sharedPreferences?.getBoolean("skip_setup", false) == true) { Log.d(TAG, "Setup skipped, returning true for SDP offset.") @@ -160,6 +171,10 @@ class RadareOffsetFinder(context: Context) { fun isHookOffsetAvailable(): Boolean { + if (isOxygenOSOrColorOS16OrAbove()) { + Log.d(TAG, "OxygenOS/ColorOS 16+ detected, L2CAP works without hook.") + return true + } Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString()) if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) { Log.d(TAG, "Setup skipped, returning true.") diff --git a/docs/device-info.md b/docs/device-info.md index 2c817760..b75bc5d1 100644 --- a/docs/device-info.md +++ b/docs/device-info.md @@ -23,4 +23,3 @@ The data is in this order: - Serial number (Right Bud) - Version (?) (I have `8454371`) - A few more bytes, I don't know what they are -