From 58de49d1b1e8e6e6fc857616300c180a3f9cc130 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Wed, 27 Nov 2024 00:38:45 +0530 Subject: [PATCH] try to add automatic device connection detection; add "Off Listening Mode" toggle --- android/app/src/main/AndroidManifest.xml | 38 ++--- .../me/kavishdevar/aln/AirPodsQSService.kt | 24 ++- .../java/me/kavishdevar/aln/AirPodsService.kt | 6 + .../kavishdevar/aln/AirPodsSettingsScreen.kt | 32 ++++ .../java/me/kavishdevar/aln/CustomDevice.kt | 159 ++++++++++++++++++ .../java/me/kavishdevar/aln/MainActivity.kt | 89 ++++++---- .../main/java/me/kavishdevar/aln/receiver.kt | 62 +++++++ android/app/src/main/res/values/strings.xml | 1 + android/gradle/libs.versions.toml | 2 +- 9 files changed, 354 insertions(+), 59 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/aln/CustomDevice.kt create mode 100644 android/app/src/main/java/me/kavishdevar/aln/receiver.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 57599da..e01ed6f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,31 +7,44 @@ - - + tools:ignore="UnusedAttribute" + tools:targetApi="31"> + + + + + + + @@ -42,7 +55,6 @@ android:exported="true" android:foregroundServiceType="connectedDevice" android:permission="android.permission.BLUETOOTH_CONNECT" /> - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/aln/AirPodsQSService.kt index c7562fc..12fe4bf 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/AirPodsQSService.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsQSService.kt @@ -10,8 +10,8 @@ import android.service.quicksettings.TileService import android.util.Log class AirPodsQSService: TileService() { - private val ancModes = listOf(NoiseControlMode.OFF.name, NoiseControlMode.NOISE_CANCELLATION.name, NoiseControlMode.TRANSPARENCY.name, NoiseControlMode.ADAPTIVE.name) - private var currentModeIndex = 3 + private val ancModes = listOf(NoiseControlMode.NOISE_CANCELLATION.name, NoiseControlMode.TRANSPARENCY.name, NoiseControlMode.ADAPTIVE.name) + private var currentModeIndex = 2 private lateinit var ancStatusReceiver: BroadcastReceiver private lateinit var availabilityReceiver: BroadcastReceiver @@ -63,8 +63,24 @@ class AirPodsQSService: TileService() { override fun onStopListening() { super.onStopListening() - unregisterReceiver(ancStatusReceiver) - unregisterReceiver(availabilityReceiver) + try { + unregisterReceiver(ancStatusReceiver) + } + catch ( + e: IllegalArgumentException + ) + { + Log.e("QuickSettingTileService", "Receiver not registered") + } + try { + unregisterReceiver(availabilityReceiver) + } + catch ( + e: IllegalArgumentException + ) + { + Log.e("QuickSettingTileService", "Receiver not registered") + } } override fun onClick() { diff --git a/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt index 97aaf9f..ed7e839 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt @@ -1,3 +1,5 @@ +@file:Suppress("unused") + package me.kavishdevar.aln import android.annotation.SuppressLint @@ -90,6 +92,10 @@ class AirPodsService : Service() { socket?.outputStream?.write(if (enabled) Enums.SET_CONVERSATION_AWARENESS_ON.value else Enums.SET_CONVERSATION_AWARENESS_OFF.value) } + fun setOffListeningMode(enabled: Boolean) { + socket?.outputStream?.write(byteArrayOf(0x04, 0x00 ,0x04, 0x00, 0x09, 0x00, 0x34, if (enabled) 0x01 else 0x02, 0x00, 0x00, 0x00)) + } + fun setAdaptiveStrength(strength: Int) { val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2E, strength.toByte(), 0x00, 0x00, 0x00) socket?.outputStream?.write(bytes) diff --git a/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt index 52ead13..35e2de5 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt @@ -150,6 +150,33 @@ fun BatteryView() { } } +@Composable +fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 + val textColor = if (isDarkTheme) Color.White else Color.Black + + Text( + text = "ACCESSIBILITY", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f) + ), + modifier = Modifier.padding(8.dp, bottom = 2.dp) + ) + + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(top = 2.dp) + ) { + // + } +} + @SuppressLint("MissingPermission", "NewApi") @Composable fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?, service: AirPodsService?, @@ -203,6 +230,11 @@ fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice? Spacer(modifier = Modifier.height(16.dp)) IndependentToggle(name = "Automatic Ear Detection", service = service, functionName = "setEarDetection", sharedPreferences = sharedPreferences, true) + Spacer(modifier = Modifier.height(16.dp)) + IndependentToggle(name = "Off Listening Mode", service = service, functionName = "setOffListeningMode", sharedPreferences = sharedPreferences, false) + + Spacer(modifier = Modifier.height(16.dp)) + AccessibilitySettings(service = service, sharedPreferences = sharedPreferences) // Spacer(modifier = Modifier.height(16.dp)) // val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 diff --git a/android/app/src/main/java/me/kavishdevar/aln/CustomDevice.kt b/android/app/src/main/java/me/kavishdevar/aln/CustomDevice.kt new file mode 100644 index 0000000..f88783a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/aln/CustomDevice.kt @@ -0,0 +1,159 @@ +package me.kavishdevar.aln + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothDevice.TRANSPORT_LE +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothManager +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import me.kavishdevar.aln.ui.theme.ALNTheme +import org.lsposed.hiddenapibypass.HiddenApiBypass +import java.util.UUID + +class CustomDevice : ComponentActivity() { + @SuppressLint("MissingPermission", "CoroutineCreationDuringComposition", "NewApi") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + ALNTheme { + val connect = remember { mutableStateOf(false) } + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Custom Device", style = MaterialTheme.typography.titleLarge) + } + } + ) { innerPadding -> + HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") + val manager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager +// val device: BluetoothDevice = manager.adapter.getRemoteDevice("EC:D6:F4:3D:89:B8") + val device: BluetoothDevice = manager.adapter.getRemoteDevice("E0:90:8F:D9:94:73") +// val socket = device.createInsecureL2capChannel(31) + +// socket.outputStream.write(byteArrayOf(0x12,0x3B,0x00,0x02, 0x00)) +// socket.outputStream.write(byteArrayOf(0x12, 0x3A, 0x00, 0x01, 0x00, 0x08,0x01)) + + val gatt = device.connectGatt(this, true, object: BluetoothGattCallback() { + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + // Step 2: Iterate through the services and characteristics + gatt.services.forEach { service -> + Log.d("GATT", "Service UUID: ${service.uuid}") + service.characteristics.forEach { characteristic -> + Log.d("GATT", " Characteristic UUID: ${characteristic.uuid}") + } + } + } + } + + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothGatt.STATE_CONNECTED) { + Log.d("GATT", "Connected to GATT server") + gatt.discoverServices() // Discover services after connection + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d("BLE", "Write successful for UUID: ${characteristic.uuid}") + } else { + Log.e("BLE", "Write failed for UUID: ${characteristic.uuid}, status: $status") + } + } + }, TRANSPORT_LE, 1) + + if (connect.value) { + try { + gatt.connect() + } + catch (e: Exception) { + e.printStackTrace() + } + connect.value = false + } + + Column ( + modifier = Modifier.padding(innerPadding), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) + { + Button( + onClick = { connect.value = true } + ) + { + Text("Connect") + } + + Button(onClick = { + val characteristicUuid = "4f860002-943b-49ef-bed4-2f730304427a" + val value = byteArrayOf(0x01, 0x00, 0x02) + + sendWriteRequest(gatt, characteristicUuid, value) + }) { + Text("Play Sound") + } + } + } + } + } + } +} + +@SuppressLint("MissingPermission", "NewApi") +fun sendWriteRequest( + gatt: BluetoothGatt, + characteristicUuid: String, + value: ByteArray +) { + // Retrieve the service containing the characteristic + val service = gatt.services.find { service -> + service.characteristics.any { it.uuid.toString() == characteristicUuid } + } + + if (service == null) { + Log.e("GATT", "Service containing characteristic UUID $characteristicUuid not found.") + return + } + + // Retrieve the characteristic + val characteristic = service.getCharacteristic(UUID.fromString(characteristicUuid)) + if (characteristic == null) { + Log.e("GATT", "Characteristic with UUID $characteristicUuid not found.") + return + } + + + // Send the write request + val success = gatt.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) + Log.d("GATT", "Write request sent $success to UUID: $characteristicUuid") +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt index 153d7e6..db84c3e 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt @@ -1,7 +1,6 @@ package me.kavishdevar.aln import android.annotation.SuppressLint -import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile @@ -43,6 +42,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.getSystemService import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -61,6 +61,43 @@ class MainActivity : ComponentActivity() { setContent { val topAppBarTitle = remember { mutableStateOf("AirPods Pro") } ALNTheme { + val navController = rememberNavController() + registerReceiver(object: BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + val bluetoothDevice = + intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE", BluetoothDevice::class.java) + val action = intent.action + + // Airpods filter + if (bluetoothDevice != null && action != null && !action.isEmpty()) { + Log.d("BluetoothReceiver", "Received broadcast") + // Airpods connected, show notification. + if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { + val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") + if (bluetoothDevice.uuids.contains(uuid)) { + topAppBarTitle.value = bluetoothDevice.name + } + // start service + startService(Intent(context, AirPodsService::class.java).apply { + putExtra("device", bluetoothDevice) + }) + Log.d("AirPodsService", "Service started") + context?.sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTED)) + } + + // Airpods disconnected, remove notification but leave the scanner going. + if (BluetoothDevice.ACTION_ACL_DISCONNECTED == action + || BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED == action + ) { + topAppBarTitle.value = "AirPods Pro" + // stop service + stopService(Intent(context, AirPodsService::class.java)) + Log.d("AirPodsService", "Service stopped") + } + } + } + }, BluetoothReceiver.buildFilter()) + Scaffold ( containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color( 0xFF000000 @@ -109,6 +146,7 @@ fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState) { val airPodsService = remember { mutableStateOf(null) } val navController = rememberNavController() + val disconnectReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { navController.navigate("notConnected") @@ -165,42 +203,8 @@ fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState) { } } - // BroadcastReceiver to listen for connection state changes - val bluetoothReceiver = remember { - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - val action = intent?.action - val device = intent?.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) - if (action == BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED) { - when (intent.getIntExtra(BluetoothAdapter.EXTRA_CONNECTION_STATE, -1)) { - BluetoothAdapter.STATE_CONNECTED -> { - if (device?.uuids?.contains(uuid) == true) { - airpodsDevice.value = device - checkIfAirPodsConnected() - } - } - BluetoothAdapter.STATE_DISCONNECTED -> { - if (device?.uuids?.contains(uuid) == true) { - airpodsDevice.value = null - // Show not connected screen when AirPods disconnect - navController.navigate("notConnected") - } - } - } - } - } - } - } - // Register the receiver in LaunchedEffect LaunchedEffect(Unit) { - val filter = IntentFilter().apply { - addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(bluetoothReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } - // Initial check for AirPods connection checkIfAirPodsConnected() } @@ -230,6 +234,21 @@ fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState) { } } + ContextCompat.registerReceiver( + context, + object : BroadcastReceiver() { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + override fun onReceive(context: Context?, intent: Intent) { + Log.d("PLEASE NAVIGATE", "TO SETTINGS") + navController.navigate("settings") { + popUpTo("notConnected") { inclusive = true } + } + } + }, + IntentFilter(AirPodsNotifications.AIRPODS_CONNECTED), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + // Automatically navigate to settings screen if AirPods are connected if (airpodsDevice.value != null) { LaunchedEffect(Unit) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/receiver.kt b/android/app/src/main/java/me/kavishdevar/aln/receiver.kt new file mode 100644 index 0000000..ab96720 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/aln/receiver.kt @@ -0,0 +1,62 @@ +package me.kavishdevar.aln + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter + +class BluetoothReceiver : BroadcastReceiver() { + fun onConnect(bluetoothDevice: BluetoothDevice?) { + + } + + fun onDisconnect(bluetoothDevice: BluetoothDevice?) { + + } + + @SuppressLint("NewApi") + override fun onReceive(context: Context?, intent: Intent) { + val bluetoothDevice = + intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE", BluetoothDevice::class.java) + val action = intent.action + + // Airpods filter + if (bluetoothDevice != null && action != null && !action.isEmpty()) { + // Airpods connected, show notification. + if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { + onConnect(bluetoothDevice) + } + + // Airpods disconnected, remove notification but leave the scanner going. + if (BluetoothDevice.ACTION_ACL_DISCONNECTED == action + || BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED == action + ) { + onDisconnect(bluetoothDevice) + } + } + } + + companion object { + /** + * When the service is created, we register to get as many bluetooth and airpods related events as possible. + * ACL_CONNECTED and ACL_DISCONNECTED should have been enough, but you never know with android these days. + */ + fun buildFilter(): IntentFilter { + val intentFilter = IntentFilter() + intentFilter.addAction("android.bluetooth.device.action.ACL_CONNECTED") + intentFilter.addAction("android.bluetooth.device.action.ACL_DISCONNECTED") + intentFilter.addAction("android.bluetooth.device.action.BOND_STATE_CHANGED") + intentFilter.addAction("android.bluetooth.device.action.NAME_CHANGED") + intentFilter.addAction("android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED") + intentFilter.addAction("android.bluetooth.adapter.action.STATE_CHANGED") + intentFilter.addAction("android.bluetooth.headset.profile.action.CONNECTION_STATE_CHANGED") + intentFilter.addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT") + intentFilter.addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") + intentFilter.addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED") + intentFilter.addCategory("android.bluetooth.headset.intent.category.companyid.76") + return intentFilter + } + } +} \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 03f6736..3e00038 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ ALN DebugActivity + CustomDevice \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 0059329..e99426a 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] accompanistPermissions = "0.36.0" -agp = "8.7.0" +agp = "8.7.2" hiddenapibypass = "4.3" kotlin = "2.0.0" coreKtx = "1.13.1"