1919package me.kavishdevar.librepods
2020
2121import android.annotation.SuppressLint
22- import android.bluetooth.BluetoothDevice
23- import android.bluetooth.BluetoothManager
24- import android.bluetooth.BluetoothSocket
2522import android.os.Bundle
26- import android.os.ParcelUuid
27- import android.util.Log
2823import androidx.activity.ComponentActivity
2924import androidx.activity.compose.setContent
3025import androidx.activity.enableEdgeToEdge
31- import androidx.compose.runtime.mutableFloatStateOf
32- import androidx.compose.runtime.mutableIntStateOf
33- import androidx.compose.runtime.mutableStateOf
3426import androidx.navigation.compose.NavHost
3527import androidx.navigation.compose.composable
3628import androidx.navigation.compose.rememberNavController
37- import kotlinx.coroutines.CoroutineScope
38- import kotlinx.coroutines.Dispatchers
39- import kotlinx.coroutines.Job
40- import kotlinx.coroutines.delay
41- import kotlinx.coroutines.launch
42- import kotlinx.coroutines.withContext
43- import kotlinx.coroutines.withTimeout
29+ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
4430import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
45- import me.kavishdevar.librepods.screens.EqualizerSettingsScreen
4631import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
47- import org.lsposed.hiddenapibypass.HiddenApiBypass
48- import java.io.IOException
49- import java.nio.ByteBuffer
50- import java.nio.ByteOrder
5132
52- @Suppress( " PrivatePropertyName " )
33+ @ExperimentalHazeMaterialsApi
5334class CustomDevice : ComponentActivity () {
54- private val TAG = " AirPodsAccessibilitySettings"
55- private var socket: BluetoothSocket ? = null
56- private val deviceAddress = " 28:2D:7F:C2:05:5B"
57- private val uuid: ParcelUuid = ParcelUuid .fromString(" 00000000-0000-0000-0000-00000000000" )
58-
59- // Data states
60- private val isConnected = mutableStateOf(false )
61- private val leftAmplification = mutableFloatStateOf(1.0f )
62- private val leftTone = mutableFloatStateOf(1.0f )
63- private val leftAmbientNoiseReduction = mutableFloatStateOf(0.5f )
64- private val leftConversationBoost = mutableStateOf(false )
65- private val leftEQ = mutableStateOf(FloatArray (8 ) { 50.0f })
66-
67- private val rightAmplification = mutableFloatStateOf(1.0f )
68- private val rightTone = mutableFloatStateOf(1.0f )
69- private val rightAmbientNoiseReduction = mutableFloatStateOf(0.5f )
70- private val rightConversationBoost = mutableStateOf(false )
71- private val rightEQ = mutableStateOf(FloatArray (8 ) { 50.0f })
72-
73- private val singleMode = mutableStateOf(false )
74- private val amplification = mutableFloatStateOf(1.0f )
75- private val balance = mutableFloatStateOf(0.5f )
76-
77- private val retryCount = mutableIntStateOf(0 )
78- private val showRetryButton = mutableStateOf(false )
79- private val maxRetries = 3
80-
81- private var debounceJob: Job ? = null
82-
83- // Phone and Media EQ state
84- private val phoneMediaEQ = mutableStateOf(FloatArray (8 ) { 50.0f })
85-
8635 @SuppressLint(" MissingPermission" )
8736 override fun onCreate (savedInstanceState : Bundle ? ) {
8837 super .onCreate(savedInstanceState)
@@ -93,294 +42,14 @@ class CustomDevice : ComponentActivity() {
9342
9443 NavHost (navController = navController, startDestination = " main" ) {
9544 composable(" main" ) {
96- AccessibilitySettingsScreen (
97- navController = navController,
98- isConnected = isConnected.value,
99- leftAmplification = leftAmplification,
100- leftTone = leftTone,
101- leftAmbientNoiseReduction = leftAmbientNoiseReduction,
102- leftConversationBoost = leftConversationBoost,
103- rightAmplification = rightAmplification,
104- rightTone = rightTone,
105- rightAmbientNoiseReduction = rightAmbientNoiseReduction,
106- rightConversationBoost = rightConversationBoost,
107- singleMode = singleMode,
108- amplification = amplification,
109- balance = balance,
110- showRetryButton = showRetryButton.value,
111- onRetry = { CoroutineScope (Dispatchers .IO ).launch { connectL2CAP() } },
112- onSettingsChanged = { sendAccessibilitySettings() }
113- )
114- }
115- composable(" eq" ) {
116- EqualizerSettingsScreen (
117- navController = navController,
118- leftEQ = leftEQ,
119- rightEQ = rightEQ,
120- singleMode = singleMode,
121- onEQChanged = { sendAccessibilitySettings() },
122- phoneMediaEQ = phoneMediaEQ
123- )
45+ AccessibilitySettingsScreen ()
12446 }
12547 }
12648 }
12749 }
128-
129- // Connect automatically
130- CoroutineScope (Dispatchers .IO ).launch { connectL2CAP() }
13150 }
13251
13352 override fun onDestroy () {
13453 super .onDestroy()
135- socket?.close()
136- }
137-
138- @SuppressLint(" MissingPermission" )
139- private suspend fun connectL2CAP () {
140- retryCount.intValue = 0
141- // Close any existing socket
142- socket?.close()
143- socket = null
144- while (retryCount.intValue < maxRetries) {
145- try {
146- Log .d(TAG , " Starting L2CAP connection setup, attempt ${retryCount.intValue + 1 } " )
147- HiddenApiBypass .addHiddenApiExemptions(" Landroid/bluetooth/BluetoothSocket;" )
148- val manager = getSystemService(BLUETOOTH_SERVICE ) as BluetoothManager
149- val device: BluetoothDevice = manager.adapter.getRemoteDevice(deviceAddress)
150- socket = createBluetoothSocket(device)
151-
152- withTimeout(5000L ) {
153- socket?.connect()
154- }
155-
156- withContext(Dispatchers .Main ) {
157- isConnected.value = true
158- showRetryButton.value = false
159- Log .d(TAG , " L2CAP connection established successfully" )
160- }
161-
162- // Read current settings
163- readCurrentSettings()
164-
165- // Start listening for responses
166- listenForData()
167-
168- return
169- } catch (e: Exception ) {
170- Log .e(TAG , " Failed to connect, attempt ${retryCount.intValue + 1 } : ${e.message} " )
171- retryCount.intValue++
172- if (retryCount.intValue < maxRetries) {
173- delay(2000 ) // Wait 2 seconds before retry
174- }
175- }
176- }
177-
178- // After max retries
179- withContext(Dispatchers .Main ) {
180- isConnected.value = false
181- showRetryButton.value = true
182- Log .e(TAG , " Failed to connect after $maxRetries attempts" )
183- }
184- }
185-
186- private fun createBluetoothSocket (device : BluetoothDevice ): BluetoothSocket {
187- val type = 3 // L2CAP
188- val constructorSpecs = listOf (
189- arrayOf(device, type, true , true , 31 , uuid),
190- arrayOf(device, type, 1 , true , true , 31 , uuid),
191- arrayOf(type, 1 , true , true , device, 31 , uuid),
192- arrayOf(type, true , true , device, 31 , uuid)
193- )
194-
195- val constructors = BluetoothSocket ::class .java.declaredConstructors
196- Log .d(TAG , " BluetoothSocket has ${constructors.size} constructors" )
197-
198- var lastException: Exception ? = null
199- var attemptedConstructors = 0
200-
201- for ((index, params) in constructorSpecs.withIndex()) {
202- try {
203- Log .d(TAG , " Trying constructor signature #${index + 1 } " )
204- attemptedConstructors++
205- return HiddenApiBypass .newInstance(BluetoothSocket ::class .java, * params) as BluetoothSocket
206- } catch (e: Exception ) {
207- Log .e(TAG , " Constructor signature #${index + 1 } failed: ${e.message} " )
208- lastException = e
209- }
210- }
211-
212- val errorMessage = " Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
213- Log .e(TAG , errorMessage)
214- throw lastException ? : IllegalStateException (errorMessage)
215- }
216-
217- private fun readCurrentSettings () {
218- CoroutineScope (Dispatchers .IO ).launch {
219- try {
220- Log .d(TAG , " Sending read settings command: 0A1800" )
221- val readCommand = byteArrayOf(0x0A , 0x18 , 0x00 )
222- socket?.outputStream?.write(readCommand)
223- socket?.outputStream?.flush()
224- Log .d(TAG , " Read settings command sent" )
225- } catch (e: IOException ) {
226- Log .e(TAG , " Failed to send read command: ${e.message} " )
227- }
228- }
229- }
230-
231- private fun listenForData () {
232- CoroutineScope (Dispatchers .IO ).launch {
233- try {
234- val buffer = ByteArray (1024 )
235- Log .d(TAG , " Started listening for incoming data" )
236- while (socket?.isConnected == true ) {
237- val bytesRead = socket?.inputStream?.read(buffer)
238- if (bytesRead != null && bytesRead > 0 ) {
239- val data = buffer.copyOfRange(0 , bytesRead)
240- Log .d(TAG , " Received data: ${data.joinToString(" " ) { " %02X" .format(it) }} " )
241- parseSettingsResponse(data)
242- } else if (bytesRead == - 1 ) {
243- Log .d(TAG , " Connection closed by remote device" )
244- withContext(Dispatchers .Main ) {
245- isConnected.value = false
246- }
247- // Attempt to reconnect
248- connectL2CAP()
249- break
250- }
251- }
252- } catch (e: IOException ) {
253- Log .e(TAG , " Connection lost: ${e.message} " )
254- withContext(Dispatchers .Main ) {
255- isConnected.value = false
256- }
257- // Close socket
258- socket?.close()
259- socket = null
260- // Attempt to reconnect
261- connectL2CAP()
262- }
263- }
264- }
265-
266- private fun parseSettingsResponse (data : ByteArray ) {
267- if (data.size < 2 || data[0 ] != 0x0B .toByte()) {
268- Log .d(TAG , " Not a settings response" )
269- return
270- }
271-
272- val settingsData = data.copyOfRange(1 , data.size)
273- if (settingsData.size < 100 ) { // 25 floats * 4 bytes
274- Log .e(TAG , " Settings data too short: ${settingsData.size} bytes" )
275- return
276- }
277-
278- val buffer = ByteBuffer .wrap(settingsData).order(ByteOrder .LITTLE_ENDIAN )
279-
280- // Global enabled
281- val enabled = buffer.float
282- Log .d(TAG , " Parsed enabled: $enabled " )
283-
284- // Left bud
285- val newLeftEQ = leftEQ.value.copyOf()
286- for (i in 0 .. 7 ) {
287- newLeftEQ[i] = buffer.float
288- Log .d(TAG , " Parsed left EQ${i+ 1 } : ${newLeftEQ[i]} " )
289- }
290- leftEQ.value = newLeftEQ
291- if (singleMode.value) rightEQ.value = newLeftEQ
292-
293- leftAmplification.floatValue = buffer.float
294- Log .d(TAG , " Parsed left amplification: ${leftAmplification.floatValue} " )
295- leftTone.floatValue = buffer.float
296- Log .d(TAG , " Parsed left tone: ${leftTone.floatValue} " )
297- if (singleMode.value) rightTone.floatValue = leftTone.floatValue
298- val leftConvFloat = buffer.float
299- leftConversationBoost.value = leftConvFloat > 0.5f
300- Log .d(TAG , " Parsed left conversation boost: $leftConvFloat (${leftConversationBoost.value} )" )
301- if (singleMode.value) rightConversationBoost.value = leftConversationBoost.value
302- leftAmbientNoiseReduction.floatValue = buffer.float
303- Log .d(TAG , " Parsed left ambient noise reduction: ${leftAmbientNoiseReduction.floatValue} " )
304- if (singleMode.value) rightAmbientNoiseReduction.floatValue = leftAmbientNoiseReduction.floatValue
305-
306- // Right bud
307- val newRightEQ = rightEQ.value.copyOf()
308- for (i in 0 .. 7 ) {
309- newRightEQ[i] = buffer.float
310- Log .d(TAG , " Parsed right EQ${i+ 1 } : ${newRightEQ[i]} " )
311- }
312- rightEQ.value = newRightEQ
313-
314- rightAmplification.floatValue = buffer.float
315- Log .d(TAG , " Parsed right amplification: ${rightAmplification.floatValue} " )
316- rightTone.floatValue = buffer.float
317- Log .d(TAG , " Parsed right tone: ${rightTone.floatValue} " )
318- val rightConvFloat = buffer.float
319- rightConversationBoost.value = rightConvFloat > 0.5f
320- Log .d(TAG , " Parsed right conversation boost: $rightConvFloat (${rightConversationBoost.value} )" )
321- rightAmbientNoiseReduction.floatValue = buffer.float
322- Log .d(TAG , " Parsed right ambient noise reduction: ${rightAmbientNoiseReduction.floatValue} " )
323-
324- Log .d(TAG , " Settings parsed successfully" )
325-
326- // Update single mode values if in single mode
327- if (singleMode.value) {
328- val avg = (leftAmplification.floatValue + rightAmplification.floatValue) / 2
329- amplification.floatValue = avg.coerceIn(0f , 1f )
330- val diff = rightAmplification.floatValue - leftAmplification.floatValue
331- balance.floatValue = (0.5f + diff / (2 * avg)).coerceIn(0f , 1f )
332- }
333- }
334-
335- private fun sendAccessibilitySettings () {
336- if (! isConnected.value || socket == null ) {
337- Log .w(TAG , " Not connected, cannot send settings" )
338- return
339- }
340-
341- debounceJob?.cancel()
342- debounceJob = CoroutineScope (Dispatchers .IO ).launch {
343- delay(100 )
344- try {
345- val buffer = ByteBuffer .allocate(103 ).order(ByteOrder .LITTLE_ENDIAN ) // 3 header + 100 data bytes
346-
347- buffer.put(0x12 )
348- buffer.put(0x18 )
349- buffer.put(0x00 )
350- buffer.putFloat(1.0f ) // enabled
351-
352- // Left bud
353- for (eq in leftEQ.value) {
354- buffer.putFloat(eq)
355- }
356- buffer.putFloat(leftAmplification.floatValue)
357- buffer.putFloat(leftTone.floatValue)
358- buffer.putFloat(if (leftConversationBoost.value) 1.0f else 0.0f )
359- buffer.putFloat(leftAmbientNoiseReduction.floatValue)
360-
361- // Right bud
362- for (eq in rightEQ.value) {
363- buffer.putFloat(eq)
364- }
365- buffer.putFloat(rightAmplification.floatValue)
366- buffer.putFloat(rightTone.floatValue)
367- buffer.putFloat(if (rightConversationBoost.value) 1.0f else 0.0f )
368- buffer.putFloat(rightAmbientNoiseReduction.floatValue)
369-
370- val packet = buffer.array()
371- Log .d(TAG , " Packet length: ${packet.size} " )
372- socket?.outputStream?.write(packet)
373- socket?.outputStream?.flush()
374- Log .d(TAG , " Accessibility settings sent: ${packet.joinToString(" " ) { " %02X" .format(it) }} " )
375- } catch (e: IOException ) {
376- Log .e(TAG , " Failed to send accessibility settings: ${e.message} " )
377- withContext(Dispatchers .Main ) {
378- isConnected.value = false
379- }
380- // Close socket
381- socket?.close()
382- socket = null
383- }
384- }
38554 }
38655}
0 commit comments