Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.health.connect.client)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
Expand Down
25 changes: 25 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
<!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />

<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>

<application
android:name=".LibrePodsApplication"
Expand Down Expand Up @@ -79,6 +84,26 @@
android:resource="@xml/battery_widget_info" />
</receiver>

<activity
android:name=".HealthConnectPermissionsRationaleActivity"
android:exported="true"
android:theme="@style/Theme.LibrePods">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>

<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".HealthConnectPermissionsRationaleActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>

<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
LibrePods - AirPods liberated from Apple’s ecosystem
Copyright (C) 2025 LibrePods contributors

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
*/

package me.kavishdevar.librepods

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.presentation.screens.onboarding.PrivacyPolicyPage
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme

/** Privacy-policy entry point shown from Health Connect's permission UI. */
class HealthConnectPermissionsRationaleActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
LibrePodsTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(16.dp)
) {
PrivacyPolicyPage(
onForward = ::finish,
actionLabel = "Close"
)
}
}
}
}
}
22 changes: 5 additions & 17 deletions android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.os.Bundle
import android.os.IBinder
import android.util.Log
Expand All @@ -40,8 +39,8 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
Expand All @@ -55,6 +54,7 @@ import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.XposedState
import kotlin.io.encoding.ExperimentalEncodingApi
Expand All @@ -80,23 +80,11 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()

setContent {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) }
val appSettingsViewModel: AppSettingsViewModel = viewModel()
val appSettingsState = appSettingsViewModel.uiState.collectAsState()

val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
when (key) {
"m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true)
}
}

DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
}
}
LibrePodsTheme(
m3eEnabled = m3eEnabled.value
m3eEnabled = appSettingsState.value.m3eEnabled
) {
// For demo screenshots
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
*/
class AACPManager {
private val TAG = "AACPManager[${System.identityHashCode(this)}]"
private val writerLock = Any()
companion object {
@Suppress("unused")
object Opcodes {
Expand Down Expand Up @@ -62,6 +63,34 @@ class AACPManager {

private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)

// Exact AACP 1.3 initialization used by the validated RTBuddy probe before HR streaming.
private val HEART_RATE_CONNECT_SERVICE_0 = byteArrayOf(
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
)
private val HEART_RATE_CAPABILITIES_SERVICE_0 =
byteArrayOf(0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00)
private val HEART_RATE_CONNECT_SERVICE_4 = byteArrayOf(
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
)
private val HEART_RATE_CAPABILITIES_SERVICE_4 =
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00)

// Verified RTBuddy SensorDataWX HEARTRATE(19) service-setting frames from the legacy probe.
// These arrays intentionally omit HEADER_BYTES because sendDataPacket() adds it.
private val HEART_RATE_START_1S = byteArrayOf(
0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00,
0x08, 0xE3.toByte(), 0x46, 0x42, 0x0B, 0x08, 0x13, 0x10,
0x02, 0x1A, 0x05, 0x01, 0x40, 0x42, 0x0F, 0x00
)

private val HEART_RATE_STOP = byteArrayOf(
0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00,
0x08, 0xED.toByte(), 0x46, 0x42, 0x0B, 0x08, 0x13, 0x10,
0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
)

data class ControlCommandStatus(
val identifier: ControlCommandIdentifiers, val value: ByteArray
) {
Expand Down Expand Up @@ -235,6 +264,7 @@ class AACPManager {
fun onControlCommandReceived(controlCommand: ByteArray)
fun onDeviceInformationReceived(deviceInformation: AirPodsInformation)
fun onHeadTrackingReceived(headTracking: ByteArray)
fun onHeartRateReceived(sample: HeartRateSample)
fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray)
fun onStemPressReceived(stemPress: ByteArray)
Expand Down Expand Up @@ -280,6 +310,7 @@ class AACPManager {
}

private var callback: PacketCallback? = null
private val heartRateDecoder = RtBuddyHeartRateDecoder()

fun setPacketCallback(callback: PacketCallback) {
this.callback = callback
Expand All @@ -306,6 +337,18 @@ class AACPManager {
return sendPacket(createDataPacket(data))
}

fun sendHeartRateStartFrame(): Boolean = sendDataPacket(HEART_RATE_START_1S)

fun sendHeartRateStopFrame(): Boolean = sendDataPacket(HEART_RATE_STOP)

fun sendHeartRateConnectService0(): Boolean = sendPacket(HEART_RATE_CONNECT_SERVICE_0)

fun sendHeartRateCapabilitiesService0(): Boolean = sendPacket(HEART_RATE_CAPABILITIES_SERVICE_0)

fun sendHeartRateConnectService4(): Boolean = sendPacket(HEART_RATE_CONNECT_SERVICE_4)

fun sendHeartRateCapabilitiesService4(): Boolean = sendPacket(HEART_RATE_CAPABILITIES_SERVICE_4)

fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
val controlPacket = createControlCommandPacket(identifier, value)
setControlCommandStatusValue(
Expand Down Expand Up @@ -397,8 +440,23 @@ class AACPManager {
return opcode + data
}

fun receivePacket(packet: ByteArray): Boolean {
val heartRateResult = heartRateDecoder.feed(packet)
if (heartRateResult.relatedFrameCount > 0) {
Log.d(
TAG,
"Received RTBuddy heart-rate frames=${heartRateResult.relatedFrameCount}, " +
"rejected=${heartRateResult.rejectedFrameCount}, " +
"samples=${heartRateResult.samples.size}"
)
}
heartRateResult.samples.forEach { callback?.onHeartRateReceived(it) }
heartRateResult.passthroughPackets.forEach(::receiveStandardPacket)
return heartRateResult.suppressRawLogging
}

@OptIn(ExperimentalStdlibApi::class)
fun receivePacket(packet: ByteArray) {
private fun receiveStandardPacket(packet: ByteArray) {
if (!packet.toHexString().startsWith("04000400")) {
Log.w(
TAG, "Received packet does not start with expected header: ${
Expand Down Expand Up @@ -1139,7 +1197,11 @@ class AACPManager {
@OptIn(ExperimentalStdlibApi::class)
fun sendPacket(packet: ByteArray): Boolean {
try {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
if (isHeartRateRtBuddyPacket(packet)) {
Log.d(TAG, "Sending RTBuddy heart-rate stream control packet")
} else {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
}

if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = try {
Expand All @@ -1159,15 +1221,16 @@ class AACPManager {
)
}

val socket = BluetoothConnectionManager.aacpSocket ?: return false

if (socket.isConnected) {
socket.outputStream?.write(packet)
socket.outputStream?.flush()
return true
} else {
Log.d(TAG, "Can't send packet: Socket not initialized or connected")
return false
return synchronized(writerLock) {
val socket = BluetoothConnectionManager.aacpSocket
if (socket?.isConnected == true) {
socket.outputStream.write(packet)
socket.outputStream.flush()
true
} else {
Log.d(TAG, "Can't send packet: Socket not initialized or connected")
false
}
}
} catch (e: Exception) {
Log.e(TAG, "Error sending packet: ${e.message}")
Expand Down Expand Up @@ -1269,8 +1332,14 @@ class AACPManager {
)
}

private fun isHeartRateRtBuddyPacket(packet: ByteArray): Boolean {
return packet.contentEquals(HEADER_BYTES + HEART_RATE_START_1S) ||
packet.contentEquals(HEADER_BYTES + HEART_RATE_STOP)
}

fun disconnected() {
Log.d(TAG, "Disconnected, clearing state")
heartRateDecoder.reset()
controlCommandStatusList.clear()
controlCommandListeners.clear()
owns = false
Expand Down
Loading