Skip to content

Commit c53356f

Browse files
committed
android: implement the accessiblity settings page
1 parent fa00620 commit c53356f

File tree

13 files changed

+841
-1004
lines changed

13 files changed

+841
-1004
lines changed

android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt

Lines changed: 3 additions & 334 deletions
Original file line numberDiff line numberDiff line change
@@ -19,70 +19,19 @@
1919
package me.kavishdevar.librepods
2020

2121
import android.annotation.SuppressLint
22-
import android.bluetooth.BluetoothDevice
23-
import android.bluetooth.BluetoothManager
24-
import android.bluetooth.BluetoothSocket
2522
import android.os.Bundle
26-
import android.os.ParcelUuid
27-
import android.util.Log
2823
import androidx.activity.ComponentActivity
2924
import androidx.activity.compose.setContent
3025
import androidx.activity.enableEdgeToEdge
31-
import androidx.compose.runtime.mutableFloatStateOf
32-
import androidx.compose.runtime.mutableIntStateOf
33-
import androidx.compose.runtime.mutableStateOf
3426
import androidx.navigation.compose.NavHost
3527
import androidx.navigation.compose.composable
3628
import 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
4430
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
45-
import me.kavishdevar.librepods.screens.EqualizerSettingsScreen
4631
import 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
5334
class 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
}

android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,6 @@ fun AccessibilitySettings() {
154154
},
155155
textColor = textColor
156156
)
157-
158-
SinglePodANCSwitch()
159-
VolumeControlSwitch()
160157
}
161158
}
162159

android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ import androidx.compose.ui.Alignment
4242
import androidx.compose.ui.Modifier
4343
import androidx.compose.ui.graphics.Color
4444
import androidx.compose.ui.input.pointer.pointerInput
45+
import androidx.compose.ui.res.stringResource
4546
import androidx.compose.ui.tooling.preview.Preview
4647
import androidx.compose.ui.unit.dp
4748
import androidx.compose.ui.unit.sp
49+
import me.kavishdevar.librepods.R
4850
import me.kavishdevar.librepods.services.ServiceManager
4951
import me.kavishdevar.librepods.utils.AACPManager
5052
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -111,7 +113,7 @@ fun ConversationalAwarenessSwitch() {
111113
)
112114
Spacer(modifier = Modifier.height(4.dp))
113115
Text(
114-
text = "Lowers media volume and reduces background noise when you start speaking to other people.",
116+
text = stringResource(R.string.conversational_awareness_description),
115117
fontSize = 12.sp,
116118
color = textColor.copy(0.6f),
117119
lineHeight = 14.sp,

0 commit comments

Comments
 (0)