Compare commits

..

16 Commits

Author SHA1 Message Date
Kavish Devar
7341e41837 android: add custom EQ settings (ios27)
will be released into stable as soon as I implement capability parsing
2026-06-13 04:58:14 +05:30
Kavish Devar
bffb5c8b3e android: consider all A17 devices supported
Google's statements were ambiguous on if the workaround will be available on A17 or still on OEM to implement this specific patch. But the app does work on OneUI 9
2026-06-10 14:43:36 +05:30
Kavish Devar
aca4373ec4 android: fix widget not showing charging when charge limit is enabled 2026-06-10 14:39:46 +05:30
Kavish Devar
8804197760 android: bump version 2026-06-04 11:19:33 +05:30
Kavish Devar
57d692c4ae android: refactor AACP socket handling 2026-06-01 14:53:33 +05:30
jiggles
0477674810 android: set audiofocus none in popup video views (#611)
fixed popups interrupting media playback
2026-06-01 16:29:59 +05:30
Kavish Devar
c1093fbe24 android: fix FOSS upgraded being written false on app launch
fixes #610
2026-06-01 12:43:57 +05:30
Kavish Devar
0f50eab788 android: move ATT code to viewmodel from screens and enable notifications 2026-05-31 00:48:20 +05:30
Kavish Devar
1381022b2e android: fix name field being empty on rename screen launch 2026-05-31 00:48:20 +05:30
Kavish Devar
af4261485a android: fix rework ATT connection 2026-05-31 00:48:20 +05:30
Kavish Devar
571db0ebde android: listen to UUID broadcasts 2026-05-31 00:48:20 +05:30
Kavish Devar
3c3c0edffd android: add message for Play users who unlocked FOSS upgrade 2026-05-31 00:48:20 +05:30
Kavish Devar
f86d7b9aca android: fix PLAY_BUILD flag 2026-05-31 00:48:20 +05:30
Kavish Devar
29a914c2ff docs: update 2-way audio description for Android in project README 2026-05-19 02:05:07 +05:30
Kavish Devar
3f2a7df749 docs: remove installation instructions from project README 2026-05-16 10:10:06 +05:30
Kavish Devar
f9367f4445 docs: fix root requirement link in repo README 2026-05-16 10:01:57 +05:30
29 changed files with 1627 additions and 822 deletions

View File

@@ -31,46 +31,6 @@ Development paused due to lack of time until June 2026 (JEE Advanced). PRs and i
LibrePods allows you to use AirPods features that are exclusive to Apple devices. It implements the proprietary protocol used to exchange data between AirPods and Apple devices, enabling features like changing noise control modes, fast ear detection, accurate battery status, head gestures, conversational awareness, and more on non-Apple platforms.
# Installation
> [!IMPORTANT]
> Before installing, please read the [feature availability](#feature-availability) and platform-specific READMEs.
## Android
### README: [android/README.md](./android/README.md)
### Google Play Store
If you are using a supported device/OS combination listed in the [root requirements section](/android/#root-requirement), you can install LibrePods from the Google Play Store. You can use the VendorID hook features with root even from the Play Store version.
<a href="https://play.google.com/store/apps/details?id=me.kavishdevar.librepods"><img width="170" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/2948308f-af92-443f-94d9-ee381c3a6ccc"/></a>
### GitHub Releases
If you need xposed because of the [root requirement](#root-requirement), you will have to use the apk/zip from the [GitHub releases](https://github.com/kavishdevar/librepods/releases/latest).
### Root Module
If you want LibrePods to have privileged Bluetooth permissions to
- show battery status in the system settings and widgets
- show AirPods icon in the system settings (xposed is also currently required for this)
- disconnect AirPods when you take them out of your ears
## Linux
### README: [linux/README.md](./linux/README.md)
### GitHub Releases
The app is ready to download as an AppImage or an executable. You can download the latest pre-release from the [GitHub releases](https://github.com/kavishdevar/librepods/releases?q="linux-v0").
### Nightly Builds (recommended)
You can also try the latest build of the new version from the [GitHub Actions artifacts](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml). On the latest successful workflow run, download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**.
# Feature availability
| Feature | Linux | Android |
@@ -93,8 +53,8 @@ You can also try the latest build of the new version from the [GitHub Actions ar
| [Find My](#find-my) | ❓ | ❓ |
| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 |
| Emoji | Meaning |
| ----- | ------------------------------------------------------------------- |
| Symbol | Meaning |
| ------ | ------------------------------------------------------------------- |
| ✅ | Implemented and works well |
| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk |
| 🔴 | Not implemented yet; planned |
@@ -122,9 +82,12 @@ This is being worked upon, check the #reverse-engineering channel on the Libr
## High quality two-way audio
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
Since there is no way on Android to have a virtual audio source which can be used for calls where the LibrePods app can provide the higher quality microphone stream, the app will need root on Android.s
Since this needs deeper integration with audio on Android, it will most likely need root.
&ast; Features marked with an asterisk require the VendorID to be change to that of Apple.
# Installation
- [**Android**](/android/README.md)
- [**Linux**](/linux/README.md)
# VendorID Spoofing

View File

@@ -1,6 +1,6 @@
import java.util.Properties
val appVersionName = "0.2.9"
val appVersionName = "0.3.0"
plugins {
alias(libs.plugins.android.application)
@@ -41,7 +41,7 @@ android {
defaultConfig {
applicationId = "me.kavishdevar.librepods"
targetSdk = 37
versionCode = 52
versionCode = 56
versionName = appVersionName
}
buildTypes {
@@ -56,7 +56,6 @@ android {
arguments += "-DCMAKE_BUILD_TYPE=Release"
}
}
buildConfigField("Boolean", "PLAY_BUILD", "false")
if (releaseSigningAvailable) {
signingConfig = signingConfigs.getByName("release")
}
@@ -65,7 +64,6 @@ android {
}
}
debug {
buildConfigField("Boolean", "PLAY_BUILD", "false")
if (releaseSigningAvailable) {
signingConfig = signingConfigs.getByName("release")
}

View File

@@ -118,6 +118,7 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.components.AppInfoCard
@@ -129,6 +130,7 @@ import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
import me.kavishdevar.librepods.presentation.screens.DebugScreen
import me.kavishdevar.librepods.presentation.screens.EqualizerScreen
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
@@ -466,7 +468,7 @@ fun Main() {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
if (airPodsViewModel != null) UpdateHearingTestScreen()
if (airPodsViewModel != null) UpdateHearingTestScreen(airPodsViewModel)
}
composable("version_info") {
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
@@ -478,6 +480,9 @@ fun Main() {
val purchaseViewModel: PurchaseViewModel = viewModel()
PurchaseScreen(purchaseViewModel, navController)
}
composable("equalizer_screen") {
if (airPodsViewModel != null) EqualizerScreen(airPodsViewModel)
}
}
}
@@ -541,7 +546,7 @@ fun Main() {
Context.BIND_AUTO_CREATE
)
if (airPodsService.value?.isConnected() == true) {
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
isConnected.value = true
}
} else {

View File

@@ -21,6 +21,8 @@
package me.kavishdevar.librepods.bluetooth
import android.util.Log
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.CustomEq
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -31,9 +33,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
* constructing and parsing packets for communication with AirPods.
*/
class AACPManager {
private val TAG = "AACPManager[${System.identityHashCode(this)}]"
companion object {
private const val TAG = "AACPManager"
@Suppress("unused")
object Opcodes {
const val SET_FEATURE_FLAGS: Byte = 0x4D
@@ -48,7 +49,7 @@ class AACPManager {
const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31
const val STEM_PRESS: Byte = 0x19
const val EQ_DATA: Byte = 0x53
const val HEADPHONE_ACCOMMODATION: Byte = 0x53
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
const val SMART_ROUTING: Byte = 0x10
@@ -56,6 +57,7 @@ class AACPManager {
const val SMART_ROUTING_RESP: Byte = 0x11
const val SEND_CONNECTED_MAC: Byte = 0x14
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
const val CUSTOM_EQ: Byte = 0x63
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -200,6 +202,11 @@ class AACPManager {
var eqOnMedia: Boolean = false
private set
var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50)
private set
var customEqCallback: ((CustomEq) -> Unit)? = null
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
return controlCommandStatusList.find { it.identifier == identifier }
}
@@ -236,7 +243,9 @@ class AACPManager {
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
fun onShowNearbyUI(sender: String)
fun onEQPacketReceived(eqData: FloatArray)
fun onHeadphoneAccommodationReceived(eqData: FloatArray)
fun onCustomEqReceived(customEq: CustomEq)
fun onCapabilitiesReceived(capabilities: List<Capability>)
}
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -549,18 +558,18 @@ class AACPManager {
}
}
Opcodes.EQ_DATA -> {
Opcodes.HEADPHONE_ACCOMMODATION -> {
if (packet.size != 140) {
Log.w(
TAG,
"Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140"
"Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140"
)
return
}
if (packet[6] != 0x84.toByte()) {
Log.w(
TAG,
"Received EQ_DATA packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
"Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
)
return
}
@@ -583,7 +592,7 @@ class AACPManager {
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
)
callback?.onEQPacketReceived(eqData)
callback?.onHeadphoneAccommodationReceived(eqData)
}
Opcodes.INFORMATION -> {
@@ -592,6 +601,13 @@ class AACPManager {
callback?.onDeviceInformationReceived(information)
}
Opcodes.CUSTOM_EQ -> {
Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}")
customEq = parseCustomEqPacket(packet)
customEqCallback?.invoke(customEq)
callback?.onCustomEqReceived(customEq)
}
else -> {
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet)
@@ -1143,7 +1159,7 @@ class AACPManager {
)
}
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
val socket = BluetoothConnectionManager.getAACPSocket() ?: return false
if (socket.isConnected) {
socket.outputStream?.write(packet)
@@ -1297,4 +1313,38 @@ class AACPManager {
version3 = strings.getOrNull(10) ?: "",
)
}
fun sendCustomEqPacket(customEq: CustomEq): Boolean {
return sendDataPacket(customEq.toPacket())
}
fun parseCustomEqPacket(packet: ByteArray): CustomEq {
val data = packet.sliceArray(6 until packet.size)
if (data.size < 7) {
Log.e(TAG, "custom EQ packet length less than 7, returning default")
return CustomEq(1, 50, 50, 50)
}
val lengthLow = data[0].toInt() and 0xFF
val lengthHigh = data[1].toInt() and 0xFF
val length = (lengthHigh shl 8) or lengthLow
if (length != 5) {
Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally")
}
val state = data[3].toInt()
val low = data[4].toInt()
val mid = data[5].toInt()
val high = data[6].toInt()
return CustomEq(
state,
low,
mid,
high
)
}
}

View File

@@ -16,234 +16,196 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
* what is necessary for LibrePods to function, i.e. reading and writing characteristics,
* and receiving notifications. It is not a complete implementation of the ATT protocol.
*/
package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "ATTManager"
enum class ATTHandles(val value: Int) {
TRANSPARENCY(0x18),
LOUD_SOUND_REDUCTION(0x1B),
HEARING_AID(0x2A),
HEARING_AID(0x2A)
}
enum class ATTCCCDHandles(val value: Int) {
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
}
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
companion object {
private const val TAG = "ATTManager"
class ATTManagerv2 {
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
private const val OPCODE_READ_REQUEST: Byte = 0x0A
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
private val readerRunning = AtomicBoolean(false)
private var readerThread: Thread? = null
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
fun startReader() {
if (readerRunning.getAndSet(true)) return
readerThread = Thread {
try {
runReaderLoop()
} catch (t: Throwable) {
Log.e(TAG, "reader thread crashed: ${t.message}", t)
} finally {
readerRunning.set(false)
Log.d(TAG, "reader thread stopped")
}
}.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() }
Log.d(TAG, "reader started")
}
var socket: BluetoothSocket? = null
private var input: InputStream? = null
private var output: OutputStream? = null
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
private var notificationJob: Job? = null
fun stopReader() {
readerRunning.set(false)
readerThread?.interrupt()
readerThread = null
}
// queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>()
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
onNotificationReceived = listener
}
@SuppressLint("MissingPermission")
fun connect() {
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
fun enableNotification(handle: ATTCCCDHandles) {
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
}
fun getCharacteristic(handle: ATTHandles): ByteArray? {
val storedValue = characteristicList[handle]
return if (storedValue?.isNotEmpty() != true) {
readCharacteristic(handle)
} else storedValue
}
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
val socket = BluetoothConnectionManager.getATTSocket() ?: return null
try {
socket = createBluetoothSocket(adapter, device, uuid)
val output = socket.outputStream
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
synchronized(output) {
output.write(pdu)
output.flush()
}
Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val resp = waitForResponse(0x0B, timeoutMillis) ?: run {
Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}")
return null
}
Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
val value = resp.copyOfRange(1, resp.size)
characteristicList[handle] = value
return value
} catch (e: Exception) {
Log.w(TAG, "Failed to create socket")
Log.e(TAG, "error reading characteristic: ${e.message}")
return null
}
}
fun writeCharacteristic(handle: ATTHandles, data: ByteArray, timeoutMillis: Long = 2000) {
characteristicList[handle] = data
writeCharacteristic(handle.value.toByte(), data, timeoutMillis)
}
fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) {
val socket = BluetoothConnectionManager.getATTSocket() ?: return
try {
socket!!.connect()
val output = socket.outputStream
val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE
synchronized(output) {
output.write(pdu)
output.flush()
}
Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val resp = waitForResponse(0x13, timeoutMillis) ?: run {
Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}")
return
}
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.w(TAG, "ATT socket failed to connect")
Log.e(TAG, "error writing characteristic: ${e.message}")
}
}
fun disconnected() {
characteristicList.clear()
stopReader()
val socket = BluetoothConnectionManager.getATTSocket() ?: return
try {
socket.close()
} catch (e: Exception) {
Log.w(TAG, "error closing socket: ${e.message}")
}
Log.d(TAG, "ATT disconnected")
}
private fun runReaderLoop() {
val socket = BluetoothConnectionManager.getATTSocket() ?: run {
Log.w(TAG, "ATT socket not available. stopping reader")
readerRunning.set(false)
return
}
input = socket!!.inputStream
output = socket!!.outputStream
Log.d(TAG, "Connected to ATT")
notificationJob = CoroutineScope(Dispatchers.IO).launch {
while (socket?.isConnected == true) {
try {
val pdu = readPDU()
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
// notification -> dispatch to listeners
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
val value = pdu.copyOfRange(3, pdu.size)
listeners[handle]?.forEach { listener ->
try {
listener(value)
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
}
val input = socket.inputStream
val buffer = ByteArray(512)
while (readerRunning.get()) {
try {
val len = input.read(buffer)
if (len == -1) {
Log.w(TAG, "ATT input stream ended")
break
}
val data = buffer.copyOfRange(0, len)
if (data.isEmpty()) continue
val opcode = data[0]
Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}")
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
queue.offer(data)
if (opcode == 0x1B.toByte()) {
if (data.size >= 3) {
val handle = data[1]
val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0)
Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}")
try {
onNotificationReceived?.invoke(handle, value)
} catch (t: Throwable) {
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
}
} else {
// not a notification -> treat as a response for pending request(s)
responses.put(pdu)
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading notification/response: ${e.message}")
if (socket?.isConnected != true) break
}
}
}
}
fun disconnect() {
try {
notificationJob?.cancel()
socket?.close()
} catch (e: Exception) {
Log.w(TAG, "Error closing socket: ${e.message}")
}
}
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
}
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
listeners[handle.value]?.remove(listener)
}
fun enableNotifications(handle: ATTHandles) {
write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00))
}
fun read(handle: ATTHandles): ByteArray {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
writeRaw(pdu)
// wait for response placed into responses queue by the reader coroutine
return readResponse()
}
fun write(handle: ATTHandles, value: ByteArray) {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
try {
readResponse()
} catch (e: Exception) {
Log.w(TAG, "No write response received: ${e.message}")
}
}
fun write(handle: ATTCCCDHandles, value: ByteArray) {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
try {
readResponse()
} catch (e: Exception) {
Log.w(TAG, "No write response received: ${e.message}")
}
}
private fun writeRaw(pdu: ByteArray) {
if (output == null) return
output?.write(pdu)
output?.flush()
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
}
// rename / specialize: read raw PDU directly from input stream (blocking)
private fun readPDU(): ByteArray {
val inp = input ?: throw IllegalStateException("Not connected")
val buffer = ByteArray(512)
val len = inp.read(buffer)
if (len == -1) {
disconnect()
throw IllegalStateException("End of stream reached")
}
val data = buffer.copyOfRange(0, len)
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
return data
}
// wait for a response PDU produced by the background reader
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
try {
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
return resp.copyOfRange(1, resp.size)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw IllegalStateException("Interrupted while waiting for ATT response", e)
}
}
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 31, uuid),
arrayOf(device, type, true, true, 31, uuid),
arrayOf(device, type, 1, true, true, 31, uuid),
arrayOf(type, 1, true, true, device, 31, uuid),
arrayOf(type, true, true, device, 31, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("ATTManager", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
attemptedConstructors++
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) {
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
Log.e(TAG, "error in reader loop: ${e.message}", e)
break
}
}
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("ATTManager", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
readerRunning.set(false)
}
private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? {
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
return try {
queue.poll(timeoutMillis, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}

View File

@@ -18,23 +18,22 @@
package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
object BluetoothConnectionManager {
private const val TAG = "BluetoothConnectionManager"
private var aacpSocket: BluetoothSocket? = null
private var attSocket: BluetoothSocket? = null
private var currentSocket: BluetoothSocket? = null
private var currentDevice: BluetoothDevice? = null
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
currentSocket = socket
currentDevice = device
Log.d(TAG, "Current connection set to device: ${device.address}")
fun setCurrentConnection(aacpSocket: BluetoothSocket?, attSocket: BluetoothSocket?) {
BluetoothConnectionManager.aacpSocket = aacpSocket
BluetoothConnectionManager.attSocket = attSocket
}
fun getCurrentSocket(): BluetoothSocket? {
return currentSocket
fun getAACPSocket(): BluetoothSocket? {
return aacpSocket
}
fun getATTSocket(): BluetoothSocket? {
return attSocket
}
}

View File

@@ -0,0 +1,27 @@
package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.bluetooth.AACPManager
enum class CustomEqBand { LOW, MID, HIGH }
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
fun isEnabled(): Boolean {
return state == 2
}
fun toPacket(): ByteArray {
return byteArrayOf(
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
0x05, 0x00, // length (LE)
0x01, state.toByte(),
low.toByte(), mid.toByte(), high.toByte()
)
}
init {
require(low in 0..100) { "low must be between 0 and 100, was $low" }
require(mid in 0..100) { "mid must be between 0 and 100, was $mid" }
require(high in 0..100) { "high must be between 0 and 100, was $high" }
}
}

View File

@@ -26,7 +26,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManager
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -138,15 +137,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
}
fun sendHearingAidSettings(
attManager: ATTManager,
currentData: ByteArray,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>
debounceJob: MutableState<Job?>,
sender: (ATTHandles, ByteArray) -> Unit
) {
debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) {
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
@@ -184,7 +183,7 @@ fun sendHearingAidSettings(
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
sender(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}

View File

@@ -22,8 +22,10 @@ import android.os.Parcelable
import android.util.Log
import kotlinx.parcelize.Parcelize
// TODO: Remove everything but Battery-related stuff
enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
NOISE_CANCELLATION(byteArrayOf(0x0d)),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 0x00)),
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
@@ -81,12 +83,12 @@ class AirPodsNotifications {
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
}
class EarDetection {
private val notificationBit = Capabilities.EAR_DETECTION
private val notificationBit = 6.toByte()
private val notificationPrefix = Enums.PREFIX.value + notificationBit
var status: List<Byte> = listOf(0x01, 0x01)
@@ -243,13 +245,6 @@ class AirPodsNotifications {
}
}
class Capabilities {
companion object {
val NOISE_CANCELLATION = byteArrayOf(0x0d)
val EAR_DETECTION = byteArrayOf(0x06)
}
}
fun isHeadTrackingData(data: ByteArray): Boolean {
if (data.size <= 60) return false

View File

@@ -83,7 +83,8 @@ data class TransparencySettings(
}
}
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
if (data.size < 50) return null // 50 is arbitrary, too lazy to count
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float

View File

@@ -53,6 +53,7 @@ fun AudioSettings(
conversationalAwarenessCapability: Boolean,
loudSoundReductionCapability: Boolean,
adaptiveAudioCapability: Boolean,
customEqCapability: Boolean,
adaptiveVolumeChecked: Boolean,
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
@@ -157,6 +158,20 @@ fun AudioSettings(
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
}
if (customEqCapability) {
NavigationButton(
to = "equalizer_screen",
name = stringResource(R.string.equalizer),
navController = navController,
independent = false
)
}
}
}
@@ -170,6 +185,7 @@ fun AudioSettingsPreview() {
conversationalAwarenessCapability = true,
loudSoundReductionCapability = true,
adaptiveAudioCapability = true,
customEqCapability = true,
adaptiveVolumeChecked = true,
onAdaptiveVolumeCheckedChange = { },
conversationalAwarenessChecked = true,

View File

@@ -140,7 +140,7 @@ half4 main(float2 coord) {
}
drawRect(color)
} else {
if (isPressed) {
if (isPressed && enabled) {
drawRect(Color.Black.copy(alpha = 0.4f))
drawRect(Color.White.copy(alpha = 0.2f))
}
@@ -264,29 +264,38 @@ half4 main(float2 coord) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch {
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
if (enabled) {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch {
progressAnimation.animateTo(
0f,
progressAnimationSpec
)
}
launch {
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
}
}
}
}
inspectDragGestures(
onDragStart = { down ->
pressStartPosition = down.position
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
if (enabled) {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
}
launch { offsetAnimation.snapTo(Offset.Zero) }
}
launch { offsetAnimation.snapTo(Offset.Zero) }
}
},
onDragEnd = {
@@ -294,11 +303,13 @@ half4 main(float2 coord) {
},
onDragCancel = onDragStop
) { _, dragAmount ->
scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
if (enabled) {
scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
}

View File

@@ -33,6 +33,7 @@ import android.content.IntentFilter
import android.content.res.Resources
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
@@ -374,6 +375,7 @@ class IslandWindow(private val context: Context) {
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
videoView.setVideoURI(videoUri)
videoView.setOnPreparedListener { mediaPlayer ->
mediaPlayer.isLooping = true

View File

@@ -29,6 +29,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.PixelFormat
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
@@ -137,6 +138,7 @@ class PopupWindow(
updateBatteryStatus(batteryNotification)
val vid = mView.findViewById<VideoView>(R.id.video)
vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
vid.resolveAdjustedSize(vid.width, vid.height)
vid.start()

View File

@@ -23,10 +23,14 @@ package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -51,6 +55,7 @@ import androidx.compose.runtime.remember
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.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
@@ -64,6 +69,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
@@ -93,6 +99,7 @@ import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.util.concurrent.TimeUnit
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -170,6 +177,44 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
}
} else Modifier)) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
item(key = "play_update_banner") {
if (state.timeUntilFOSSPremiumExpiry > 0L) {
val context = LocalContext.current
Box(
modifier = Modifier
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
.clickable {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
putExtra(
Intent.EXTRA_TEXT,
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
)
}
context.startActivity(emailIntent)
}
) {
Text(
text = stringResource(
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
),
modifier = Modifier
.padding(16.dp),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
item(key = "battery") {
BatteryView(
batteryList = state.battery,
@@ -320,6 +365,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
conversationalAwarenessCapability = conversationalAwarenessCapability,
loudSoundReductionCapability = loudSoundReductionCapability,
adaptiveAudioCapability = adaptiveAudioCapability,
customEqCapability = true,
adaptiveVolumeChecked = adaptiveVolumeChecked,
onAdaptiveVolumeCheckedChange = { checked ->
viewModel.setControlCommandBoolean(

View File

@@ -24,6 +24,7 @@ import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -91,6 +92,7 @@ import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -147,7 +149,39 @@ fun AppSettingsScreen(
)
}
}
if (state.timeUntilFOSSPremiumExpiry > 0L) {
Box(
modifier = Modifier
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
.clickable {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
putExtra(
Intent.EXTRA_TEXT,
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
)
}
context.startActivity(emailIntent)
}
) {
Text(
text = stringResource(
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
),
modifier = Modifier
.padding(16.dp),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
if (state.connectionSuccessful) {
StyledToggle(
title = stringResource(R.string.widget),

View File

@@ -0,0 +1,658 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.visible
import androidx.compose.foundation.layout.width
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
@OptIn(FlowPreview::class)
@Composable
fun EqualizerScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val customEq = state.customEq
val enabled = customEq.isEnabled()
val recommendedString = stringResource(R.string.recommended)
val customString = stringResource(R.string.custom)
val eqStateOptions = remember(state.customEq) {
listOf(
SelectItem(
name = recommendedString,
selected = !enabled,
onClick = { viewModel.setCustomEqEnabled(false) }
),
SelectItem(
name = customString,
selected = enabled,
onClick = { viewModel.setCustomEqEnabled(true) }
),
)
}
StyledScaffold(
title = stringResource(R.string.equalizer)
) { spacerHeight ->
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val height = 200.dp
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
val offsets = remember(state.customEq) {
listOf(
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
)
}
Spacer(modifier = Modifier.height(spacerHeight))
StyledSelectList(items = eqStateOptions)
Spacer(modifier = Modifier.height(12.dp))
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Crossfade (
customEq.isEnabled()
) { visible ->
Column(
modifier = Modifier
.fillMaxWidth()
.visible(visible),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
) {
val dashColor =
if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D)
// LaunchedEffect(offsets[0].floatValue, offsets[1].floatValue, offsets[2].floatValue) {
// val low = ((offsets[0].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt()
// val mid = ((offsets[1].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt()
// val high = ((offsets[2].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt()
// Log.d("EqualizerScreen", "$low, $mid, $high")
// viewModel.setCustomEq(
// low = low,
// mid = mid,
// high = high
// )
// }
LaunchedEffect(offsets) {
snapshotFlow {
Triple(
offsets[0].floatValue,
offsets[1].floatValue,
offsets[2].floatValue
)
}
.debounce(100.milliseconds) // cool, should've been using this since the very beginning
.collect { (lowF, midF, highF) ->
val low =
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
val mid =
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
val high =
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
viewModel.setCustomEq(low, mid, high)
}
}
val backdrop = rememberLayerBackdrop()
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
) {
Spacer(modifier = Modifier.height(42.dp))
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .padding(18.dp),
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(12.dp)
// ) {
// Box(
// modifier = Modifier
// .size(64.dp)
// .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp))
// )
// Column(
// modifier = Modifier
// .weight(1f),
// verticalArrangement = Arrangement.Center
// ) {
// Text(
// text = "Written into Changes",
// style = TextStyle(
// fontSize = 16.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Bold,
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
// )
// )
// Spacer(modifier = Modifier.height(4.dp))
// Text(
// text = "Avalon Emerson",
// style = TextStyle(
// fontSize = 14.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Normal,
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
// )
// )
// }
// val paused = remember { mutableStateOf(false) }
// Box(
// modifier = Modifier
// .size(48.dp)
// .background(Color(0x600091FF), CircleShape)
// .clickable(
// interactionSource = remember { MutableInteractionSource() },
// indication = null,
// ) {
// paused.value = !paused.value
// },
// contentAlignment = Alignment.Center
// ) {
// Crossfade(
// targetState = paused.value,
// label = "media_icon"
// ) { p ->
// Text(
// text = if (p) "􀊄" else "􀊆",
// style = TextStyle(
// fontSize = 24.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Normal,
// color = Color(0xFF0091FF),
// textAlign = TextAlign.Center
// )
// )
// }
// }
// }
//
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888),
// modifier = Modifier
// .padding(horizontal = 20.dp)
// .padding(bottom = 16.dp)
// )
Box(
modifier = Modifier.fillMaxWidth()
) {
fun colorFromY(y: Float): Color {
val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f)
val stops = listOf(
0.0f to Color(0xFFFFA300),
0.25f to Color(0xFFFCE600),
0.5f to Color(0xFF00FAAF),
0.75f to Color(0xFF00FAFF),
1.0f to Color(0xFF00B5FF)
)
val (start, end) = stops.zipWithNext()
.first { f <= it.second.first }
val c = (f - start.first) / (end.first - start.first)
return lerp(start.second, end.second, c)
}
fun pathBrush(
startY: Float,
endY: Float,
): Brush {
val stops = (0..20).map { i ->
val t = i / 20f
val y = lerp(startY, endY, t)
t to colorFromY(y)
}
return Brush.linearGradient(
colorStops = stops.toTypedArray()
)
}
Column(
modifier = Modifier.fillMaxWidth().layerBackdrop(backdrop)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = 20.dp)
) {
Row(
modifier = Modifier
.fillMaxSize()
) {
val dashCount = (height / 10.dp).toInt()
repeat(3) {
Box(
modifier = Modifier
.fillMaxSize()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
for (i in 1..(dashCount)) {
val t = i.toFloat() / dashCount
val centerDistance = abs(0.5f - t)
val alpha = 1f - (centerDistance * 2f)
Box(
modifier = Modifier
.height(9.dp)
.width(0.75.dp)
.background(
dashColor.copy(alpha),
RoundedCornerShape(28.dp)
)
)
}
}
}
}
}
Canvas(
modifier = Modifier
.fillMaxSize()
) {
val canvasWidth = size.width
drawLine(
color = backgroundColor,
start = Offset(
x = 0f,
y = offsets[0].floatValue + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = offsets[0].floatValue + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(offsets[0].floatValue),
start = Offset(
x = 0f,
y = offsets[0].floatValue + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = offsets[0].floatValue + maxOffset
),
strokeWidth = 8f
)
val lowToMidPath = Path()
lowToMidPath.moveTo(
x = 1 / 6f * canvasWidth,
y = offsets[0].floatValue + maxOffset
)
lowToMidPath.cubicTo(
x1 = canvasWidth * 1 / 6f + 108.dp.value,
y1 = offsets[0].floatValue + maxOffset,
x2 = canvasWidth * 0.5f - 108.dp.value,
y2 = offsets[1].floatValue + maxOffset,
x3 = canvasWidth * 0.5f,
y3 = offsets[1].floatValue + maxOffset
)
drawPath(
color = backgroundColor,
path = lowToMidPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
offsets[0].floatValue,
offsets[1].floatValue
),
path = lowToMidPath,
style = Stroke(width = 8f)
)
val midToHighPath = Path()
midToHighPath.moveTo(
x = 0.5f * canvasWidth,
y = offsets[1].floatValue + maxOffset
)
midToHighPath.cubicTo(
x1 = canvasWidth * 0.5f + 108.dp.value,
y1 = offsets[1].floatValue + maxOffset,
x2 = canvasWidth * 5 / 6f - 108.dp.value,
y2 = offsets[2].floatValue + maxOffset,
x3 = canvasWidth * 5 / 6f,
y3 = offsets[2].floatValue + maxOffset
)
drawPath(
color = backgroundColor,
path = midToHighPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
offsets[1].floatValue,
offsets[2].floatValue
),
path = midToHighPath,
style = Stroke(width = 8f)
)
drawLine(
color = backgroundColor,
start = Offset(
x = 5 / 6f * canvasWidth,
y = offsets[2].floatValue + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = offsets[2].floatValue + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(offsets[2].floatValue),
start = Offset(
x = 5 / 6f * canvasWidth,
y = offsets[2].floatValue + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = offsets[2].floatValue + maxOffset
),
strokeWidth = 8f
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "Low".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "Mid".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "High".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically
) {
for (i in 0..2) {
Row(
modifier = Modifier
.weight(1f),
horizontalArrangement = Arrangement.Center
) {
val pressed = remember { mutableStateOf(false) }
Box(
modifier = Modifier
.offset {
IntOffset(
x = 0,
y = offsets[i].floatValue.roundToInt()
)
},
contentAlignment = Alignment.Center
) {
Crossfade(
pressed.value
) {
Box(
modifier = Modifier
.size(96.dp)
.then(
if (it) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { CircleShape },
highlight = {
Highlight.Ambient
},
onDrawSurface = {
drawCircle(
color = Color.White.copy(
0.2f
),
radius = size.height
)
drawCircle(
color = colorFromY(
offsets[i].floatValue
),
style = Stroke(2.dp.value),
radius = size.height / 2
)
},
effects = {
lens(
refractionHeight = 32f.dp.value,
refractionAmount = size.height
)
}
)
} else Modifier
)
)
}
Box(
modifier = Modifier
.size(18.dp)
.background(
colorFromY(offsets[i].floatValue),
CircleShape
)
.border(
2.5.dp,
backgroundColor,
CircleShape
)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
offsets[i].floatValue =
(offsets[i].floatValue + delta).coerceIn(
-maxOffset,
maxOffset
)
},
onDragStarted = {
pressed.value = true
},
onDragStopped = {
pressed.value = false
}
)
)
}
}
}
}
}
}
}
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
StyledButton(
onClick = {
offsets[0].floatValue = 0f
offsets[1].floatValue = 0f
offsets[2].floatValue = 0f
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
isInteractive = false,
surfaceColor = backgroundColor,
enabled = resetButtonEnabled.value
) {
Text(
text = stringResource(R.string.reset),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = if (!offsets.all { it.floatValue == 0f }) Color(0xFF0093FF) else Color.Gray
)
)
}
}
}
}
}
}

View File

@@ -32,13 +32,12 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -48,22 +47,17 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@@ -74,14 +68,83 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme()
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.adjustments)
) { spacerHeight ->
val debounceJob = remember { mutableStateOf<Job?>(null) }
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
val rightEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember { mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = 0f,
rightAmplification = 0f,
leftTone = 0f,
rightTone = 0f,
leftConversationBoost = false,
rightConversationBoost = false,
leftAmbientNoiseReduction = 0f,
rightAmbientNoiseReduction = 0f,
netAmplification = 0f,
balance = 0f,
ownVoiceAmplification = 0f
)
) }
LaunchedEffect(state.hearingAidData) {
parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed ->
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
initialized.value = true
}
}
LaunchedEffect(
amplificationSliderValue.floatValue,
balanceSliderValue.floatValue,
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialized.value) return@LaunchedEffect
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
}
StyledScaffold(title = stringResource(R.string.adjustments)) { spacerHeight ->
Column(
modifier = Modifier
.hazeSource(hazeState)
@@ -93,136 +156,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
) {
Spacer(modifier = Modifier.height(spacerHeight))
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val hearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
)
}
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
}
}
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
return@LaunchedEffect
}
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
initialReadSucceeded.value = true
} else {
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
}
StyledSlider(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
@@ -235,7 +168,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
independent = true,
)
StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),

