mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-06-02 14:36:09 +00:00
Compare commits
9 Commits
nightly-01
...
nightly-0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f50eab788 | ||
|
|
1381022b2e | ||
|
|
af4261485a | ||
|
|
571db0ebde | ||
|
|
3c3c0edffd | ||
|
|
f86d7b9aca | ||
|
|
29a914c2ff | ||
|
|
3f2a7df749 | ||
|
|
f9367f4445 |
51
README.md
51
README.md
@@ -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.
|
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 availability
|
||||||
|
|
||||||
| Feature | Linux | Android |
|
| 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) | ❓ | ❓ |
|
| [Find My](#find-my) | ❓ | ❓ |
|
||||||
| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 |
|
| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 |
|
||||||
|
|
||||||
| Emoji | Meaning |
|
| Symbol | Meaning |
|
||||||
| ----- | ------------------------------------------------------------------- |
|
| ------ | ------------------------------------------------------------------- |
|
||||||
| ✅ | Implemented and works well |
|
| ✅ | Implemented and works well |
|
||||||
| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk |
|
| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk |
|
||||||
| 🔴 | Not implemented yet; planned |
|
| 🔴 | 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
|
## High quality two-way audio
|
||||||
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
|
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.
|
||||||
|
|
||||||
* 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
|
# VendorID Spoofing
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "me.kavishdevar.librepods"
|
applicationId = "me.kavishdevar.librepods"
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 52
|
versionCode = 55
|
||||||
versionName = appVersionName
|
versionName = appVersionName
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -56,7 +56,6 @@ android {
|
|||||||
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
|
||||||
if (releaseSigningAvailable) {
|
if (releaseSigningAvailable) {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
@@ -65,7 +64,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
|
||||||
if (releaseSigningAvailable) {
|
if (releaseSigningAvailable) {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ fun Main() {
|
|||||||
OpenSourceLicensesScreen(navController)
|
OpenSourceLicensesScreen(navController)
|
||||||
}
|
}
|
||||||
composable("update_hearing_test") {
|
composable("update_hearing_test") {
|
||||||
if (airPodsViewModel != null) UpdateHearingTestScreen()
|
if (airPodsViewModel != null) UpdateHearingTestScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("version_info") {
|
composable("version_info") {
|
||||||
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
||||||
|
|||||||
@@ -1143,7 +1143,7 @@ class AACPManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
|
val socket = BluetoothConnectionManager.getAACPSocket() ?: return false
|
||||||
|
|
||||||
if (socket.isConnected) {
|
if (socket.isConnected) {
|
||||||
socket.outputStream?.write(packet)
|
socket.outputStream?.write(packet)
|
||||||
|
|||||||
@@ -16,234 +16,196 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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
|
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 android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
private const val TAG = "ATTManager"
|
||||||
|
|
||||||
enum class ATTHandles(val value: Int) {
|
enum class ATTHandles(val value: Int) {
|
||||||
TRANSPARENCY(0x18),
|
TRANSPARENCY(0x18),
|
||||||
LOUD_SOUND_REDUCTION(0x1B),
|
LOUD_SOUND_REDUCTION(0x1B),
|
||||||
HEARING_AID(0x2A),
|
HEARING_AID(0x2A)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ATTCCCDHandles(val value: Int) {
|
enum class ATTCCCDHandles(val value: Int) {
|
||||||
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
||||||
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
|
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
|
||||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
|
class ATTManagerv2 {
|
||||||
companion object {
|
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
|
||||||
private const val TAG = "ATTManager"
|
|
||||||
|
|
||||||
private const val OPCODE_READ_REQUEST: Byte = 0x0A
|
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
|
||||||
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
|
|
||||||
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
|
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
|
fun stopReader() {
|
||||||
private var input: InputStream? = null
|
readerRunning.set(false)
|
||||||
private var output: OutputStream? = null
|
readerThread?.interrupt()
|
||||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
readerThread = null
|
||||||
private var notificationJob: Job? = null
|
|
||||||
|
|
||||||
// queue for non-notification PDUs (responses to requests)
|
|
||||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
fun connect() {
|
|
||||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
|
||||||
|
|
||||||
try {
|
|
||||||
socket = createBluetoothSocket(adapter, device, uuid)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to create socket")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
|
||||||
|
onNotificationReceived = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
socket!!.connect()
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "ATT socket failed to connect")
|
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 {
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
input = socket!!.inputStream
|
|
||||||
output = socket!!.outputStream
|
|
||||||
Log.d(TAG, "Connected to ATT")
|
|
||||||
|
|
||||||
notificationJob = CoroutineScope(Dispatchers.IO).launch {
|
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
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) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
|
Log.e(TAG, "error writing characteristic: ${e.message}")
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// not a notification -> treat as a response for pending request(s)
|
|
||||||
responses.put(pdu)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error reading notification/response: ${e.message}")
|
|
||||||
if (socket?.isConnected != true) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnected() {
|
||||||
|
characteristicList.clear()
|
||||||
|
stopReader()
|
||||||
|
val socket = BluetoothConnectionManager.getATTSocket() ?: return
|
||||||
try {
|
try {
|
||||||
notificationJob?.cancel()
|
socket.close()
|
||||||
socket?.close()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error closing socket: ${e.message}")
|
Log.w(TAG, "error closing socket: ${e.message}")
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "ATT disconnected")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
private fun runReaderLoop() {
|
||||||
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
|
val socket = BluetoothConnectionManager.getATTSocket() ?: run {
|
||||||
|
Log.w(TAG, "ATT socket not available. stopping reader")
|
||||||
|
readerRunning.set(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
val input = socket.inputStream
|
||||||
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 buffer = ByteArray(512)
|
||||||
val len = inp.read(buffer)
|
|
||||||
|
while (readerRunning.get()) {
|
||||||
|
try {
|
||||||
|
val len = input.read(buffer)
|
||||||
if (len == -1) {
|
if (len == -1) {
|
||||||
disconnect()
|
Log.w(TAG, "ATT input stream ended")
|
||||||
throw IllegalStateException("End of stream reached")
|
break
|
||||||
}
|
}
|
||||||
val data = buffer.copyOfRange(0, len)
|
val data = buffer.copyOfRange(0, len)
|
||||||
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
if (data.isEmpty()) continue
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for a response PDU produced by the background reader
|
val opcode = data[0]
|
||||||
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
|
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 {
|
try {
|
||||||
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
onNotificationReceived?.invoke(handle, value)
|
||||||
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
|
} catch (t: Throwable) {
|
||||||
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
|
||||||
return resp.copyOfRange(1, resp.size)
|
}
|
||||||
} catch (e: InterruptedException) {
|
} else {
|
||||||
Thread.currentThread().interrupt()
|
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
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) {
|
} catch (e: Exception) {
|
||||||
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
Log.e(TAG, "error in reader loop: ${e.message}", e)
|
||||||
lastException = e
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
readerRunning.set(false)
|
||||||
Log.e("ATTManager", errorMessage)
|
}
|
||||||
throw lastException ?: IllegalStateException(errorMessage)
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,23 +18,22 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.bluetooth
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
object BluetoothConnectionManager {
|
object BluetoothConnectionManager {
|
||||||
private const val TAG = "BluetoothConnectionManager"
|
private var aacpSocket: BluetoothSocket? = null
|
||||||
|
private var attSocket: BluetoothSocket? = null
|
||||||
|
|
||||||
private var currentSocket: BluetoothSocket? = null
|
fun setCurrentConnection(aacpSocket: BluetoothSocket?, attSocket: BluetoothSocket?) {
|
||||||
private var currentDevice: BluetoothDevice? = null
|
BluetoothConnectionManager.aacpSocket = aacpSocket
|
||||||
|
BluetoothConnectionManager.attSocket = attSocket
|
||||||
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
|
||||||
currentSocket = socket
|
|
||||||
currentDevice = device
|
|
||||||
Log.d(TAG, "Current connection set to device: ${device.address}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentSocket(): BluetoothSocket? {
|
fun getAACPSocket(): BluetoothSocket? {
|
||||||
return currentSocket
|
return aacpSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getATTSocket(): BluetoothSocket? {
|
||||||
|
return attSocket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTManager
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@@ -138,15 +137,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendHearingAidSettings(
|
fun sendHearingAidSettings(
|
||||||
attManager: ATTManager,
|
currentData: ByteArray,
|
||||||
hearingAidSettings: HearingAidSettings,
|
hearingAidSettings: HearingAidSettings,
|
||||||
debounceJob: MutableState<Job?>
|
debounceJob: MutableState<Job?>,
|
||||||
|
sender: (ATTHandles, ByteArray) -> Unit
|
||||||
) {
|
) {
|
||||||
debounceJob.value?.cancel()
|
debounceJob.value?.cancel()
|
||||||
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
try {
|
try {
|
||||||
val currentData = attManager.read(ATTHandles.HEARING_AID)
|
|
||||||
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
if (currentData.size < 104) {
|
if (currentData.size < 104) {
|
||||||
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
|
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) }}")
|
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) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|
||||||
val enabled = buffer.float
|
val enabled = buffer.float
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ package me.kavishdevar.librepods.presentation.screens
|
|||||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context.MODE_PRIVATE
|
import android.content.Context.MODE_PRIVATE
|
||||||
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -51,6 +55,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
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.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@@ -170,6 +177,44 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
|
|||||||
}
|
}
|
||||||
} else Modifier)) {
|
} else Modifier)) {
|
||||||
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
|
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") {
|
item(key = "battery") {
|
||||||
BatteryView(
|
BatteryView(
|
||||||
batteryList = state.battery,
|
batteryList = state.battery,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
import me.kavishdevar.librepods.utils.XposedState
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@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) {
|
if (state.connectionSuccessful) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
title = stringResource(R.string.widget),
|
title = stringResource(R.string.widget),
|
||||||
|
|||||||
@@ -32,13 +32,12 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -48,22 +47,17 @@ import dev.chrisbanes.haze.HazeState
|
|||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.R
|
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.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
|
||||||
import me.kavishdevar.librepods.data.HearingAidSettings
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
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 me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
@@ -74,64 +68,42 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
isSystemInDarkTheme()
|
isSystemInDarkTheme()
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
|
||||||
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.adjustments)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.verticalScroll(verticalScrollState)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
|
|
||||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
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 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 initialReadSucceeded = remember { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val hearingAidSettings = remember {
|
val hearingAidSettings = remember { mutableStateOf(
|
||||||
mutableStateOf(
|
|
||||||
HearingAidSettings(
|
HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
leftAmplification = 0f,
|
||||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
rightAmplification = 0f,
|
||||||
leftTone = toneSliderValue.floatValue,
|
leftTone = 0f,
|
||||||
rightTone = toneSliderValue.floatValue,
|
rightTone = 0f,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = false,
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
rightConversationBoost = false,
|
||||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
leftAmbientNoiseReduction = 0f,
|
||||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
rightAmbientNoiseReduction = 0f,
|
||||||
netAmplification = amplificationSliderValue.floatValue,
|
netAmplification = 0f,
|
||||||
balance = balanceSliderValue.floatValue,
|
balance = 0f,
|
||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = 0f
|
||||||
)
|
)
|
||||||
)
|
) }
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
LaunchedEffect(state.hearingAidData) {
|
||||||
object : (ByteArray) -> Unit {
|
parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed ->
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
val parsed = parseHearingAidSettingsResponse(value)
|
|
||||||
if (parsed != null) {
|
|
||||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
amplificationSliderValue.floatValue = parsed.netAmplification
|
||||||
balanceSliderValue.floatValue = parsed.balance
|
balanceSliderValue.floatValue = parsed.balance
|
||||||
toneSliderValue.floatValue = parsed.leftTone
|
toneSliderValue.floatValue = parsed.leftTone
|
||||||
@@ -140,25 +112,19 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
initialized.value = true
|
||||||
} 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) {
|
LaunchedEffect(
|
||||||
if (!initialLoadComplete.value) {
|
amplificationSliderValue.floatValue,
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
balanceSliderValue.floatValue,
|
||||||
return@LaunchedEffect
|
toneSliderValue.floatValue,
|
||||||
}
|
conversationBoostEnabled.value,
|
||||||
|
ambientNoiseReductionSliderValue.floatValue,
|
||||||
if (!initialReadSucceeded.value) {
|
ownVoiceAmplification.floatValue
|
||||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
) {
|
||||||
return@LaunchedEffect
|
if (!initialized.value) return@LaunchedEffect
|
||||||
}
|
|
||||||
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
@@ -175,53 +141,20 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
StyledScaffold(title = stringResource(R.string.adjustments)) { spacerHeight ->
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
Column(
|
||||||
try {
|
modifier = Modifier
|
||||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
.hazeSource(hazeState)
|
||||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
var parsedSettings: HearingAidSettings? = null
|
.verticalScroll(verticalScrollState)
|
||||||
for (attempt in 1..3) {
|
.padding(horizontal = 16.dp),
|
||||||
initialReadAttempts.intValue = attempt
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
try {
|
) {
|
||||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
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(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
@@ -235,7 +168,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
independent = true,
|
independent = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.swipe_to_control_amplification),
|
label = stringResource(R.string.swipe_to_control_amplification),
|
||||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||||
|
if (parsed == null) {
|
||||||
|
Log.w(TAG, "transparency parse failed")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val disabledSettings = parsed.copy(enabled = false)
|
val disabledSettings = parsed.copy(enabled = false)
|
||||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ import androidx.navigation.NavController
|
|||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import me.kavishdevar.librepods.BuildConfig
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PurchaseScreen(
|
fun PurchaseScreen(
|
||||||
@@ -199,7 +199,7 @@ fun PurchaseScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (BuildConfig.FLAVOR == "xposed") {
|
if (XposedState.isAvailable) {
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
color = Color(0x40888888),
|
color = Color(0x40888888),
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
|
|||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
|
||||||
val textFieldState = rememberTextFieldState()
|
val name = sharedPreferences.getString("name", "")?: ""
|
||||||
textFieldState.edit { sharedPreferences.getString("name", "") ?: "" }
|
val textFieldState = rememberTextFieldState(initialText = name)
|
||||||
|
|
||||||
LaunchedEffect(textFieldState.text) {
|
LaunchedEffect(textFieldState.text) {
|
||||||
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
||||||
viewModel.setName(textFieldState.text.toString())
|
viewModel.setName(textFieldState.text.toString())
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
@@ -64,17 +65,14 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.BuildConfig
|
|
||||||
import me.kavishdevar.librepods.R
|
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.TransparencySettings
|
||||||
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.data.sendTransparencySettings
|
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 me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private const val TAG = "TransparencySettings"
|
private const val TAG = "TransparencySettings"
|
||||||
@@ -112,19 +110,26 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
val enabled = remember { mutableStateOf(false) }
|
val enabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val eq = remember { mutableStateOf(FloatArray(8)) }
|
val eq = rememberSaveable(
|
||||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
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 initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val transparencySettings = remember {
|
val transparencySettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -153,23 +158,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
toneSliderValue.floatValue,
|
toneSliderValue.floatValue,
|
||||||
conversationBoostEnabled.value,
|
conversationBoostEnabled.value,
|
||||||
ambientNoiseReductionSliderValue.floatValue,
|
ambientNoiseReductionSliderValue.floatValue,
|
||||||
eq.value,
|
eq.value
|
||||||
initialLoadComplete.value,
|
|
||||||
initialReadSucceeded.value
|
|
||||||
) {
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialized.value) return@LaunchedEffect
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
transparencySettings.value = TransparencySettings(
|
transparencySettings.value = TransparencySettings(
|
||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
leftEQ = eq.value,
|
leftEQ = eq.value,
|
||||||
@@ -189,38 +180,8 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(state.transparencyData) {
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
|
||||||
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")
|
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
||||||
enabled.value = parsedSettings.enabled
|
enabled.value = parsedSettings.enabled
|
||||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||||
@@ -229,19 +190,10 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
ambientNoiseReductionSliderValue.floatValue =
|
ambientNoiseReductionSliderValue.floatValue =
|
||||||
parsedSettings.leftAmbientNoiseReduction
|
parsedSettings.leftAmbientNoiseReduction
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||||
|
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
|
||||||
eq.value = parsedSettings.leftEQ.copyOf()
|
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
|
|
||||||
}
|
}
|
||||||
|
initialized.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.vendorIdHook) {
|
if (state.vendorIdHook) {
|
||||||
|
|||||||
@@ -35,13 +35,14 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -57,34 +58,19 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.R
|
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.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
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"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateHearingTestScreen() {
|
fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val attManager = ServiceManager.getService()?.attManager
|
val state by viewModel.uiState.collectAsState()
|
||||||
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 backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_test)
|
title = stringResource(R.string.hearing_test)
|
||||||
@@ -112,18 +98,31 @@ fun UpdateHearingTestScreen() {
|
|||||||
),
|
),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
val tone = remember { mutableFloatStateOf(0.5f) }
|
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
|
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
|
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
|
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
val leftEQ = rememberSaveable(
|
||||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
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 debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val hearingAidSettings = remember {
|
val hearingAidSettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -145,10 +144,8 @@ fun UpdateHearingTestScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
LaunchedEffect(state.hearingAidData) {
|
||||||
object : (ByteArray) -> Unit {
|
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
val parsed = parseHearingAidSettingsResponse(value)
|
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
@@ -158,46 +155,24 @@ fun UpdateHearingTestScreen() {
|
|||||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
leftAmplification.floatValue = parsed.leftAmplification
|
leftAmplification.floatValue = parsed.leftAmplification
|
||||||
rightAmplification.floatValue = parsed.rightAmplification
|
rightAmplification.floatValue = parsed.rightAmplification
|
||||||
|
initialized.value = true
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
leftEQ.value,
|
leftEQ.value,
|
||||||
rightEQ.value,
|
rightEQ.value,
|
||||||
conversationBoostEnabled.value,
|
conversationBoostEnabled.value,
|
||||||
initialLoadComplete.value,
|
|
||||||
initialReadSucceeded.value,
|
|
||||||
leftAmplification.floatValue,
|
leftAmplification.floatValue,
|
||||||
rightAmplification.floatValue,
|
rightAmplification.floatValue,
|
||||||
tone.floatValue,
|
tone.floatValue,
|
||||||
ambientNoiseReduction.floatValue,
|
ambientNoiseReduction.floatValue,
|
||||||
ownVoiceAmplification.floatValue
|
ownVoiceAmplification.floatValue
|
||||||
) {
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialized.value) return@LaunchedEffect
|
||||||
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(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
@@ -214,55 +189,7 @@ fun UpdateHearingTestScreen() {
|
|||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val frequencies =
|
val frequencies =
|
||||||
|
|||||||
@@ -29,15 +29,16 @@ import androidx.core.content.edit
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.billing.BillingManager
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
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.ATTHandles
|
||||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||||
import me.kavishdevar.librepods.data.AirPodsModels
|
import me.kavishdevar.librepods.data.AirPodsModels
|
||||||
@@ -93,7 +94,8 @@ data class AirPodsUiState(
|
|||||||
|
|
||||||
val dynamicEndOfCharge: Boolean = false,
|
val dynamicEndOfCharge: Boolean = false,
|
||||||
|
|
||||||
val connectionSuccessful: Boolean = false
|
val connectionSuccessful: Boolean = false,
|
||||||
|
val timeUntilFOSSPremiumExpiry: Long = 0L
|
||||||
)
|
)
|
||||||
|
|
||||||
class AirPodsViewModel(
|
class AirPodsViewModel(
|
||||||
@@ -142,9 +144,11 @@ class AirPodsViewModel(
|
|||||||
loadInstance()
|
loadInstance()
|
||||||
loadSharedPreferences()
|
loadSharedPreferences()
|
||||||
setupControlObservers()
|
setupControlObservers()
|
||||||
observeBilling()
|
|
||||||
loadControlList()
|
loadControlList()
|
||||||
|
loadATT()
|
||||||
observeATT()
|
observeATT()
|
||||||
|
observeSharedPreferences()
|
||||||
|
observeBilling()
|
||||||
if (isDemoMode) activateDemoMode()
|
if (isDemoMode) activateDemoMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,17 +176,37 @@ class AirPodsViewModel(
|
|||||||
// billingFirstCollectDone = true
|
// billingFirstCollectDone = true
|
||||||
// return@collect
|
// return@collect
|
||||||
// }
|
// }
|
||||||
if (!premium) {
|
if (premium) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove("premium_expiry_time")
|
||||||
|
remove("foss_upgraded")
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
|
||||||
|
} else {
|
||||||
|
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
|
||||||
setControlCommandBoolean(
|
setControlCommandBoolean(
|
||||||
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
setHeadGesturesEnabled(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() {
|
private fun observeBroadcasts() {
|
||||||
broadcastReceiver = object : BroadcastReceiver() {
|
broadcastReceiver = object : BroadcastReceiver() {
|
||||||
@@ -358,6 +382,7 @@ class AirPodsViewModel(
|
|||||||
|
|
||||||
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
|
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
|
||||||
|
|
||||||
|
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
offListeningMode = offListeningModeEnabled,
|
offListeningMode = offListeningModeEnabled,
|
||||||
@@ -368,9 +393,56 @@ class AirPodsViewModel(
|
|||||||
rightAction = rightAction,
|
rightAction = rightAction,
|
||||||
vendorIdHook = vendorIdHook,
|
vendorIdHook = vendorIdHook,
|
||||||
dynamicEndOfCharge = dynamicEndOfCharge,
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setOffListeningMode(enabled: Boolean) {
|
fun setOffListeningMode(enabled: Boolean) {
|
||||||
@@ -454,51 +526,69 @@ class AirPodsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
||||||
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
|
when (handle) {
|
||||||
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
|
// 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) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
service.attManager?.connect()
|
service.attManager.writeCharacteristic(handle, value)
|
||||||
while (service.attManager?.socket?.isConnected != true) {
|
|
||||||
delay(250)
|
|
||||||
}
|
|
||||||
service.attManager?.write(handle, value)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshATT() {
|
fun loadATT() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
|
||||||
val loudSoundReduction =
|
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
|
||||||
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
|
loudSoundReduction[0].toInt() == 1
|
||||||
val transparencyData =
|
} else false
|
||||||
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
|
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
|
||||||
val hearingAid =
|
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
|
||||||
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
|
_uiState.update {
|
||||||
_uiState.value = _uiState.value.copy(
|
it.copy(
|
||||||
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
|
loudSoundReductionEnabled = loudSoundReductionEnabled,
|
||||||
transparencyData = transparencyData,
|
transparencyData = transparencyData,
|
||||||
hearingAidData = hearingAid
|
hearingAidData = hearingAidData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeATT() {
|
fun observeATT() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
service.attManager?.connect()
|
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
|
||||||
while (service.attManager?.socket?.isConnected != true) {
|
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
|
||||||
delay(1000)
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
|
|
||||||
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
|
|
||||||
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
refreshATT()
|
|
||||||
delay(15000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.billing.BillingManager
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -34,7 +35,8 @@ data class AppSettingsUiState(
|
|||||||
val isPremium: Boolean = false,
|
val isPremium: Boolean = false,
|
||||||
val connectionSuccessful: Boolean = false,
|
val connectionSuccessful: Boolean = false,
|
||||||
val showBottomSheetPopup: Boolean = true,
|
val showBottomSheetPopup: Boolean = true,
|
||||||
val showIslandPopup: Boolean = true
|
val showIslandPopup: Boolean = true,
|
||||||
|
val timeUntilFOSSPremiumExpiry: Long = 0L
|
||||||
)
|
)
|
||||||
|
|
||||||
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
@@ -66,12 +68,71 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
|
|||||||
private fun observeBilling() {
|
private fun observeBilling() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
BillingManager.provider.isPremium.collect { premium ->
|
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() {
|
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 ->
|
_uiState.update { currentState ->
|
||||||
currentState.copy(
|
currentState.copy(
|
||||||
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ import me.kavishdevar.librepods.MainActivity
|
|||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
|
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.BLEManager
|
||||||
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||||
@@ -126,9 +128,9 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
|
|||||||
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
|
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.time.LocalDateTime
|
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private const val TAG = "AirPodsService"
|
private const val TAG = "AirPodsService"
|
||||||
|
|
||||||
@@ -151,7 +153,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
var macAddress = ""
|
var macAddress = ""
|
||||||
var localMac = ""
|
var localMac = ""
|
||||||
lateinit var aacpManager: AACPManager
|
lateinit var aacpManager: AACPManager
|
||||||
var attManager: ATTManager? = null
|
lateinit var attManager: ATTManagerv2
|
||||||
var airpodsInstance: AirPodsInstance? = null
|
var airpodsInstance: AirPodsInstance? = null
|
||||||
var cameraActive = false
|
var cameraActive = false
|
||||||
private var disconnectedBecauseReversed = false
|
private var disconnectedBecauseReversed = false
|
||||||
@@ -380,6 +382,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
aacpManager = AACPManager()
|
aacpManager = AACPManager()
|
||||||
initializeAACPManagerCallback()
|
initializeAACPManagerCallback()
|
||||||
|
|
||||||
|
attManager = ATTManagerv2()
|
||||||
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||||
|
|
||||||
localMac = config.selfMacAddress
|
localMac = config.selfMacAddress
|
||||||
@@ -654,6 +658,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
|
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
|
||||||
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
|
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
|
||||||
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
|
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
|
||||||
|
addAction("android.bluetooth.device.action.UUID")
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionReceiver = object : BroadcastReceiver() {
|
connectionReceiver = object : BroadcastReceiver() {
|
||||||
@@ -691,8 +696,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
popupShown = false
|
popupShown = false
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
attManager?.disconnect()
|
aacpManager.disconnected()
|
||||||
attManager = null
|
attManager.disconnected()
|
||||||
|
BluetoothConnectionManager.setCurrentConnection(null, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1019,7 +1025,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
)
|
)
|
||||||
// Store in SharedPreferences
|
// Store in SharedPreferences
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putString("airpods_name", deviceInformation.name)
|
putString("name", deviceInformation.name)
|
||||||
putString("airpods_model_number", deviceInformation.modelNumber)
|
putString("airpods_model_number", deviceInformation.modelNumber)
|
||||||
putString("airpods_manufacturer", deviceInformation.manufacturer)
|
putString("airpods_manufacturer", deviceInformation.manufacturer)
|
||||||
putString("airpods_serial_number", deviceInformation.serialNumber)
|
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}"
|
"Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
|
||||||
)
|
)
|
||||||
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
|
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
|
||||||
if (BuildConfig.FLAVOR == "xposed") {
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
|
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
val action = getActionFor(bud, stemPressType)
|
val action = getActionFor(bud, stemPressType)
|
||||||
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
|
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
|
||||||
@@ -2390,16 +2394,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
?.getString("name", bluetoothDevice?.name)
|
?.getString("name", bluetoothDevice?.name)
|
||||||
if (bluetoothDevice != null && !action.isNullOrEmpty()) {
|
if (bluetoothDevice != null && !action.isNullOrEmpty()) {
|
||||||
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
|
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
|
||||||
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
|
|
||||||
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||||
bluetoothDevice.fetchUuidsWithSdp()
|
|
||||||
if (bluetoothDevice.uuids != null) {
|
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
|
||||||
if (bluetoothDevice.uuids.contains(uuid)) {
|
if (bluetoothDevice.uuids?.contains(uuid) == true) {
|
||||||
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
||||||
intent.putExtra("name", name)
|
intent.putExtra("name", name)
|
||||||
intent.putExtra("device", bluetoothDevice)
|
intent.putExtra("device", bluetoothDevice)
|
||||||
context?.sendBroadcast(intent)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2612,15 +2627,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createBluetoothSocket(
|
private fun createBluetoothSocket(
|
||||||
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid
|
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
|
||||||
): BluetoothSocket {
|
): BluetoothSocket {
|
||||||
val type = 3 // L2CAP
|
val type = 3 // L2CAP
|
||||||
val constructorSpecs = listOf(
|
val constructorSpecs = listOf(
|
||||||
arrayOf(adapter, device, type, true, true, 0x1001, uuid), // A16QPR3
|
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
|
||||||
arrayOf(device, type, true, true, 0x1001, uuid),
|
arrayOf(device, type, true, true, psm, uuid),
|
||||||
arrayOf(device, type, 1, true, true, 0x1001, uuid),
|
arrayOf(device, type, 1, true, true, psm, uuid),
|
||||||
arrayOf(type, 1, true, true, device, 0x1001, uuid),
|
arrayOf(type, 1, true, true, device, psm, uuid),
|
||||||
arrayOf(type, true, true, device, 0x1001, uuid)
|
arrayOf(type, true, true, device, psm, uuid)
|
||||||
)
|
)
|
||||||
|
|
||||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
val constructors = BluetoothSocket::class.java.declaredConstructors
|
||||||
@@ -2666,7 +2681,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||||
// if (!isConnectedLocally) {
|
// if (!isConnectedLocally) {
|
||||||
socket = try {
|
socket = try {
|
||||||
createBluetoothSocket(adapter, device, uuid)
|
createBluetoothSocket(adapter, device, uuid, 4097)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
|
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
|
||||||
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
|
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
|
||||||
@@ -2675,17 +2690,30 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
withTimeout(5000L) {
|
withTimeout(5000.milliseconds) {
|
||||||
try {
|
try {
|
||||||
socket.connect()
|
socket.connect()
|
||||||
// isConnectedLocally = true
|
// isConnectedLocally = true
|
||||||
this@AirPodsService.device = device
|
this@AirPodsService.device = device
|
||||||
|
|
||||||
BluetoothConnectionManager.setCurrentConnection(socket, device)
|
|
||||||
val xposedRemotePref = XposedRemotePrefProvider.create()
|
val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||||
if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
|
val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
|
||||||
attManager = ATTManager(adapter, device)
|
createBluetoothSocket(
|
||||||
attManager!!.connect()
|
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
|
// Create AirPodsInstance from stored config if available
|
||||||
@@ -2891,7 +2919,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
socket.close()
|
socket.close()
|
||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
aacpManager.disconnected()
|
aacpManager.disconnected()
|
||||||
attManager?.disconnect()
|
attManager.disconnected()
|
||||||
|
BluetoothConnectionManager.setCurrentConnection(null, null)
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||||
setPackage(packageName)
|
setPackage(packageName)
|
||||||
|
|||||||
@@ -276,4 +276,5 @@
|
|||||||
<string name="optimized_charging">Optimized Charge Limit</string>
|
<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="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="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>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user