View File

@@ -259,6 +259,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
return@launch
}
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
if (parsed == null) {
Log.w(TAG, "transparency parse failed")
return@launch
}
val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) {

View File

@@ -53,11 +53,11 @@ import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun PurchaseScreen(
@@ -199,7 +199,7 @@ fun PurchaseScreen(
)
)
}
if (BuildConfig.FLAVOR == "xposed") {
if (XposedState.isAvailable) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),

View File

@@ -68,8 +68,9 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
) {
Spacer(modifier = Modifier.height(spacerHeight))
val textFieldState = rememberTextFieldState()
textFieldState.edit { sharedPreferences.getString("name", "") ?: "" }
val name = sharedPreferences.getString("name", "")?: ""
val textFieldState = rememberTextFieldState(initialText = name)
LaunchedEffect(textFieldState.text) {
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
viewModel.setName(textFieldState.text.toString())

View File

@@ -46,9 +46,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
@@ -64,17 +65,14 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "TransparencySettings"
@@ -112,19 +110,26 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val enabled = rememberSaveable { mutableStateOf(false) }
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val eq = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8) { 0.5f }) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val initialized = rememberSaveable { mutableStateOf(false) }
val transparencySettings = remember {
mutableStateOf(
@@ -153,23 +158,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
eq.value,
initialLoadComplete.value,
initialReadSucceeded.value
eq.value
) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect
}
if (!initialized.value) return@LaunchedEffect
transparencySettings.value = TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
@@ -189,59 +180,20 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
try {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = state.eqData
if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf()
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
var parsedSettings: TransparencySettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = state.transparencyData
parsedSettings = parseTransparencySettingsResponse(data = data)
Log.d(TAG, "Parsed settings on attempt $attempt")
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
eq.value = parsedSettings.leftEQ.copyOf()
initialReadSucceeded.value = true
} else {
Log.d(
TAG,
"Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts"
)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
LaunchedEffect(state.transparencyData) {
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
eq.value = parsedSettings.leftEQ.copyOf()
}
initialized.value = true
}
if (state.vendorIdHook) {

View File

@@ -35,13 +35,14 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -57,34 +58,19 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import java.io.IOException
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@Composable
fun UpdateHearingTestScreen() {
fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager
if (attManager == null) {
Text(
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
textAlign = TextAlign.Center
)
return
}
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_test)
@@ -112,18 +98,31 @@ fun UpdateHearingTestScreen() {
),
textAlign = TextAlign.Center,
)
val tone = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val rightEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val debounceJob = remember { mutableStateOf<Job?>(null) }
val initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember {
mutableStateOf(
@@ -145,31 +144,21 @@ fun UpdateHearingTestScreen() {
)
}
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
tone.floatValue = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
}
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
LaunchedEffect(state.hearingAidData) {
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
tone.floatValue = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
initialized.value = true
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
@@ -177,27 +166,13 @@ fun UpdateHearingTestScreen() {
leftEQ.value,
rightEQ.value,
conversationBoostEnabled.value,
initialLoadComplete.value,
initialReadSucceeded.value,
leftAmplification.floatValue,
rightAmplification.floatValue,
tone.floatValue,
ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect
}
if (!initialized.value) return@LaunchedEffect
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
@@ -214,55 +189,7 @@ fun UpdateHearingTestScreen() {
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
tone.floatValue = parsedSettings.leftTone
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
leftAmplification.floatValue = parsedSettings.leftAmplification
rightAmplification.floatValue = parsedSettings.rightAmplification
initialReadSucceeded.value = true
} else {
Log.d(
TAG,
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
}
val frequencies =

View File

@@ -24,21 +24,24 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.util.Log
import android.widget.Toast
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications
@@ -47,6 +50,7 @@ import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.CustomEq
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService
@@ -93,7 +97,10 @@ data class AirPodsUiState(
val dynamicEndOfCharge: Boolean = false,
val connectionSuccessful: Boolean = false
val connectionSuccessful: Boolean = false,
val timeUntilFOSSPremiumExpiry: Long = 0L,
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
)
class AirPodsViewModel(
@@ -136,15 +143,40 @@ class AirPodsViewModel(
_cameraAction.value = action
}
fun setCustomEq(low: Int, mid: Int, high: Int) {
require(low in 0..100)
require(mid in 0..100)
require(high in 0..100)
val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high)
service.aacpManager.sendCustomEqPacket(updatedEq)
_uiState.update {
it.copy(
customEq = updatedEq
)
}
}
fun setCustomEqEnabled(enabled: Boolean) {
service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1))
_uiState.update {
it.copy(
customEq = it.customEq.copy(state = if (enabled) 2 else 1)
)
}
}
init {
observeBroadcasts()
loadName()
loadInstance()
loadSharedPreferences()
setupControlObservers()
observeBilling()
observeAACP()
loadControlList()
loadEq()
loadATT()
observeATT()
observeSharedPreferences()
observeBilling()
if (isDemoMode) activateDemoMode()
}
@@ -152,7 +184,7 @@ class AirPodsViewModel(
listeners.forEach { (id, listener) ->
controlRepo.remove(id, listener)
}
service.aacpManager.customEqCallback = null
appContext.unregisterReceiver(broadcastReceiver)
super.onCleared()
@@ -172,18 +204,38 @@ class AirPodsViewModel(
// billingFirstCollectDone = true
// return@collect
// }
if (!premium) {
setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false)
if (premium) {
sharedPreferences.edit {
remove("premium_expiry_time")
if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
}
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
} else {
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false)
_uiState.update { it.copy(isPremium = false) }
}
}
_uiState.update { it.copy(isPremium = premium) }
}
}
}
private fun observeSharedPreferences() {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) {
"name" -> loadName()
"off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd",
"head_gestures", "left_long_press_action", "right_long_press_action",
"dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences()
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
}
private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -289,7 +341,7 @@ class AirPodsViewModel(
}
// I'm lazy, sorry.
fun setupControlObservers() {
fun observeAACP() {
val identifiersList = listOf(
ControlCommandIdentifiers.MIC_MODE,
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
@@ -321,6 +373,9 @@ class AirPodsViewModel(
for (identifier in identifiersList) {
observeControl(identifier)
}
service.aacpManager.customEqCallback = { customEq ->
_uiState.update { it.copy(customEq = customEq) }
}
}
fun refreshInitialData() {
@@ -328,7 +383,7 @@ class AirPodsViewModel(
service.let { service ->
_uiState.update {
it.copy(
isLocallyConnected = service.isConnected(), battery = service.getBattery()
isLocallyConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true, battery = service.getBattery()
)
}
}
@@ -368,9 +423,58 @@ class AirPodsViewModel(
rightAction = rightAction,
vendorIdHook = vendorIdHook,
dynamicEndOfCharge = dynamicEndOfCharge,
connectionSuccessful = connectionSuccessful
connectionSuccessful = connectionSuccessful,
)
}
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
if (BuildConfig.PLAY_BUILD) {
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
val now = System.currentTimeMillis()
when {
// existing temporary premium
expiryTime > 0L -> {
if (expiryTime <= now) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = 0L,
isPremium = false
)
}
} else {
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = expiryTime - now,
isPremium = true
)
}
}
}
// First migration from accidental FOSS Play build
fossUpgraded && !_uiState.value.isPremium -> {
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
sharedPreferences.edit {
putLong("premium_expiry_time", newExpiry)
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = newExpiry - now,
isPremium = true
)
}
}
}
}
}
fun setOffListeningMode(enabled: Boolean) {
@@ -404,6 +508,14 @@ class AirPodsViewModel(
}
}
private fun loadEq() {
_uiState.update {
it.copy(
customEq = service.aacpManager.customEq
)
}
}
private fun loadInstance() {
val instance = service.airpodsInstance ?: AirPodsInstance(
name = "AirPods",
@@ -454,51 +566,69 @@ class AirPodsViewModel(
}
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
when (handle) {
// ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction.
ATTHandles.LOUD_SOUND_REDUCTION -> {
_uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01)
}
ATTHandles.HEARING_AID -> {
_uiState.value = _uiState.value.copy(hearingAidData = value)
}
ATTHandles.TRANSPARENCY -> {
_uiState.value = _uiState.value.copy(transparencyData = value)
}
}
viewModelScope.launch(Dispatchers.IO) {
try {
service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(250)
}
service.attManager?.write(handle, value)
service.attManager.writeCharacteristic(handle, value)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun refreshATT() {
viewModelScope.launch(Dispatchers.IO) {
val loudSoundReduction =
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
val transparencyData =
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
val hearingAid =
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
_uiState.value = _uiState.value.copy(
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
fun loadATT() {
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
loudSoundReduction[0].toInt() == 1
} else false
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
_uiState.update {
it.copy(
loudSoundReductionEnabled = loudSoundReductionEnabled,
transparencyData = transparencyData,
hearingAidData = hearingAid
hearingAidData = hearingAidData
)
}
}
fun observeATT() {
viewModelScope.launch(Dispatchers.IO) {
service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(1000)
}
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) {
refreshATT()
delay(15000)
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
// service.attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION)
}
service.attManager.setOnNotificationReceived { handle, value ->
when (handle) {
ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> {
val loudSoundReductionEnabled = if (value.isNotEmpty()) {
value[0].toInt() == 1
} else false
_uiState.update {
it.copy(loudSoundReductionEnabled = loudSoundReductionEnabled)
}
}
ATTHandles.HEARING_AID.value.toByte() -> {
_uiState.update {
it.copy(hearingAidData = value)
}
}
ATTHandles.TRANSPARENCY.value.toByte() -> {
_uiState.update {
it.copy(transparencyData = value)
}
}
}
}
}

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import kotlin.math.roundToInt
@@ -34,7 +35,8 @@ data class AppSettingsUiState(
val isPremium: Boolean = false,
val connectionSuccessful: Boolean = false,
val showBottomSheetPopup: Boolean = true,
val showIslandPopup: Boolean = true
val showIslandPopup: Boolean = true,
val timeUntilFOSSPremiumExpiry: Long = 0L
)
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -66,12 +68,71 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
private fun observeBilling() {
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
_uiState.update { it.copy(isPremium = premium) }
if (premium) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
} else {
// No billing premium, only update if no temporary premium is active
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
_uiState.update { it.copy(isPremium = false) }
}
}
}
}
}
private fun loadSettings() {
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
val now = System.currentTimeMillis()
when {
// existing temporary premium
expiryTime > 0L -> {
if (expiryTime <= now) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = 0L,
isPremium = false
)
}
} else {
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = expiryTime - now,
isPremium = true
)
}
}
}
// First migration from accidental FOSS Play build
fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
sharedPreferences.edit {
putLong("premium_expiry_time", newExpiry)
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = newExpiry - now,
isPremium = true
)
}
}
}
_uiState.update { currentState ->
currentState.copy(
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),

View File

@@ -35,9 +35,10 @@ import android.util.Log
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(Build.VERSION_CODES.Q)
@@ -98,7 +99,7 @@ class AirPodsQSService : TileService() {
Log.d("AirPodsQSService", "onStartListening")
val service = ServiceManager.getService()
isAirPodsConnected = service?.isConnected() == true
isAirPodsConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {

View File

@@ -85,7 +85,9 @@ import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.bluetooth.ATTManager
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
import me.kavishdevar.librepods.bluetooth.BLEManager
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance
@@ -94,6 +96,8 @@ import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.CustomEq
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.data.isHeadTrackingData
@@ -126,9 +130,9 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.time.LocalDateTime
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "AirPodsService"
@@ -151,7 +155,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var macAddress = ""
var localMac = ""
lateinit var aacpManager: AACPManager
var attManager: ATTManager? = null
lateinit var attManager: ATTManagerv2
var airpodsInstance: AirPodsInstance? = null
var cameraActive = false
private var disconnectedBecauseReversed = false
@@ -231,8 +235,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
lateinit var bleManager: BLEManager
private lateinit var socket: BluetoothSocket
companion object {
init {
System.loadLibrary("bluetooth_socket")
@@ -244,7 +246,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onDeviceStatusChanged(
device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus?
) {
if (device.connectionState == "Disconnected" && !isConnected()) { // should never happen unless android messes up and sends us a stale broadcast
if (device.connectionState == "Disconnected" && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast
Log.d(TAG, "Seems no device has taken over, we will.")
val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager.adapter
@@ -256,7 +258,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
connectToSocket(bluetoothAdapter, bluetoothDevice)
}
Log.d(TAG, "Device status changed")
if (socket.isConnected) return
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
@@ -289,7 +291,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro")
?: "AirPods"
)
if (socket.isConnected) return
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
@@ -323,7 +325,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
if (socket.isConnected) return
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
@@ -380,6 +382,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
aacpManager = AACPManager()
initializeAACPManagerCallback()
attManager = ATTManagerv2()
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
localMac = config.selfMacAddress
@@ -654,6 +658,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
addAction("android.bluetooth.device.action.UUID")
}
connectionReceiver = object : BroadcastReceiver() {
@@ -691,8 +696,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// isConnectedLocally = false
popupShown = false
updateNotificationContent(false)
attManager?.disconnect()
attManager = null
aacpManager.disconnected()
attManager.disconnected()
BluetoothConnectionManager.setCurrentConnection(null, null)
}
}
}
@@ -1019,7 +1025,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
// Store in SharedPreferences
sharedPreferences.edit {
putString("airpods_name", deviceInformation.name)
putString("name", deviceInformation.name)
putString("airpods_model_number", deviceInformation.modelNumber)
putString("airpods_manufacturer", deviceInformation.manufacturer)
putString("airpods_serial_number", deviceInformation.serialNumber)
@@ -1094,9 +1100,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
)
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
if (BuildConfig.FLAVOR == "xposed") {
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
}
} else {
val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
@@ -1157,13 +1161,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
override fun onEQPacketReceived(eqData: FloatArray) {
override fun onHeadphoneAccommodationReceived(eqData: FloatArray) {
sendBroadcast(
Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply {
setPackage(packageName)
})
}
override fun onCustomEqReceived(customEq: CustomEq) {
// TODO
}
override fun onCapabilitiesReceived(capabilities: List<Capability>) {
// TODO
}
override fun onUnknownPacketReceived(packet: ByteArray) {
Log.d(
"AACPManager",
@@ -1735,7 +1747,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val socketFailureChannel = NotificationChannel(
"socket_connection_failure",
"AirPods Socket Connection Issues",
"AirPods BluetoothConnectionManager.getAACPSocket()? Connection Issues",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications about problems connecting to AirPods protocol"
@@ -1781,7 +1793,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (BuildConfig.FLAVOR != "xposed") {
Log.w(
TAG,
"Not showing socket error notification to user, the service shouldn't be running if it isn't supported."
"Not showing BluetoothConnectionManager.getAACPSocket()? error notification to user, the service shouldn't be running if it isn't supported."
)
return
}
@@ -1910,7 +1922,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
it.setViewVisibility(
R.id.left_charging_icon,
if (leftBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
if (leftBattery?.status == BatteryStatus.CHARGING || leftBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE
)
it.setTextViewText(R.id.right_battery_widget, rightBattery?.let {
@@ -1921,7 +1933,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
it.setViewVisibility(
R.id.right_charging_icon,
if (rightBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
if (rightBattery?.status == BatteryStatus.CHARGING || rightBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
)
it.setTextViewText(R.id.case_battery_widget, caseBattery?.let {
@@ -1932,7 +1944,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
it.setViewVisibility(
R.id.case_charging_icon,
if (caseBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
)
it.setViewVisibility(
@@ -2036,10 +2048,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
if (!::socket.isInitialized) {
if (BluetoothConnectionManager.getAACPSocket() == null) {
return
}
if (connected && (config.bleOnlyMode || socket.isConnected)) {
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
val updatedNotificationBuilder =
NotificationCompat.Builder(this, "airpods_connection_status")
.setSmallIcon(R.drawable.airpods)
@@ -2087,8 +2099,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.cancel(1)
} else if (!connected) {
notificationManager.cancel(2)
} else if (!config.bleOnlyMode && !socket.isConnected) {
showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
} else if (!config.bleOnlyMode && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) {
showSocketConnectionFailureNotification("BluetoothConnectionManager.getAACPSocket()? created, but not connected. Check logs")
}
}
@@ -2390,16 +2402,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && !action.isNullOrEmpty()) {
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
bluetoothDevice.fetchUuidsWithSdp()
if (bluetoothDevice.uuids != null) {
if (bluetoothDevice.uuids.contains(uuid)) {
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
}
if (bluetoothDevice.uuids?.contains(uuid) == true) {
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
} else {
bluetoothDevice.fetchUuidsWithSdp()
}
} else if ("android.bluetooth.device.action.UUID" == action) {
val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("mac_address", "") ?: ""
val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac
val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true
if (matchedByUuid || matchedByMac) {
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
}
}
}
@@ -2452,8 +2475,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d(
TAG, "owns connection: $ownsConnection"
)
if (!::socket.isInitialized) return
if (socket.isConnected) {
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) {
Log.d(TAG, "not taking over, vendorid is probably not set to apple")
return
@@ -2612,15 +2634,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
private fun createBluetoothSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 0x1001, uuid), // A16QPR3
arrayOf(device, type, true, true, 0x1001, uuid),
arrayOf(device, type, 1, true, true, 0x1001, uuid),
arrayOf(type, 1, true, true, device, 0x1001, uuid),
arrayOf(type, true, true, device, 0x1001, uuid)
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
arrayOf(device, type, true, true, psm, uuid),
arrayOf(device, type, 1, true, true, psm, uuid),
arrayOf(type, 1, true, true, device, psm, uuid),
arrayOf(type, true, true, device, psm, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
@@ -2662,11 +2684,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun connectToSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false
) {
if (BluetoothConnectionManager.getAACPSocket() != null && BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
Log.d(TAG, "<LogCollector:Start> Connecting to socket")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
// if (!isConnectedLocally) {
socket = try {
createBluetoothSocket(adapter, device, uuid)
val socket = try {
createBluetoothSocket(adapter, device, uuid, 4097)
} catch (e: Exception) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
@@ -2675,17 +2698,30 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
try {
runBlocking {
withTimeout(5000L) {
withTimeout(5000.milliseconds) {
try {
socket.connect()
// isConnectedLocally = true
this@AirPodsService.device = device
BluetoothConnectionManager.setCurrentConnection(socket, device)
val xposedRemotePref = XposedRemotePrefProvider.create()
if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
attManager = ATTManager(adapter, device)
attManager!!.connect()
val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
createBluetoothSocket(
adapter,
device,
ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"),
31
)
} else null
attSocket?.connect()
BluetoothConnectionManager.setCurrentConnection(socket, attSocket)
if (attSocket != null) {
attManager.startReader()
attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION)
attManager.readCharacteristic(ATTHandles.TRANSPARENCY)
attManager.readCharacteristic(ATTHandles.HEARING_AID)
attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
// attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION)
attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
}
// Create AirPodsInstance from stored config if available
@@ -2740,7 +2776,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
if (!socket.isConnected) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
Log.d(TAG, "<LogCollector:Complete:Failed> socket not connected")
if (manual) {
sendToast(
"Couldn't connect to socket: timeout."
@@ -2751,13 +2787,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return
}
this@AirPodsService.device = device
socket.let {
BluetoothConnectionManager.getAACPSocket()?.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 {
delay(200)
aacpManager.sendPacket(aacpManager.createHandshakePacket())
delay(200)
aacpManager.sendSetFeatureFlagsPacket()
@@ -2785,55 +2822,53 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
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) }
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()
)
updateNotificationContent(
true,
sharedPreferences.getString("name", device.name),
batteryNotification.getBattery()
)
aacpManager.receivePacket(data)
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)")
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
setPackage(packageName)
})
aacpManager.disconnected()
return@launch
if (!isHeadTrackingData(data)) {
Log.d("AirPodsData", "Data received: $formattedHex")
logPacket(data, "AirPods")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading data, we have probably disconnected.")
e.printStackTrace()
} else if (bytesRead == -1) {
Log.d("AirPodsService", "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 {
setPackage(packageName)
})
aacpManager.disconnected()
return@launch
}
}
Log.d("AirPods Service", "Socket closed")
Log.d("AirPods Service", "socket closed")
// isConnectedLocally = false
socket.close()
aacpManager.disconnected()
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
@@ -2843,20 +2878,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
} catch (e: Exception) {
e.printStackTrace()
Log.d(TAG, "Failed to connect to socket: ${e.message}")
Log.d(TAG, "Failed to connect to BluetoothConnectionManager.getAACPSocket()?: ${e.message}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
// isConnectedLocally = false
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})")
// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.getAACPSocket()? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.getAACPSocket()?.isConnected = ${this::BluetoothConnectionManager.getAACPSocket()?.isInitialized && BluetoothConnectionManager.getAACPSocket()?.isConnected})")
// }
}
fun disconnectForCD() {
if (!this::socket.isInitialized) return
socket.close()
BluetoothConnectionManager.getAACPSocket()?.close()
MediaController.pausedWhileTakingOver = false
Log.d(TAG, "Disconnected from AirPods, showing island.")
showIsland(
@@ -2887,11 +2921,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
fun disconnectAirPods() {
if (!this::socket.isInitialized) return
socket.close()
if (BluetoothConnectionManager.getAACPSocket() == null) return
try {
BluetoothConnectionManager.getAACPSocket()?.close()
} catch(e: Exception) {
Log.e(TAG, "error closing aacp socket ${e.message}")
}
// isConnectedLocally = false
aacpManager.disconnected()
attManager?.disconnect()
attManager.disconnected()
BluetoothConnectionManager.setCurrentConnection(null, null)
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
setPackage(packageName)
@@ -3199,10 +3238,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
}
fun isConnected(): Boolean {
return if (::socket.isInitialized) socket.isConnected else false
}
}
private fun Int.dpToPx(): Int {

View File

@@ -22,22 +22,13 @@ import android.content.SharedPreferences
import android.os.Build
fun isSupported(sharedPreferences: SharedPreferences): Boolean {
if (Build.VERSION.SDK_INT == 37) return true
val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
if (isBypassFlagActive) return true
val isPixel = Build.MANUFACTURER.lowercase() == "google"
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
if (isBypassFlagActive) return true
if (isPixel) {
when (Build.VERSION.SDK_INT) {
36 -> {
return Build.ID.startsWith("CP1A")
}
37 -> {
return true
}
}
if (isPixel && Build.VERSION.SDK_INT == 36) {
return Build.ID.startsWith("CP1A")
} else if (isOppoFamily) {
return Build.VERSION.SDK_INT >= 36
}

View File

@@ -276,4 +276,7 @@
<string name="optimized_charging">Optimized Charge Limit</string>
<string name="optimized_charging_description">AirPods can learn from your daily usage and determine when to charge to an optmized limit and when to allow or full charge. This limit adapts to your daily usage and preserves your battery lifespan over time.\nThis setting may not affect unsupported AirPods, or AirPods on an older firmware version.</string>
<string name="enable_app_in_xposed_or_update_device">Enable LibrePods in Xposed or update your device to proceed.</string>
<string name="play_foss_premium_banner">Due to an error in billing, premium access will expire in %1$d days. If you already upgraded the app, please click on this message to email billing@kavish.xyz to restore or verify access. Apologies for the inconvenience.</string>
<string name="custom">Custom</string>
<string name="recommended">Recommended</string>
</resources>