Compare commits

...

5 Commits

Author SHA1 Message Date
Kavish Devar 56f9274145 android: add gradle jvm properties file
no idea why this is the first time it showed up, but it's supposed to be checked out, so...
2026-08-25 16:13:10 +05:30
Kavish Devar a68637ed7c android: refactor... stuff (4, see description)
hrm alerts
2026-08-25 15:57:36 +05:30
Kavish Devar e950aa010d android: refactor... stuff (3, see description)
implement head gestures for calls, clean up code ig
2026-08-16 21:02:11 +05:30
Kavish Devar ddd1a9c5ce android: refactor... stuff (2, see description)
implement buddy (head tracking and heartrate, finally!), added swipe anywhere to go back, and cleaned up app settings a bit.
2026-08-16 01:24:55 +05:30
Kavish Devar 8457880210 android: refactor... stuff (1, see description)
use StateFlows instead of broadcasts/callbacks and add support for multiple devices. some features are yet to be implemented, marked with TODO. forgot to make incremental commits
2026-08-11 16:16:11 +05:30
319 changed files with 27974 additions and 18935 deletions
+42 -6
View File
@@ -6,6 +6,10 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.room3)
alias(libs.plugins.protobuf)
// alias(libs.plugins.hilt)
id("kotlin-parcelize")
}
@@ -24,10 +28,34 @@ val releaseSigningAvailable = listOf(
"RELEASE_KEY_PASSWORD"
).all { props[it]?.toString()?.isNotBlank() == true }
room3 {
schemaDirectory("$projectDir/schemas")
}
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java")
create("kotlin")
}
}
}
}
kotlin {
compilerOptions {
optIn.add(
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi"
optIn.addAll(
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"kotlin.uuid.ExperimentalUuidApi",
"kotlinx.coroutines.FlowPreview",
"kotlinx.serialization.ExperimentalSerializationApi",
"kotlinx.coroutines.ExperimentalCoroutinesApi",
"dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi"
)
}
}
@@ -49,7 +77,7 @@ android {
defaultConfig {
applicationId = "me.kavishdevar.librepods"
targetSdk = 37
versionCode = 63
versionCode = 85
versionName = appVersionName
}
buildTypes {
@@ -125,8 +153,8 @@ android {
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions)
implementation(libs.androidx.compose.ui.text.google.fonts)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.foundation.layout)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -140,12 +168,11 @@ dependencies {
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.billing)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.backdrop)
// implementation(libs.hilt)
@@ -158,6 +185,12 @@ dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.navigationevent)
implementation(libs.androidx.room3.runtime)
ksp(libs.androidx.room3.compiler)
implementation(libs.kotlinx.serialization.cbor)
implementation(libs.protobuf.kotlin)
// compileOnly(files("../../../framework-classes.jar"))
implementation(libs.androidx.healthconnect.client)
}
aboutLibraries {
@@ -178,6 +211,7 @@ fun registerRootModuleZipTask(
flavor: String,
buildType: String
) = tasks.register<Zip>(name) {
description = "Zips the root module for the $flavor-$buildType variant."
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
dependsOn(variantTask)
@@ -213,6 +247,7 @@ val zipDebug = registerRootModuleZipTask(
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
description = "Collects release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
dependsOn(
zipRelease,
zipDebug,
@@ -241,5 +276,6 @@ val collect = tasks.register<Copy>("collectReleaseArtifacts") {
}
tasks.register("packageReleaseArtifacts") {
description = "Packages release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
dependsOn(collect)
}
@@ -0,0 +1,221 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "c547604d916583c3b94c7bf1ac787fdf",
"entities": [
{
"tableName": "AppleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
"fields": [
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "settings",
"columnName": "settings",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "cache",
"columnName": "cache",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"macAddress"
]
}
},
{
"tableName": "AppSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nightMode",
"columnName": "nightMode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "designSystem",
"columnName": "designSystem",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useHighestRefreshRate",
"columnName": "useHighestRefreshRate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "debugMode",
"columnName": "debugMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleScanMode",
"columnName": "bleScanMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleReportDelay",
"columnName": "bleReportDelay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "swipeAnywhereForBack",
"columnName": "swipeAnywhereForBack",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "AppStateEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "isPremium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCompletedOnboarding",
"columnName": "hasCompletedOnboarding",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastVersionShown",
"columnName": "lastVersionShown",
"affinity": "TEXT"
},
{
"fieldPath": "hasConnectedToAACP",
"columnName": "hasConnectedToAACP",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstSuccessfulConnectionTime",
"columnName": "firstSuccessfulConnectionTime",
"affinity": "INTEGER"
},
{
"fieldPath": "reviewPrompted",
"columnName": "reviewPrompted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeUntilFOSSPremiumExpiry",
"columnName": "timeUntilFOSSPremiumExpiry",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "WidgetConfigEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
"fields": [
{
"fieldPath": "appWidgetId",
"columnName": "appWidgetId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"appWidgetId"
]
}
},
{
"tableName": "HeartRateSampleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bpm",
"columnName": "bpm",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c547604d916583c3b94c7bf1ac787fdf')"
]
}
}
@@ -0,0 +1,221 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "721149fa5e2a13e82b73a83de312968a",
"entities": [
{
"tableName": "AppleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
"fields": [
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "settings",
"columnName": "settings",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "cache",
"columnName": "cache",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"macAddress"
]
}
},
{
"tableName": "AppSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nightMode",
"columnName": "nightMode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "designSystem",
"columnName": "designSystem",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useHighestRefreshRate",
"columnName": "useHighestRefreshRate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "debugMode",
"columnName": "debugMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleScanMode",
"columnName": "bleScanMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleReportDelay",
"columnName": "bleReportDelay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "swipeAnywhereForBack",
"columnName": "swipeAnywhereForBack",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "AppStateEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "isPremium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCompletedOnboarding",
"columnName": "hasCompletedOnboarding",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastVersionShown",
"columnName": "lastVersionShown",
"affinity": "TEXT"
},
{
"fieldPath": "hasConnectedToAACP",
"columnName": "hasConnectedToAACP",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstSuccessfulConnectionTime",
"columnName": "firstSuccessfulConnectionTime",
"affinity": "INTEGER"
},
{
"fieldPath": "reviewPrompted",
"columnName": "reviewPrompted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeUntilFOSSPremiumExpiry",
"columnName": "timeUntilFOSSPremiumExpiry",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "WidgetConfigEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
"fields": [
{
"fieldPath": "appWidgetId",
"columnName": "appWidgetId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"appWidgetId"
]
}
},
{
"tableName": "HeartRateSampleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bpm",
"columnName": "bpm",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
]
}
}
@@ -0,0 +1,226 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "721149fa5e2a13e82b73a83de312968a",
"entities": [
{
"tableName": "AppleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
"fields": [
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "settings",
"columnName": "settings",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "cache",
"columnName": "cache",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"macAddress"
]
}
},
{
"tableName": "AppSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `overrideMaterialColor` INTEGER, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nightMode",
"columnName": "nightMode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "designSystem",
"columnName": "designSystem",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "overrideMaterialColor",
"columnName": "overrideMaterialColor",
"affinity": "INTEGER"
},
{
"fieldPath": "useHighestRefreshRate",
"columnName": "useHighestRefreshRate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "debugMode",
"columnName": "debugMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleScanMode",
"columnName": "bleScanMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleReportDelay",
"columnName": "bleReportDelay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "swipeAnywhereForBack",
"columnName": "swipeAnywhereForBack",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "AppStateEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "isPremium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCompletedOnboarding",
"columnName": "hasCompletedOnboarding",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastVersionShown",
"columnName": "lastVersionShown",
"affinity": "TEXT"
},
{
"fieldPath": "hasConnectedToAACP",
"columnName": "hasConnectedToAACP",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstSuccessfulConnectionTime",
"columnName": "firstSuccessfulConnectionTime",
"affinity": "INTEGER"
},
{
"fieldPath": "reviewPrompted",
"columnName": "reviewPrompted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeUntilFOSSPremiumExpiry",
"columnName": "timeUntilFOSSPremiumExpiry",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "WidgetConfigEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
"fields": [
{
"fieldPath": "appWidgetId",
"columnName": "appWidgetId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"appWidgetId"
]
}
},
{
"tableName": "HeartRateSampleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bpm",
"columnName": "bpm",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
]
}
}
+37 -11
View File
@@ -26,15 +26,22 @@
<uses-permission
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.MODIFY_AUDIO_ROUTING"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
<!-- android:maxSdkVersion="30" />-->
@@ -42,6 +49,8 @@
<!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
<application
android:name=".LibrePodsApplication"
android:allowBackup="true"
@@ -80,7 +89,7 @@
</receiver>
<activity
android:name=".MainActivity"
android:name=".presentation.activities.MainActivity"
android:exported="true"
android:theme="@style/Theme.LibrePods">
<intent-filter>
@@ -98,16 +107,11 @@
</activity>
<activity
android:name=".QuickSettingsDialogActivity"
android:exported="false"
android:theme="@style/Theme.TransparentDialog"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:taskAffinity=""
/>
android:name=".presentation.activities.NoiseControlWidgetConfigurationActivity"
android:exported="false" />
<service
android:name=".services.AirPodsService"
android:name=".services.LibrePodsService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="connectedDevice"
@@ -115,7 +119,7 @@
<service
android:name=".services.AirPodsQSService"
android:exported="true"
android:icon="@drawable/airpods"
android:icon="@drawable/ic_airpods"
android:label="ANC Mode"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
@@ -153,6 +157,28 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<activity
android:name=".presentation.activities.PrivacyPolicyActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".presentation.activities.PrivacyPolicyActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
</application>
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest>
+10 -10
View File
@@ -3,21 +3,21 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23)
add_library(bluetooth_socket SHARED
bluetooth_socket.cpp
add_library(hiddenapi SHARED
hiddenapi.cpp
)
target_compile_options(bluetooth_socket PRIVATE
target_compile_options(hiddenapi PRIVATE
-O2
-fvisibility=hidden
)
target_link_options(bluetooth_socket PRIVATE
target_link_options(hiddenapi PRIVATE
-Wl,--strip-all
-Wl,--gc-sections
)
target_link_libraries(bluetooth_socket
target_link_libraries(hiddenapi
android
log
)
@@ -25,8 +25,8 @@ target_link_libraries(bluetooth_socket
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
add_library(l2c_fcr_hook SHARED
l2c_fcr_hook.cpp
add_library(fluoride_hooks SHARED
fluoride_hooks.cpp
xz/xz_crc32.c
xz/xz_crc64.c
@@ -36,11 +36,11 @@ add_library(l2c_fcr_hook SHARED
xz/xz_dec_bcj.c
)
target_include_directories(l2c_fcr_hook PRIVATE
target_include_directories(fluoride_hooks PRIVATE
xz
)
target_compile_definitions(l2c_fcr_hook PRIVATE
target_compile_definitions(fluoride_hooks PRIVATE
XZ_DEC_X86
XZ_DEC_ARM
XZ_DEC_ARMTHUMB
@@ -51,7 +51,7 @@ target_compile_definitions(l2c_fcr_hook PRIVATE
XZ_DEC_CONCATENATED
)
target_link_libraries(l2c_fcr_hook
target_link_libraries(fluoride_hooks
android
log
)
+203
View File
@@ -0,0 +1,203 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include <android/log.h>
#include <cstring>
#include <string>
#include <vector>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <elf.h>
#include <atomic>
#include <jni.h>
#include "helpers.h"
#include "fluoride_hooks.h"
static std::atomic<bool> enableSdpHook(false);
static HookFunType hook_func = nullptr;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
LOGI("fake_l2c_fcr_chk_chan_modes called");
uint8_t orig = 0;
if (original_l2c_fcr_chk_chan_modes)
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig);
return 1;
}
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
LOGI("fake_BTA_DmSetLocalDiRecord called");
if (original_BTA_DmSetLocalDiRecord &&
enableSdpHook.load(std::memory_order_relaxed))
original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
if (p_device_info) {
p_device_info->vendor = 0x004C;
p_device_info->vendor_id_source = 0x0001;
}
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle)
: BTA_FAILURE);
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info,
p_handle)
: BTA_FAILURE;
}
static bool hookLibrary(const char *libname) {
LOGI("hookLibrary called with libname: %s", libname);
if (!hook_func) {
LOGE("hook_func not initialized");
return false;
}
std::string path;
if (!getLibraryPath(libname, path)) {
LOGE("Failed to locate %s", libname);
return false;
}
LOGI("hookLibrary: located path: %s", path.c_str());
int fd = open(path.c_str(), O_RDONLY);
if (fd < 0) {
LOGE("hookLibrary: open failed");
return false;
}
struct stat st{};
if (fstat(fd, &st) != 0) {
LOGE("hookLibrary: fstat failed");
close(fd);
return false;
}
LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
std::vector<uint8_t> file(st.st_size);
read(fd, file.data(), st.st_size);
close(fd);
auto *eh = reinterpret_cast<Elf64_Ehdr *>(file.data());
auto *shdr = reinterpret_cast<Elf64_Shdr *>(
file.data() + eh->e_shoff);
const char *shstr = reinterpret_cast<const char *>(
file.data() + shdr[eh->e_shstrndx].sh_offset);
uint64_t chk_offset = 0;
uint64_t sdp_offset = 0;
for (int i = 0; i < eh->e_shnum; ++i) {
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
LOGI("hookLibrary: found .gnu_debugdata section");
std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
std::vector<uint8_t> decompressed;
if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
} else {
LOGE("debugdata decompress failed");
}
break;
}
}
if (!chk_offset) {
LOGI("fallback dynsym chk");
chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes");
}
if (!sdp_offset) {
LOGI("fallback dynsym sdp");
sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord");
}
uintptr_t base = getModuleBase(libname);
if (!base) {
LOGE("hookLibrary: getModuleBase failed");
return false;
}
if (chk_offset) {
void *target = reinterpret_cast<void *>(base + chk_offset);
hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
(void **) &original_l2c_fcr_chk_chan_modes);
LOGI("hooked chk");
}
if (sdp_offset) {
void *target = reinterpret_cast<void *>(base + sdp_offset);
hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
(void **) &original_BTA_DmSetLocalDiRecord);
LOGI("hooked sdp");
}
return chk_offset || sdp_offset;
}
static void on_library_loaded(const char *name, void *) {
LOGI("on_library_loaded called with name: %s", name);
if (strstr(name, "libbluetooth_jni.so")) {
LOGI("Bluetooth JNI loaded");
hookLibrary("libbluetooth_jni.so");
}
if (strstr(name, "libbluetooth_qti.so")) {
LOGI("Bluetooth QTI loaded");
hookLibrary("libbluetooth_qti.so");
}
}
extern "C" [[gnu::visibility("default")]]
[[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
LOGI("native_init called with entries: %p", entries);
hook_func = (HookFunType) entries->hook_func;
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
enableSdpHook.load(std::memory_order_relaxed));
return on_library_loaded;
}
extern "C" JNIEXPORT void JNICALL
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz,
jboolean enable) {
LOGI("setSdpHook called with enable: %d", enable);
enableSdpHook.store(enable, std::memory_order_relaxed);
LOGI("sdp hook enabled: %d", enable);
}
@@ -1,21 +1,3 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include <android/log.h>
#include <cstring>
#include <string>
@@ -24,60 +6,17 @@
#include <unistd.h>
#include <sys/stat.h>
#include <elf.h>
#include <atomic>
#include <jni.h>
#include "l2c_fcr_hook.h"
extern "C" {
#include "xz.h"
#include "xz.h"
}
#define LOG_TAG "LibrePodsHook"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
static HookFunType hook_func = nullptr;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
static std::atomic<bool> enableSdpHook(false);
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
LOGI("fake_l2c_fcr_chk_chan_modes called");
uint8_t orig = 0;
if (original_l2c_fcr_chk_chan_modes)
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig);
return 1;
}
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
LOGI("fake_BTA_DmSetLocalDiRecord called");
if (original_BTA_DmSetLocalDiRecord &&
enableSdpHook.load(std::memory_order_relaxed))
original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
if (p_device_info) {
p_device_info->vendor = 0x004C;
p_device_info->vendor_id_source = 0x0001;
}
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle)
: BTA_FAILURE);
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info,
p_handle)
: BTA_FAILURE;
}
static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<uint8_t> &output) {
LOGI("decompressXZ called with input_size: %zu", input_size);
@@ -184,7 +123,7 @@ static uintptr_t getModuleBase(const char *name) {
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, name)) {
base = strtoull(line, nullptr, 16);
LOGI("getModuleBase: found base at 0x%lx", base);
LOGI("getModuleBase: found base at 0x%x", base);
break;
}
}
@@ -302,134 +241,3 @@ static uint64_t findSymbolOffset(const std::vector<uint8_t> &elf, const char *sy
LOGI("findSymbolOffset: no match found for %s", symbol_substring);
return 0;
}
static bool hookLibrary(const char *libname) {
LOGI("hookLibrary called with libname: %s", libname);
if (!hook_func) {
LOGE("hook_func not initialized");
return false;
}
std::string path;
if (!getLibraryPath(libname, path)) {
LOGE("Failed to locate %s", libname);
return false;
}
LOGI("hookLibrary: located path: %s", path.c_str());
int fd = open(path.c_str(), O_RDONLY);
if (fd < 0) {
LOGE("hookLibrary: open failed");
return false;
}
struct stat st{};
if (fstat(fd, &st) != 0) {
LOGE("hookLibrary: fstat failed");
close(fd);
return false;
}
LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
std::vector<uint8_t> file(st.st_size);
read(fd, file.data(), st.st_size);
close(fd);
auto *eh = reinterpret_cast<Elf64_Ehdr *>(file.data());
auto *shdr = reinterpret_cast<Elf64_Shdr *>(
file.data() + eh->e_shoff);
const char *shstr = reinterpret_cast<const char *>(
file.data() + shdr[eh->e_shstrndx].sh_offset);
uint64_t chk_offset = 0;
uint64_t sdp_offset = 0;
for (int i = 0; i < eh->e_shnum; ++i) {
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
LOGI("hookLibrary: found .gnu_debugdata section");
std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
std::vector<uint8_t> decompressed;
if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
} else {
LOGE("debugdata decompress failed");
}
break;
}
}
if (!chk_offset) {
LOGI("fallback dynsym chk");
chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes");
}
if (!sdp_offset) {
LOGI("fallback dynsym sdp");
sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord");
}
uintptr_t base = getModuleBase(libname);
if (!base) {
LOGE("hookLibrary: getModuleBase failed");
return false;
}
if (chk_offset) {
void *target = reinterpret_cast<void *>(base + chk_offset);
hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
(void **) &original_l2c_fcr_chk_chan_modes);
LOGI("hooked chk");
}
if (sdp_offset) {
void *target = reinterpret_cast<void *>(base + sdp_offset);
hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
(void **) &original_BTA_DmSetLocalDiRecord);
LOGI("hooked sdp");
}
return chk_offset || sdp_offset;
}
static void on_library_loaded(const char *name, void *) {
LOGI("on_library_loaded called with name: %s", name);
if (strstr(name, "libbluetooth_jni.so")) {
LOGI("Bluetooth JNI loaded");
hookLibrary("libbluetooth_jni.so");
}
if (strstr(name, "libbluetooth_qti.so")) {
LOGI("Bluetooth QTI loaded");
hookLibrary("libbluetooth_qti.so");
}
}
extern "C" [[gnu::visibility("default")]]
[[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
LOGI("native_init called with entries: %p", entries);
hook_func = (HookFunType) entries->hook_func;
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
enableSdpHook.load(std::memory_order_relaxed));
return on_library_loaded;
}
extern "C" JNIEXPORT void JNICALL
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz,
jboolean enable) {
LOGI("setSdpHook called with enable: %d", enable);
enableSdpHook.store(enable, std::memory_order_relaxed);
LOGI("sdp hook enabled: %d", enable);
}
@@ -41,7 +41,7 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
constexpr auto c6 = ENC("java/lang/String");
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
constexpr auto c8 = ENC("L");
JNIEnv* env;
getVm()->AttachCurrentThread(&env, nullptr);
@@ -1,245 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods
// import me.kavishdevar.librepods.screens.Onboarding
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
//import dagger.hilt.android.AndroidEntryPoint
import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.ComponentName
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
import androidx.activity.ComponentActivity
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.edit
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.android.play.core.review.ReviewManagerFactory
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.data.AirPodsNotifications
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.services.AirPodsService
import me.kavishdevar.librepods.utils.XposedState
import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver
lateinit var testReviewReceiver: BroadcastReceiver
//@AndroidEntryPoint
@ExperimentalMaterial3Api
class MainActivity : ComponentActivity() {
companion object {
init {
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
System.loadLibrary("l2c_fcr_hook")
}
}
}
@ExperimentalHazeMaterialsApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) }
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
) {
// For demo screenshots
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
Main()
}
}
}
override fun onDestroy() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
super.onDestroy()
}
override fun onStop() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
super.onStop()
}
}
@ExperimentalHazeMaterialsApi
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun Main() {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val airPodsViewModel: AirPodsViewModel = viewModel()
LaunchedEffect(Unit) {
if (BuildConfig.PLAY_BUILD) {
val now = System.currentTimeMillis()
val firstConn =
sharedPreferences.getLong("first_connection_successful_time", 0L)
val alreadyPrompted =
sharedPreferences.getBoolean("review_prompted", false)
val oneDay = 24 * 60 * 60 * 1000L
if (
firstConn != 0L &&
!alreadyPrompted &&
(now - firstConn) > oneDay
) {
triggerReviewFlow(context as? Activity ?: return@LaunchedEffect)
sharedPreferences.edit {
putBoolean("review_prompted", true)
}
}
}
}
val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false)
val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}"
val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false)
fun bindService() {
context.startForegroundService(Intent(context, AirPodsService::class.java))
serviceConnection = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder
val service = binder.getService()
airPodsService.value = service
airPodsViewModel.init(
service = service,
controlRepo = ControlCommandRepository(service.aacpManager),
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
appContext = context.applicationContext
)
if (!sharedPreferences.contains("first_connection_successful_time")) {
sharedPreferences.edit {
putLong("first_connection_successful_time", System.currentTimeMillis())
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
airPodsService.value = null
}
}
context.bindService(
Intent(context, AirPodsService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
if (onboardingComplete) {
bindService()
}
NavigationRoot(
showReleaseNotes = !releaseNotesShown,
updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } },
showOnboarding = !onboardingComplete,
onboardingComplete = {
sharedPreferences.edit { putBoolean("onboarding_complete", true) }
bindService()
},
airPodsViewModel = airPodsViewModel
)
}
private fun triggerReviewFlow(activity: Activity) {
val manager = ReviewManagerFactory.create(activity)
val request = manager.requestReviewFlow()
request.addOnCompleteListener { task ->
if (task.isSuccessful) {
val reviewInfo = task.result
manager.launchReviewFlow(activity, reviewInfo)
}
}
}
@@ -1,639 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.view.Gravity
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.presentation.components.IconAreaSize
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
class QuickSettingsDialogActivity : ComponentActivity() {
private var airPodsService: AirPodsService? = null
private var isBound = false
private var isNoiseControlExpandedState by mutableStateOf(false)
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as AirPodsService.LocalBinder
airPodsService = binder.getService()
isBound = true
Log.d("QSActivity", "Service bound")
setContent {
LibrePodsTheme {
DraggableDismissBox(
onDismiss = { finish() },
onlyCollapseWhenClicked = {
if (isNoiseControlExpandedState) {
isNoiseControlExpandedState = false
true
} else {
false
}
}
) {
if (isBound && airPodsService != null) {
NewControlCenterDialogContent(
service = airPodsService,
isNoiseControlExpanded = isNoiseControlExpandedState,
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
)
}
}
}
}
}
override fun onServiceDisconnected(arg0: ComponentName) {
isBound = false
airPodsService = null
Log.d("QSActivity", "Service unbound")
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH)
window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
window.setGravity(Gravity.BOTTOM)
Intent(this, AirPodsService::class.java).also { intent ->
bindService(intent, connection, BIND_AUTO_CREATE)
}
setContent {
LibrePodsTheme {
DraggableDismissBox(
onDismiss = { finish() },
onlyCollapseWhenClicked = {
if (isNoiseControlExpandedState) {
isNoiseControlExpandedState = false
true
} else {
false
}
}
) {
if (isBound && airPodsService != null) {
NewControlCenterDialogContent(
service = airPodsService,
isNoiseControlExpanded = isNoiseControlExpandedState,
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
)
}
}
}
}
}
override fun onDestroy() {
super.onDestroy()
if (isBound) {
unbindService(connection)
isBound = false
}
}
}
@Composable
fun DraggableDismissBox(
onDismiss: () -> Unit,
onlyCollapseWhenClicked: () -> Boolean,
content: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
var dragOffset by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) }
val dismissThreshold = 400f
val animatedOffset = remember { Animatable(0f) }
val animatedScale = remember { Animatable(1f) }
val animatedAlpha = remember { Animatable(1f) }
val backgroundAlpha by animateFloatAsState(
targetValue = if (isDragging) {
val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f)
1f - dragProgress
} else 1f,
label = "BackgroundFade"
)
LaunchedEffect(isDragging) {
if (!isDragging) {
if (abs(dragOffset) < dismissThreshold) {
val springSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessHigh,
visibilityThreshold = 0.1f
)
launch { animatedOffset.animateTo(0f, springSpec) }
launch { animatedScale.animateTo(1f, springSpec) }
launch { animatedAlpha.animateTo(1f, tween(100)) }
dragOffset = 0f
}
}
}
LaunchedEffect(dragOffset, isDragging) {
if (isDragging) {
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
animatedOffset.snapTo(dragOffset)
animatedScale.snapTo(1f - dragProgress * 0.3f)
animatedAlpha.snapTo(1f - dragProgress * 0.7f)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f * backgroundAlpha))
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragStart = { isDragging = true },
onDragEnd = {
isDragging = false
if (abs(dragOffset) > dismissThreshold) {
coroutineScope.launch {
val direction = if (dragOffset > 0) 1f else -1f
launch {
animatedOffset.animateTo(
direction * 1500f,
tween(350, easing = FastOutSlowInEasing)
)
}
launch { animatedScale.animateTo(0.7f, tween(350)) }
launch { animatedAlpha.animateTo(0f, tween(250)) }
kotlinx.coroutines.delay(350)
onDismiss()
}
}
},
onDragCancel = { isDragging = false },
onVerticalDrag = { change, dragAmount ->
change.consume()
dragOffset += dragAmount
}
)
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onlyCollapseWhenClicked()
},
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer(
translationY = animatedOffset.value,
scaleX = animatedScale.value,
scaleY = animatedScale.value,
alpha = animatedAlpha.value
),
contentAlignment = Alignment.BottomCenter
) {
content()
}
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@Composable
fun NewControlCenterDialogContent(
service: AirPodsService?,
isNoiseControlExpanded: Boolean,
onNoiseControlExpandedChange: (Boolean) -> Unit
) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val textColor = Color.White
var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) }
var isConvAwarenessEnabled by remember { mutableStateOf(false) }
val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) }
val availableModes = remember(isOffModeEnabled) {
mutableListOf(
NoiseControlMode.TRANSPARENCY,
NoiseControlMode.ADAPTIVE,
NoiseControlMode.NOISE_CANCELLATION
).apply {
if (isOffModeEnabled) {
add(0, NoiseControlMode.OFF)
}
}
}
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) }
val animatedVolumeFraction by animateFloatAsState(
targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(),
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "VolumeAnimation"
)
var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) }
var isDraggingVolume by remember { mutableStateOf(false) }
LaunchedEffect(animatedVolumeFraction, isDraggingVolume) {
if (!isDraggingVolume) {
liveDragFraction = animatedVolumeFraction
}
}
DisposableEffect(service, availableModes) {
val ancReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.ANC_DATA && service != null) {
val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1
val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY }
if (availableModes.contains(newMode)) {
currentAncMode = newMode
} else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) {
currentAncMode = NoiseControlMode.TRANSPARENCY
}
Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})")
}
}
}
val filter = IntentFilter(AirPodsNotifications.ANC_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(ancReceiver, filter)
}
service?.let {
val initialModeOrdinal = it.getANC().minus(1)
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
if (!availableModes.contains(initialMode)) {
initialMode = NoiseControlMode.TRANSPARENCY
}
currentAncMode = initialMode
isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true)
Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled")
}
onDispose {
context.unregisterReceiver(ancReceiver)
}
}
DisposableEffect(Unit) {
val volumeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == "android.media.VOLUME_CHANGED_ACTION") {
val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
if (newVolume != currentVolumeInt) {
currentVolumeInt = newVolume
Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt")
}
}
}
}
val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION")
context.registerReceiver(volumeReceiver, filter)
onDispose {
context.unregisterReceiver(volumeReceiver)
}
}
val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" }
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
.padding(horizontal = 24.dp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent()
}
}
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
if (service != null) {
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier
.weight(2f)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(id = R.drawable.airpods),
contentDescription = "Device Icon",
tint = textColor.copy(alpha = 0.8f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = deviceName,
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(32.dp))
VerticalVolumeSlider(
displayFraction = animatedVolumeFraction,
maxVolume = maxVolume,
onVolumeChange = { newVolume ->
currentVolumeInt = newVolume
try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
} catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) }
},
initialFraction = animatedVolumeFraction,
onDragStateChange = { dragging -> isDraggingVolume = dragging },
baseSliderHeight = 400.dp,
baseSliderWidth = 145.dp,
baseCornerRadius = 48.dp,
maxStretchFactor = 1.15f,
minCompressionFactor = 0.875f,
stretchSensitivity = 0.3f,
compressionSensitivity = 0.3f,
cornerRadiusChangeFactor = -0.5f,
directionalStretchRatio = 0.75f,
modifier = Modifier
.width(145.dp)
.padding(vertical = 8.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 72.dp)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
),
contentAlignment = Alignment.Center
) {
Crossfade(
targetState = isNoiseControlExpanded,
animationSpec = tween(durationMillis = 300),
label = "NoiseControlCrossfade"
) { expanded ->
if (expanded) {
ControlCenterNoiseControlSegmentedButton(
availableModes = availableModes,
selectedMode = currentAncMode,
onModeSelected = { newMode ->
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
value = newMode.ordinal + 1
)
currentAncMode = newMode
},
modifier = Modifier.fillMaxWidth(0.8f)
)
} else {
Row(
modifier = Modifier.fillMaxWidth(0.85f),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) {
AdaptiveRainbowBrush
} else {
null
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(IconAreaSize)
.clip(CircleShape)
.background(
brush = noiseControlButtonBrush ?:
Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF)))
)
.clickable(
onClick = { onNoiseControlExpandedChange(true) },
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = getModeIconRes(currentAncMode)),
contentDescription = getModeLabel(currentAncMode),
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = getModeLabel(currentAncMode),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
Spacer(modifier = Modifier.width(24.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(IconAreaSize)
.clip(CircleShape)
.background(
Brush.linearGradient(
colors = listOf(
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E),
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E)
)
)
)
.clickable(
onClick = {
val newState = !isConvAwarenessEnabled
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
value = newState
)
isConvAwarenessEnabled = newState
},
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.airpods),
contentDescription = "Conversational Awareness",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Conversational\nAwareness",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
lineHeight = 14.sp
)
}
}
}
}
}
} else {
Spacer(modifier = Modifier.weight(1f))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Loading...", color = textColor)
}
Spacer(modifier = Modifier.weight(1f))
}
}
}
private fun getModeIconRes(mode: NoiseControlMode): Int {
return when (mode) {
NoiseControlMode.OFF -> R.drawable.noise_cancellation
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
}
}
private fun getModeLabel(mode: NoiseControlMode): String {
return when (mode) {
NoiseControlMode.OFF -> "Off"
NoiseControlMode.TRANSPARENCY -> "Transparency"
NoiseControlMode.ADAPTIVE -> "Adaptive"
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,211 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "ATTManager"
enum class ATTHandles(val value: Int) {
TRANSPARENCY(0x18),
LOUD_SOUND_REDUCTION(0x1B),
HEARING_AID(0x2A)
}
enum class ATTCCCDHandles(val value: Int) {
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
}
class ATTManagerv2 {
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
private val readerRunning = AtomicBoolean(false)
private var readerThread: Thread? = null
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
fun startReader() {
if (readerRunning.getAndSet(true)) return
readerThread = Thread {
try {
runReaderLoop()
} catch (t: Throwable) {
Log.e(TAG, "reader thread crashed: ${t.message}", t)
} finally {
readerRunning.set(false)
Log.d(TAG, "reader thread stopped")
}
}.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() }
Log.d(TAG, "reader started")
}
fun stopReader() {
readerRunning.set(false)
readerThread?.interrupt()
readerThread = null
}
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.attSocket ?: return null
try {
val output = socket.outputStream
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
synchronized(output) {
output.write(pdu)
output.flush()
}
Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val resp = waitForResponse(0x0B, timeoutMillis) ?: run {
Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}")
return null
}
Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
val value = resp.copyOfRange(1, resp.size)
characteristicList[handle] = value
return value
} catch (e: Exception) {
Log.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.attSocket ?: 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
}
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.e(TAG, "error writing characteristic: ${e.message}")
}
}
fun disconnected() {
characteristicList.clear()
stopReader()
val socket = BluetoothConnectionManager.attSocket?: return
try {
socket.close()
} catch (e: Exception) {
Log.w(TAG, "error closing socket: ${e.message}")
}
Log.d(TAG, "ATT disconnected")
}
private fun runReaderLoop() {
val socket = BluetoothConnectionManager.attSocket ?: run {
Log.w(TAG, "ATT socket not available. stopping reader")
readerRunning.set(false)
return
}
val input = socket.inputStream
val buffer = ByteArray(512)
while (readerRunning.get()) {
try {
val len = input.read(buffer)
if (len == -1) {
Log.w(TAG, "ATT input stream ended")
break
}
val data = buffer.copyOfRange(0, len)
if (data.isEmpty()) continue
val opcode = data[0]
Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}")
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
queue.offer(data)
if (opcode == 0x1B.toByte()) {
if (data.size >= 3) {
val handle = data[1]
val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0)
Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}")
try {
onNotificationReceived?.invoke(handle, value)
} catch (t: Throwable) {
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
}
} else {
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
}
}
} catch (e: Exception) {
Log.e(TAG, "error in reader loop: ${e.message}", e)
break
}
}
readerRunning.set(false)
}
private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? {
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
return try {
queue.poll(timeoutMillis, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
@@ -1,76 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
object BluetoothConnectionManager {
var aacpSocket: BluetoothSocket? = null
var attSocket: BluetoothSocket? = null
}
fun createBluetoothSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
arrayOf(device, type, true, true, psm, uuid),
arrayOf(device, type, 1, true, true, psm, uuid),
arrayOf(type, 1, true, true, device, psm, uuid),
arrayOf(type, true, true, device, psm, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("createSocket<psm>", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("createSocket<psm>", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("createSocket<psm>", "Trying constructor signature #${index + 1}")
attemptedConstructors++
val paramTypes =
params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) {
Log.e("createSocket<psm>", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
}
}
val errorMessage =
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("createSocket<psm>", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
}
@@ -1,277 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.R
open class AirPodsBase(
val modelNumber: List<String>,
val name: String,
val displayName: String = "AirPods",
val manufacturer: String = "Apple Inc.",
val budCaseRes: Int,
val budsRes: Int,
val leftBudsRes: Int,
val rightBudsRes: Int,
val caseRes: Int,
val capabilities: Set<Capability>
)
enum class Capability {
LISTENING_MODE,
CONVERSATION_AWARENESS,
STEM_CONFIG,
HEAD_GESTURES,
LOUD_SOUND_REDUCTION,
PPE,
SLEEP_DETECTION,
HEARING_AID,
ADAPTIVE_AUDIO,
ADAPTIVE_VOLUME,
SWIPE_FOR_VOLUME,
HRM
}
class AirPods: AirPodsBase(
modelNumber = listOf("A1523", "A1722"),
name = "AirPods 1",
// budCaseRes = R.drawable.airpods_1
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_1_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_1_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_1_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_1_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = emptySet()
)
class AirPods2: AirPodsBase(
modelNumber = listOf("A2032", "A2031"),
name = "AirPods 2",
// budCaseRes = R.drawable.airpods_2
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_2_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_2_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = emptySet()
)
class AirPods3: AirPodsBase(
modelNumber = listOf("A2565", "A2564"),
name = "AirPods 3",
// budCaseRes = R.drawable.airpods_3
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_3_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_3_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_3_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_3_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.HEAD_GESTURES
)
)
class AirPods4: AirPodsBase(
modelNumber = listOf("A3053", "A3050", "A3054"),
name = "AirPods 4",
// budCaseRes = R.drawable.airpods_4
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_4_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_4_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_4_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_4_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.HEAD_GESTURES,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPods4ANC: AirPodsBase(
modelNumber = listOf("A3056", "A3055", "A3057"),
name = "AirPods 4 (ANC)",
// budCaseRes = R.drawable.airpods_4
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_4_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_4_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_4_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_4_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.ADAPTIVE_AUDIO,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME,
Capability.STEM_CONFIG
)
)
class AirPodsPro1: AirPodsBase(
modelNumber = listOf("A2084", "A2083"),
name = "AirPods Pro 1",
displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_1
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_1_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_1_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_1_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_1_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE
)
)
class AirPodsPro2Lightning: AirPodsBase(
modelNumber = listOf("A2931", "A2699", "A2698"),
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_2
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_2_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_2_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HEAD_GESTURES
)
)
class AirPodsPro2USBC: AirPodsBase(
modelNumber = listOf("A3047", "A3048", "A3049"),
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_2
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_2_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_2_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HEAD_GESTURES
)
)
class AirPodsPro3: AirPodsBase(
modelNumber = listOf("A3063", "A3064", "A3065"),
name = "AirPods Pro 3",
displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_3
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_3_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_3_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_3_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_3_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.PPE,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HRM
)
)
data class AirPodsInstance(
val name: String,
val model: AirPodsBase,
val actualModelNumber: String,
val serialNumber: String?,
val leftSerialNumber: String?,
val rightSerialNumber: String?,
val version1: String?,
val version2: String?,
val version3: String?,
)
object AirPodsModels {
val models: List<AirPodsBase> = listOf(
AirPods(),
AirPods2(),
AirPods3(),
AirPods4(),
AirPods4ANC(),
AirPodsPro1(),
AirPodsPro2Lightning(),
AirPodsPro2USBC(),
AirPodsPro3()
)
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
return models.find { modelNumber in it.modelNumber }
}
}
@@ -1,70 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
class ControlCommandRepository(
private val aacpManager: AACPManager
) {
fun getValue(
identifier: ControlCommandIdentifiers
): ByteArray? {
return aacpManager.controlCommandStatusList
.find { it.identifier == identifier }
?.value
}
fun setValue(
id: ControlCommandIdentifiers,
value: ByteArray
) {
aacpManager.sendControlCommand(id.value, value)
}
fun observe(
identifier: ControlCommandIdentifiers,
onChange: (ByteArray) -> Unit
): AACPManager.ControlCommandListener {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
onChange(controlCommand.value)
}
}
aacpManager.registerControlCommandListener(identifier, listener)
return listener
}
fun remove(
identifier: ControlCommandIdentifiers,
listener: AACPManager.ControlCommandListener
) {
aacpManager.unregisterControlCommandListener(identifier, listener)
}
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
return aacpManager.controlCommandStatusList.associate {
it.identifier to it.value
}
}
}
@@ -1,265 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
import android.os.Parcelable
import android.util.Log
import kotlinx.parcelize.Parcelize
// TODO: Remove everything but Battery-related stuff
enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(byteArrayOf(0x0d)),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 0x00)),
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
}
object BatteryComponent {
const val LEFT = 4
const val RIGHT = 2
const val CASE = 8
}
object BatteryStatus {
const val CHARGING = 1
const val NOT_CHARGING = 2
const val DISCONNECTED = 4
const val OPTIMIZED_CHARGING = 5
}
@Parcelize
data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable {
fun getComponentName(): String? {
return when (component) {
BatteryComponent.LEFT -> "LEFT"
BatteryComponent.RIGHT -> "RIGHT"
BatteryComponent.CASE -> "CASE"
else -> null
}
}
fun getStatusName(): String? {
return when (status) {
BatteryStatus.CHARGING -> "CHARGING"
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
else -> null
}
}
}
enum class NoiseControlMode {
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
}
class AirPodsNotifications {
companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
const val BATTERY_DATA = "me.kavishdevar.librepods.BATTERY_DATA"
const val CA_DATA = "me.kavishdevar.librepods.CA_DATA"
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
}
class EarDetection {
private val notificationBit = 6.toByte()
private val notificationPrefix = Enums.PREFIX.value + notificationBit
var status: List<Byte> = listOf(0x01, 0x01)
fun setStatus(data: ByteArray) {
status = listOf(data[6], data[7])
}
fun isEarDetectionData(data: ByteArray): Boolean {
if (data.size != 8) {
return false
}
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
}
class ANC {
private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value
var status: Int = 1
private set
fun isANCData(data: ByteArray): Boolean {
if (data.size != 11) {
return false
}
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
fun setStatus(data: ByteArray) {
when (data.size) {
// if the whole packet is given
11 -> {
status = data[7].toInt()
}
// if only the data is given
1 -> {
status = data[0].toInt()
}
// if the value of control command is given
4 -> {
status = data[0].toInt()
}
else -> {
Log.d("ANC", "Invalid ANC data size: ${data.size}")
}
}
}
val name: String =
when (status) {
1 -> "OFF"
2 -> "ON"
3 -> "TRANSPARENCY"
4 -> "ADAPTIVE"
else -> "UNKNOWN"
}
}
class BatteryNotification {
private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED)
private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED)
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
fun isBatteryData(data: ByteArray): Boolean {
if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) {
Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.")
} else {
return false
}
if (data.size != 22) {
Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.")
return false
}
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
}
fun setBatteryDirect(
leftLevel: Int,
leftCharging: Boolean,
rightLevel: Int,
rightCharging: Boolean,
caseLevel: Int,
caseCharging: Boolean
) {
first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
}
fun setBattery(data: ByteArray) {
if (data.size != 22) {
return
}
// first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
// Battery(first.component, first.level, data[10].toInt())
// } else {
// Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
// }
// second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
// Battery(second.component, second.level, data[15].toInt())
// } else {
// Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
// }
// case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
// Battery(case.component, case.level, data[20].toInt())
// } else {
// Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
// }
// sometimes it shows battery as -1%, just skip all that and set it normally
first = Battery(
data[7].toInt(), data[9].toInt(), data[10].toInt()
)
second = Battery(
data[12].toInt(), data[14].toInt(), data[15].toInt()
)
case = Battery(
data[17].toInt(), data[19].toInt(), data[20].toInt()
)
}
fun getBattery(): List<Battery> {
val left = if (first.component == BatteryComponent.LEFT) first else second
val right = if (first.component == BatteryComponent.LEFT) second else first
return listOf(left, right, case)
}
}
class ConversationalAwarenessNotification {
@Suppress("PrivatePropertyName")
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
var status: Byte = 0
private set
fun isConversationalAwarenessData(data: ByteArray): Boolean {
if (data.size != 10) {
return false
}
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
fun setData(data: ByteArray) {
status = data[9]
}
}
}
fun isHeadTrackingData(data: ByteArray): Boolean {
if (data.size <= 60) return false
val prefixPattern = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00,
0x10, 0x00
)
for (i in prefixPattern.indices) {
if (data[i] != prefixPattern[i]) return false
}
if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false
if (data[11] != 0x00.toByte()) return false
return true
}
@@ -1,528 +0,0 @@
package me.kavishdevar.librepods.presentation
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
object MaterialIcons {
val notifications: ImageVector
get() {
if (_notifications != null) {
return _notifications!!
}
_notifications =
ImageVector.Builder(
name = "notifications",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(4f, 19f)
verticalLineTo(17f)
horizontalLineTo(6f)
verticalLineTo(10f)
quadTo(6f, 7.93f, 7.25f, 6.31f)
reflectiveQuadTo(10.5f, 4.2f)
verticalLineTo(3.5f)
quadToRelative(0f, -0.63f, 0.44f, -1.06f)
reflectiveQuadTo(12f, 2f)
reflectiveQuadToRelative(1.06f, 0.44f)
reflectiveQuadTo(13.5f, 3.5f)
verticalLineTo(4.2f)
quadToRelative(2f, 0.5f, 3.25f, 2.11f)
reflectiveQuadTo(18f, 10f)
verticalLineToRelative(7f)
horizontalLineToRelative(2f)
verticalLineToRelative(2f)
horizontalLineTo(4f)
close()
moveToRelative(8f, -7.5f)
close()
moveTo(12f, 22f)
quadToRelative(-0.82f, 0f, -1.41f, -0.59f)
reflectiveQuadTo(10f, 20f)
horizontalLineToRelative(4f)
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
reflectiveQuadTo(12f, 22f)
close()
moveTo(8f, 17f)
horizontalLineToRelative(8f)
verticalLineTo(10f)
quadTo(16f, 8.35f, 14.83f, 7.18f)
reflectiveQuadTo(12f, 6f)
reflectiveQuadTo(9.18f, 7.18f)
reflectiveQuadTo(8f, 10f)
verticalLineToRelative(7f)
close()
}
}
.build()
return _notifications!!
}
private var _notifications: ImageVector? = null
val headset_off: ImageVector
get() {
if (_headset_off != null) {
return _headset_off!!
}
_headset_off =
ImageVector.Builder(
name = "headset_off",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(21f, 18.15f)
lineToRelative(-2f, -2f)
verticalLineTo(14f)
horizontalLineTo(16.85f)
lineToRelative(-2f, -2f)
horizontalLineTo(19f)
verticalLineTo(11f)
quadTo(19f, 8.05f, 16.95f, 6.02f)
reflectiveQuadTo(12f, 4f)
quadTo(10.9f, 4f, 9.91f, 4.31f)
reflectiveQuadTo(8.1f, 5.2f)
lineTo(6.65f, 3.8f)
quadTo(7.78f, 2.92f, 9.14f, 2.46f)
reflectiveQuadTo(12f, 2f)
quadToRelative(1.85f, 0f, 3.49f, 0.7f)
reflectiveQuadToRelative(2.86f, 1.93f)
reflectiveQuadToRelative(1.94f, 2.86f)
reflectiveQuadTo(21f, 11f)
verticalLineToRelative(7.15f)
close()
moveTo(12f, 23f)
verticalLineTo(21f)
horizontalLineToRelative(6.18f)
lineToRelative(-1f, -1f)
horizontalLineTo(15f)
verticalLineTo(17.83f)
lineTo(5.53f, 8.35f)
quadTo(5.3f, 8.95f, 5.15f, 9.64f)
reflectiveQuadTo(5f, 11f)
verticalLineToRelative(1f)
horizontalLineTo(9f)
verticalLineToRelative(8f)
horizontalLineTo(5f)
quadTo(4.18f, 20f, 3.59f, 19.41f)
reflectiveQuadTo(3f, 18f)
verticalLineTo(11f)
quadTo(3f, 9.88f, 3.26f, 8.82f)
reflectiveQuadToRelative(0.76f, -2f)
lineTo(0.68f, 3.5f)
lineTo(2.1f, 2.1f)
lineTo(21.88f, 21.9f)
verticalLineTo(23f)
horizontalLineTo(12f)
close()
moveTo(5f, 18f)
horizontalLineTo(7f)
verticalLineTo(14f)
horizontalLineTo(5f)
verticalLineToRelative(4f)
close()
moveTo(5f, 14f)
horizontalLineTo(7f)
horizontalLineTo(5f)
close()
moveToRelative(11.85f, 0f)
horizontalLineTo(19f)
horizontalLineTo(16.85f)
close()
}
}
.build()
return _headset_off!!
}
private var _headset_off: ImageVector? = null
val pause: ImageVector
get() {
if (_pause != null) {
return _pause!!
}
_pause =
ImageVector.Builder(
name = "pause",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(13f, 19f)
verticalLineTo(5f)
horizontalLineToRelative(6f)
verticalLineTo(19f)
horizontalLineTo(13f)
close()
moveTo(5f, 19f)
verticalLineTo(5f)
horizontalLineToRelative(6f)
verticalLineTo(19f)
horizontalLineTo(5f)
close()
moveTo(15f, 17f)
horizontalLineToRelative(2f)
verticalLineTo(7f)
horizontalLineTo(15f)
verticalLineTo(17f)
close()
moveTo(7f, 17f)
horizontalLineTo(9f)
verticalLineTo(7f)
horizontalLineTo(7f)
verticalLineTo(17f)
close()
moveTo(7f, 7f)
verticalLineTo(17f)
verticalLineTo(7f)
close()
moveToRelative(8f, 0f)
verticalLineTo(17f)
verticalLineTo(7f)
close()
}
}
.build()
return _pause!!
}
private var _pause: ImageVector? = null
val bluetooth: ImageVector
get() {
if (_bluetooth != null) {
return _bluetooth!!
}
_bluetooth =
ImageVector.Builder(
name = "bluetooth",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(11f, 22f)
verticalLineTo(14.4f)
lineTo(6.4f, 19f)
lineTo(5f, 17.6f)
lineTo(10.6f, 12f)
lineTo(5f, 6.4f)
lineTo(6.4f, 5f)
lineTo(11f, 9.6f)
verticalLineTo(2f)
horizontalLineToRelative(1f)
lineToRelative(5.7f, 5.7f)
lineTo(13.4f, 12f)
lineToRelative(4.3f, 4.3f)
lineTo(12f, 22f)
horizontalLineTo(11f)
close()
moveTo(13f, 9.6f)
lineTo(14.9f, 7.7f)
lineTo(13f, 5.85f)
verticalLineTo(9.6f)
close()
moveToRelative(0f, 8.55f)
lineTo(14.9f, 16.3f)
lineTo(13f, 14.4f)
verticalLineToRelative(3.75f)
close()
}
}
.build()
return _bluetooth!!
}
private var _bluetooth: ImageVector? = null
val bluetooth_searching: ImageVector
get() {
if (_bluetooth_searching != null) {
return _bluetooth_searching!!
}
_bluetooth_searching =
ImageVector.Builder(
name = "bluetooth_searching",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(9f, 22f)
verticalLineTo(14.4f)
lineTo(4.4f, 19f)
lineTo(3f, 17.6f)
lineTo(8.6f, 12f)
lineTo(3f, 6.4f)
lineTo(4.4f, 5f)
lineTo(9f, 9.6f)
verticalLineTo(2f)
horizontalLineToRelative(1f)
lineToRelative(5.7f, 5.7f)
lineTo(11.4f, 12f)
lineToRelative(4.3f, 4.3f)
lineTo(10f, 22f)
horizontalLineTo(9f)
close()
moveTo(11f, 9.6f)
lineTo(12.9f, 7.7f)
lineTo(11f, 5.85f)
verticalLineTo(9.6f)
close()
moveToRelative(0f, 8.55f)
lineTo(12.9f, 16.3f)
lineTo(11f, 14.4f)
verticalLineToRelative(3.75f)
close()
moveToRelative(5.55f, -3.8f)
lineTo(14.25f, 12f)
lineToRelative(2.3f, -2.3f)
quadToRelative(0.23f, 0.55f, 0.36f, 1.13f)
reflectiveQuadTo(17.05f, 12f)
reflectiveQuadToRelative(-0.14f, 1.19f)
quadToRelative(-0.14f, 0.59f, -0.36f, 1.16f)
close()
moveTo(19.5f, 17.2f)
lineTo(18.25f, 16f)
quadToRelative(0.5f, -0.93f, 0.78f, -1.94f)
reflectiveQuadTo(19.3f, 12f)
reflectiveQuadTo(19.03f, 9.94f)
quadTo(18.75f, 8.92f, 18.25f, 8f)
lineTo(19.5f, 6.75f)
quadToRelative(0.73f, 1.2f, 1.11f, 2.52f)
reflectiveQuadTo(21f, 12f)
reflectiveQuadToRelative(-0.39f, 2.71f)
quadTo(20.23f, 16.02f, 19.5f, 17.2f)
close()
}
}
.build()
return _bluetooth_searching!!
}
private var _bluetooth_searching: ImageVector? = null
val call: ImageVector
get() {
if (_call != null) {
return _call!!
}
_call =
ImageVector.Builder(
name = "call",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.Companion.NonZero,
) {
moveTo(19.95f, 21f)
quadToRelative(-3.13f, 0f, -6.18f, -1.36f)
reflectiveQuadTo(8.23f, 15.78f)
quadTo(5.73f, 13.27f, 4.36f, 10.23f)
reflectiveQuadTo(3f, 4.05f)
quadTo(3f, 3.6f, 3.3f, 3.3f)
reflectiveQuadTo(4.05f, 3f)
horizontalLineTo(8.1f)
quadTo(8.45f, 3f, 8.73f, 3.24f)
reflectiveQuadTo(9.05f, 3.8f)
lineTo(9.7f, 7.3f)
quadTo(9.75f, 7.7f, 9.68f, 7.97f)
reflectiveQuadTo(9.4f, 8.45f)
lineTo(6.98f, 10.9f)
quadToRelative(0.5f, 0.93f, 1.19f, 1.79f)
reflectiveQuadToRelative(1.51f, 1.66f)
quadToRelative(0.78f, 0.78f, 1.63f, 1.44f)
reflectiveQuadTo(13.1f, 17f)
lineToRelative(2.35f, -2.35f)
quadToRelative(0.22f, -0.23f, 0.59f, -0.34f)
reflectiveQuadToRelative(0.71f, -0.06f)
lineToRelative(3.45f, 0.7f)
quadToRelative(0.35f, 0.1f, 0.57f, 0.36f)
reflectiveQuadTo(21f, 15.9f)
verticalLineToRelative(4.05f)
quadToRelative(0f, 0.45f, -0.3f, 0.75f)
reflectiveQuadTo(19.95f, 21f)
close()
moveTo(6.03f, 9f)
lineTo(7.68f, 7.35f)
lineTo(7.25f, 5f)
horizontalLineTo(5.03f)
quadTo(5.15f, 6.02f, 5.38f, 7.02f)
reflectiveQuadTo(6.03f, 9f)
close()
moveToRelative(8.95f, 8.95f)
quadToRelative(0.97f, 0.43f, 1.99f, 0.68f)
reflectiveQuadTo(19f, 18.95f)
verticalLineToRelative(-2.2f)
lineTo(16.65f, 16.27f)
lineToRelative(-1.68f, 1.68f)
close()
moveTo(6.03f, 9f)
close()
moveToRelative(8.95f, 8.95f)
close()
}
}
.build()
return _call!!
}
private var _call: ImageVector? = null
val stack: ImageVector
get() {
if (_stack != null) {
return _stack!!
}
_stack =
ImageVector.Builder(
name = "stack",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.Companion.NonZero,
) {
moveTo(6f, 14f)
verticalLineToRelative(2f)
horizontalLineTo(4f)
quadTo(3.18f, 16f, 2.59f, 15.41f)
reflectiveQuadTo(2f, 14f)
verticalLineTo(4f)
quadTo(2f, 3.17f, 2.59f, 2.59f)
reflectiveQuadTo(4f, 2f)
horizontalLineTo(14f)
quadToRelative(0.83f, 0f, 1.41f, 0.59f)
reflectiveQuadTo(16f, 4f)
verticalLineTo(6f)
horizontalLineTo(14f)
verticalLineTo(4f)
horizontalLineTo(4f)
verticalLineTo(14f)
horizontalLineTo(6f)
close()
moveToRelative(4f, 8f)
quadTo(9.18f, 22f, 8.59f, 21.41f)
reflectiveQuadTo(8f, 20f)
verticalLineTo(10f)
quadTo(8f, 9.17f, 8.59f, 8.59f)
reflectiveQuadTo(10f, 8f)
horizontalLineTo(20f)
quadToRelative(0.83f, 0f, 1.41f, 0.59f)
reflectiveQuadTo(22f, 10f)
verticalLineTo(20f)
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
reflectiveQuadTo(20f, 22f)
horizontalLineTo(10f)
close()
moveToRelative(0f, -2f)
horizontalLineTo(20f)
verticalLineTo(10f)
horizontalLineTo(10f)
verticalLineTo(20f)
close()
moveToRelative(5f, -5f)
close()
}
}
.build()
return _stack!!
}
private var _stack: ImageVector? = null
}
@@ -1,79 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AboutCard(
modelName: String,
actualModel: String,
serialNumbers: List<String>,
version: String?,
navigateToVersion: () -> Unit
) {
val serialNumbers = when (LocalDesignSystem.current) {
DesignSystem.Apple -> listOf(
serialNumbers[0],
"􀀛 ${serialNumbers[1]}",
"􀀧 ${serialNumbers[2]}"
)
DesignSystem.Material -> listOf(
serialNumbers[0],
stringResource(R.string.left) + " " + serialNumbers[1],
stringResource(R.string.right) + " " + serialNumbers[2],
)
}
val serialNumber = remember { mutableIntStateOf(0) }
StyledList (title = stringResource(R.string.about)) {
StyledListItem(
name = stringResource(R.string.model_name),
description = modelName
)
StyledListItem(
name = stringResource(R.string.model_number),
description = actualModel
)
StyledListItem (
name = stringResource(R.string.serial_number),
description = serialNumbers[serialNumber.intValue],
onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size }
)
StyledListItem(
name = stringResource(R.string.version),
description = version,
onClick = navigateToVersion,
)
}
}
@@ -1,174 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(
batteryList: List<Battery>,
budsRes: Int,
caseRes: Int
) {
val left = batteryList.find { it.component == BatteryComponent.LEFT }
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
val case = batteryList.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val singleDisplayed = remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.widthIn(max = 500.dp),
horizontalArrangement = Arrangement.Center
) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (
left?.status == right?.status &&
(leftLevel - rightLevel) in -3..3
) {
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
left?.status ?: BatteryStatus.NOT_CHARGING
)
singleDisplayed.value = true
} else {
singleDisplayed.value = false
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
left?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE5"
)
}
if (leftLevel > 0 && rightLevel > 0) {
Spacer(modifier = Modifier.width(16.dp))
}
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
rightLevel,
right?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE8"
)
}
}
}
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(caseRes),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
caseLevel,
case?.status ?: BatteryStatus.NOT_CHARGING,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
)
}
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
val fakeBattery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
)
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier
.background(bg)
.padding(16.dp)
) {
BatteryView(
batteryList = fakeBattery,
budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_pro_2_case
)
}
}
@@ -1,49 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConnectionSettings(
automaticEarDetectionEnabled: Boolean,
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
automaticConnectionEnabled: Boolean,
onAutomaticConnectionChanged: (Boolean) -> Unit,
) {
StyledList {
StyledToggle(
label = stringResource(R.string.ear_detection),
checked = automaticEarDetectionEnabled,
onCheckedChange = onAutomaticEarDetectionChanged
)
StyledToggle(
label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description),
checked = automaticConnectionEnabled,
onCheckedChange = onAutomaticConnectionChanged
)
}
}
@@ -1,106 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("unused")
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.font.FontWeight
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
private val SelectedColorBlue = Color(0xFF0A84FF)
private val UnselectedColor = Color(0x593C3C3E)
private val TextColor = Color.White
private val IconTint = Color.White
@Composable
fun ControlCenterButton(
label: String,
icon: Painter,
onClick: () -> Unit,
modifier: Modifier = Modifier,
iconAreaSize: Dp,
isSelected: Boolean,
backgroundBrush: Brush? = null
) {
val targetBackgroundColor = if (isSelected) SelectedColorBlue else UnselectedColor
val backgroundColor by animateColorAsState(
targetValue = targetBackgroundColor,
label = "ButtonBackground"
)
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(iconAreaSize)
.clip(CircleShape)
.background(backgroundBrush ?: Brush.linearGradient(colors=listOf(backgroundColor, backgroundColor)))
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = icon,
contentDescription = null,
tint = IconTint,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = label,
color = TextColor,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 2
)
}
}
@@ -1,242 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
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 me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
private val SelectedIndicatorColorBlue = Color(0xFF0A84FF)
private val TextColor = Color.White
private val IconTintUnselected = Color.White
private val IconTintSelected = Color.White
internal val AdaptiveRainbowBrush = Brush.sweepGradient(
colors = listOf(
Color(0xFFB03A2F), Color(0xFFB07A2F), Color(0xFFB0A22F), Color(0xFF6AB02F),
Color(0xFF2FAAB0), Color(0xFF2F5EB0), Color(0xFF7D2FB0), Color(0xFFB02F7D),
Color(0xFFB03A2F)
)
)
internal val IconAreaSize = 72.dp
private val IconSize = 42.dp
private val IconRowHeight = IconAreaSize + 12.dp
private val TextRowHeight = 24.dp
private val TextSize = 12.sp
@Composable
fun ControlCenterNoiseControlSegmentedButton(
modifier: Modifier = Modifier,
availableModes: List<NoiseControlMode>,
selectedMode: NoiseControlMode,
onModeSelected: (NoiseControlMode) -> Unit
) {
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
val density = LocalDensity.current
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
val itemCount = availableModes.size
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
if (itemCount > 0 && iconRowWidthPx > 0) {
iconRowWidthPx / itemCount
} else {
0f
}
}
val itemSlotWidthDp = remember(itemSlotWidthPx) { with(density) { itemSlotWidthPx.toDp() } }
val iconAreaSizePx = remember { with(density) { IconAreaSize.toPx() } }
val targetIndicatorStartPx = remember(selectedIndex, itemSlotWidthPx, iconAreaSizePx) {
if (itemSlotWidthPx > 0) {
val slotCenterPx = (selectedIndex + 0.5f) * itemSlotWidthPx
slotCenterPx - (iconAreaSizePx / 2f)
} else {
0f
}
}
val indicatorOffset: Dp by animateDpAsState(
targetValue = with(density) { targetIndicatorStartPx.toDp() },
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium
),
label = "IndicatorOffset"
)
val indicatorBackground = remember(selectedMode) {
when (selectedMode) {
NoiseControlMode.ADAPTIVE -> AdaptiveRainbowBrush
NoiseControlMode.OFF -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorGray, SelectedIndicatorColorGray))
NoiseControlMode.TRANSPARENCY,
NoiseControlMode.NOISE_CANCELLATION -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorBlue, SelectedIndicatorColorBlue))
}
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(IconRowHeight)
.clip(CircleShape)
.background(ContainerColor)
.onSizeChanged { iconRowWidthPx = it.width.toFloat() },
contentAlignment = Alignment.Center
) {
Box(
Modifier
.align(Alignment.CenterStart)
.offset(x = indicatorOffset)
.size(IconAreaSize)
.clip(CircleShape)
.background(indicatorBackground)
)
Row(
modifier = Modifier.fillMaxWidth().align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
availableModes.forEach { mode ->
val isSelected = selectedMode == mode
NoiseControlIconItem(
modifier = Modifier.size(IconAreaSize),
mode = mode,
isSelected = isSelected,
onClick = { onModeSelected(mode) }
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(TextRowHeight),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
availableModes.forEach { mode ->
val isSelected = selectedMode == mode
Text(
text = getModeLabel(mode),
color = TextColor,
fontSize = TextSize,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
textAlign = TextAlign.Center,
modifier = Modifier.width(itemSlotWidthDp.coerceAtLeast(1.dp))
)
}
}
}
}
@Composable
private fun NoiseControlIconItem(
modifier: Modifier = Modifier,
mode: NoiseControlMode,
isSelected: Boolean,
onClick: () -> Unit
) {
val iconRes = remember(mode) { getModeIconRes(mode) }
val tint = IconTintUnselected
Box(
modifier = modifier
.clip(CircleShape)
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = getModeLabel(mode),
tint = if (isSelected && mode == NoiseControlMode.ADAPTIVE) IconTintSelected else tint,
modifier = Modifier.size(IconSize)
)
}
}
private fun getModeIconRes(mode: NoiseControlMode): Int {
return when (mode) {
NoiseControlMode.OFF -> R.drawable.noise_cancellation
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
}
}
private fun getModeLabel(mode: NoiseControlMode): String {
return when (mode) {
NoiseControlMode.OFF -> "Off"
NoiseControlMode.TRANSPARENCY -> "Transparency"
NoiseControlMode.ADAPTIVE -> "Adaptive"
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancellation"
}
}
@@ -1,56 +0,0 @@
package me.kavishdevar.librepods.presentation.components
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun DeviceInfoCard() {
StyledList(title = stringResource(R.string.device_info)) {
StyledListItem(
name = stringResource(R.string.manufacturer),
description = Build.MANUFACTURER,
enabled = false
)
StyledListItem(
name = stringResource(R.string.model_number),
description = Build.MODEL,
enabled = false
)
StyledListItem(
name = stringResource(R.string.build_id),
description = Build.DISPLAY,
enabled = false
)
StyledListItem(
name = stringResource(R.string.version),
description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})",
enabled = false
)
StyledListItem(
name = stringResource(R.string.xposed_available),
description = if (XposedState.isAvailable) {
stringResource(R.string.yes)
} else {
stringResource(R.string.no)
},
enabled = false
)
StyledListItem(
name = stringResource(R.string.app_enabled_in_xposed),
description = if (XposedState.bluetoothScopeEnabled) {
stringResource(R.string.yes)
} else {
stringResource(R.string.no)
},
enabled = false
)
}
}
@@ -1,409 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
@Composable
fun StyledListItem(
modifier: Modifier = Modifier,
title: String? = null,
name: String,
onClick: (() -> Unit)?,
description: String? = null,
height: Dp = 58.dp,
enabled: Boolean = true,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column {
title?.let {
Box(
modifier = Modifier
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp)
) {
Text(
text = it,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
}
Column(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(
if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface,
RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)
)
.clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp))
) {
StyledListItemContent(
name = name,
onClick = onClick,
description = description,
height = height,
enabled = enabled,
index = 0,
count = 1,
orientation = orientation,
leadingContent = leadingContent,
trailingContent = trailingContent
)
}
}
}
@Composable
fun StyledListScope.StyledListItem(
modifier: Modifier = Modifier,
name: String,
onClick: (() -> Unit)? = null,
description: String? = null,
enabled: Boolean = onClick != null,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
selected: Boolean? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
item { index, count ->
StyledListItemContent(
name = name,
onClick = onClick,
description = description,
enabled = enabled,
index = index,
count = count,
orientation = orientation,
modifier = modifier,
selected = selected,
leadingContent = leadingContent,
trailingContent = trailingContent
)
}
}
enum class ListItemOrientation{
Horizontal,
Vertical
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun StyledListItemContent(
modifier: Modifier = Modifier,
name: String,
onClick: (() -> Unit)?,
description: String? = null,
height: Dp = 58.dp,
enabled: Boolean = true,
index: Int,
count: Int,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
selected: Boolean? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val isDarkTheme = isSystemInDarkTheme()
val surfaceColor = MaterialTheme.colorScheme.surface
val surfaceDimColor = MaterialTheme.colorScheme.surfaceDim
var backgroundColor by remember { mutableStateOf(surfaceColor) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
when (LocalDesignSystem.current) {
DesignSystem.Apple -> {
val trailingContentDefault: @Composable () -> Unit = {
if (trailingContent == null) {
if (onClick != null) {
if (selected != null) {
val floatAnimateState by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)
Text(
text = "􀆅",
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState),
),
modifier = Modifier.padding(end = 4.dp)
)
} else {
Text(
text = "􀯻",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(0.6f),
modifier = Modifier
.padding(start = if (description != null) 6.dp else 0.dp)
)
}
}
} else {
trailingContent()
}
}
Column (
modifier = Modifier
.background(
animatedBackgroundColor,
when {
(index == 0 && count == 1) -> {
RoundedCornerShape(28.dp)
}
(index == 0) -> {
RoundedCornerShape(
topStart = 28.dp,
topEnd = 28.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
}
(index + 1 == count) -> {
RoundedCornerShape(
topStart = 0.dp,
topEnd = 0.dp,
bottomStart = 28.dp,
bottomEnd = 28.dp
)
}
else -> {
RectangleShape
}
}
)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (enabled) {
backgroundColor = surfaceDimColor
tryAwaitRelease()
backgroundColor = surfaceColor
}
},
onTap = {
if (enabled) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.ContextClick
)
}
onClick?.invoke()
}
}
)
}
.heightIn(min = height)
.padding(horizontal = 16.dp)
) {
Row(
modifier = Modifier
.heightIn(min = height)
.padding(vertical = if (orientation == ListItemOrientation.Vertical) 12.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leadingContent != null) {
leadingContent()
Spacer(modifier = Modifier.width(12.dp))
}
Column (verticalArrangement = Arrangement.Center) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (description != null && orientation == ListItemOrientation.Vertical) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f), // TODO: move to color scheme
)
}
}
Spacer(modifier = Modifier.weight(1f))
if (orientation == ListItemOrientation.Horizontal && description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f) // TODO: move to color scheme
)
}
trailingContentDefault()
}
if (index+1 != count) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = if (leadingContent != null) 12.dp else 0.dp)
)
}
}
}
DesignSystem.Material -> {
val defaultShape = when {
count == 1 -> RoundedCornerShape(24.dp)
index == 0 -> RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
)
index == count - 1 -> RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
else -> RoundedCornerShape(8.dp)
}
Column {
SegmentedListItem(
modifier = modifier.heightIn(min = 64.dp),
shapes = ListItemDefaults.shapes().copy(
shape = defaultShape,
pressedShape = RoundedCornerShape(24.dp),
selectedShape = RoundedCornerShape(24.dp),
hoveredShape = RoundedCornerShape(24.dp),
),
onClick = onClick ?: {},
leadingContent = leadingContent,
trailingContent = {
if (trailingContent == null) {
if (onClick != null) {
if (selected == true) {
Icon(Icons.Default.Check, contentDescription = null)
} else if (selected == null) {
Icon(
Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = null
)
}
}
} else {
trailingContent()
}
},
supportingContent = {
if (description != null) Text(
description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 4.dp)
)
},
content = {
Text(
text = name,
style = MaterialTheme.typography.labelMediumEmphasized,
modifier = Modifier.padding(
top = 4.dp,
bottom = if (description != null) 0.dp else 4.dp
)
)
},
verticalAlignment = Alignment.CenterVertically,
colors = if (onClick == null) {
ListItemDefaults.segmentedColors().run {
copy(
disabledContentColor = contentColor,
disabledSupportingContentColor = supportingContentColor,
disabledTrailingContentColor = trailingContentColor
)
}
} else ListItemDefaults.segmentedColors(),
enabled = onClick != null && enabled,
selected = selected ?: false
)
if (index+1 != count) {
Spacer(modifier = Modifier.height(2.dp))
}
}
}
}
}
@@ -1,190 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sign
@Composable
fun VerticalVolumeSlider(
displayFraction: Float,
maxVolume: Int,
onVolumeChange: (Int) -> Unit,
initialFraction: Float,
onDragStateChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
baseSliderHeight: Dp = 400.dp,
baseSliderWidth: Dp = 145.dp,
baseCornerRadius: Dp = 45.dp,
maxStretchFactor: Float = 1.15f,
minCompressionFactor: Float = 0.875f,
stretchSensitivity: Float = 1.0f,
compressionSensitivity: Float = 1.0f,
cornerRadiusChangeFactor: Float = 0.2f,
directionalStretchRatio: Float = 0.75f
) {
val trackColor = Color(0x593C3C3E)
val progressColor = Color.White
var dragFraction by remember { mutableFloatStateOf(initialFraction) }
var isDragging by remember { mutableStateOf(false) }
var rawDragPosition by remember { mutableFloatStateOf(initialFraction) }
var overscrollAmount by remember { mutableFloatStateOf(0f) }
val baseHeightPx = with(LocalDensity.current) { baseSliderHeight.toPx() }
val animatedProgress by animateFloatAsState(
targetValue = dragFraction.coerceIn(0f, 1f),
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium
),
label = "ProgressAnimation"
)
val animatedOverscroll by animateFloatAsState(
targetValue = overscrollAmount,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "OverscrollAnimation"
)
val maxOverscrollEffect = (maxStretchFactor - 1f).coerceAtLeast(0f)
val stretchMultiplier = stretchSensitivity
val compressionMultiplier = compressionSensitivity
val overscrollDirection = sign(animatedOverscroll)
val totalStretchAmount = (min(maxOverscrollEffect, abs(animatedOverscroll) * stretchMultiplier) * baseSliderHeight.value).dp
val offsetY = if (abs(animatedOverscroll) > 0.001f) {
val asymmetricOffset = totalStretchAmount * (directionalStretchRatio - 0.5f)
(-overscrollDirection * asymmetricOffset.value).dp
} else {
0.dp
}
val heightStretch = baseSliderHeight + totalStretchAmount
val widthCompression = baseSliderWidth * max(
minCompressionFactor,
1f - min(1f - minCompressionFactor, abs(animatedOverscroll) * compressionMultiplier)
)
val dynamicCornerRadius = baseCornerRadius * (1f - min(cornerRadiusChangeFactor, abs(animatedOverscroll) * cornerRadiusChangeFactor * 2f))
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.height(heightStretch)
.width(widthCompression)
.offset(y = offsetY)
.clip(RoundedCornerShape(dynamicCornerRadius))
.background(trackColor)
.pointerInput(Unit) {
detectTapGestures { offset ->
val newFraction = 1f - (offset.y / size.height).coerceIn(0f, 1f)
dragFraction = newFraction
rawDragPosition = newFraction
overscrollAmount = 0f
val newVolume = (newFraction * maxVolume).roundToInt()
onVolumeChange(newVolume)
}
}
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
rawDragPosition -= (delta / baseHeightPx)
dragFraction = rawDragPosition.coerceIn(0f, 1f)
overscrollAmount = when {
rawDragPosition > 1f -> min(1.0f, (rawDragPosition - 1f) * 2.0f)
rawDragPosition < 0f -> max(-1.0f, rawDragPosition * 2.0f)
else -> 0f
}
val newVolume = (dragFraction * maxVolume).roundToInt()
onVolumeChange(newVolume)
},
onDragStarted = {
isDragging = true
dragFraction = displayFraction
rawDragPosition = displayFraction
overscrollAmount = 0f
onDragStateChange(true)
},
onDragStopped = {
isDragging = false
overscrollAmount = 0f
rawDragPosition = dragFraction
onDragStateChange(false)
}
),
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(animatedProgress)
.background(progressColor)
)
}
}
}
@@ -1,324 +0,0 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.activity.BackEventCompat.Companion.EDGE_LEFT
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.updates.updates
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsRoute
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.CallControlScreen
import me.kavishdevar.librepods.presentation.screens.EqualizerRoute
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
import me.kavishdevar.librepods.presentation.screens.LoadingScreen
import me.kavishdevar.librepods.presentation.screens.LongPress
import me.kavishdevar.librepods.presentation.screens.MicrophoneSettingsRoute
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen
import me.kavishdevar.librepods.presentation.screens.RenameScreen
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestRoute
import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun AppNavGraph(
showReleaseNotes: Boolean = false,
updatesShown: () -> Unit = {},
showOnboarding: Boolean = false,
onboardingComplete: () -> Unit = {},
backStack: SnapshotStateList<Screen>,
airPodsViewModel: AirPodsViewModel,
) {
val navigate: (Screen) -> Unit = { screen ->
backStack.add(screen)
}
fun navigateToPurchase() {
navigate(Screen.Purchase)
}
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
SharedTransitionLayout {
NavDisplay(
sharedTransitionScope = this,
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
},
entryProvider = { screen ->
when (screen) {
Screen.Onboarding ->
NavEntry(screen) {
OnboardingScreen {
onboardingComplete()
if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.AirPodsSettings)
backStack.remove(screen)
}
}
Screen.AirPodsSettings ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
AirPodsSettingsRoute(
viewModel = airPodsViewModel,
navigateToRename = { navigate(Screen.Rename) },
navigateToHearingProtection = { navigate(Screen.HearingProtection) },
navigateToHearingAid = { navigate(Screen.HearingAid) },
navigateToLeftLongPress = {
navigate(
Screen.LongPress("Left")
)
},
navigateToRightLongPress = {
navigate(
Screen.LongPress("Right")
)
},
navigateToPurchase = { navigate(Screen.Purchase) },
navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength) },
navigateToEqualizer = { navigate(Screen.Equalizer) },
navigateToHeadTracking = { navigate(Screen.HeadTracking) },
navigateToAccessibility = { navigate(Screen.Accessibility) },
navigateToVersion = { navigate(Screen.VersionInfo) },
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToCallControlScreen = { navigate(Screen.CallControl(it)) },
navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) },
)
}
Screen.Rename ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
RenameScreen(airPodsViewModel)
}
Screen.AppSettings ->
NavEntry(screen) {
val vm: AppSettingsViewModel = viewModel()
AppSettingsScreen(
viewModel = vm,
navigateToPurchase = ::navigateToPurchase,
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) }
)
}
Screen.Troubleshooting ->
NavEntry(screen) {
TroubleshootingScreen()
}
Screen.HeadTracking ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HeadTrackingScreen(airPodsViewModel, ::navigateToPurchase)
}
Screen.Accessibility ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
AccessibilitySettingsScreen(
viewModel = airPodsViewModel,
navigateToPurchase = ::navigateToPurchase,
navigateToTransparencyCustomization = { navigate(Screen.TransparencyCustomization) }
)
}
Screen.TransparencyCustomization ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
TransparencySettingsScreen(airPodsViewModel)
}
Screen.HearingAid ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HearingAidScreen(
viewModel = airPodsViewModel,
onNavigateHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments) },
onNavigateHearingTest = { navigate(Screen.UpdateHearingTest) },
)
}
Screen.HearingAidAdjustments ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HearingAidAdjustmentsScreen(airPodsViewModel)
}
Screen.AdaptiveStrength ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
AdaptiveStrengthScreen(airPodsViewModel, ::navigateToPurchase)
}
// Screen.CameraControl ->
// NavEntry(screen) {
// CameraControlScreen(airPodsViewModel)
// }
Screen.OpenSourceLicenses ->
NavEntry(screen) {
OpenSourceLicensesScreen()
}
Screen.UpdateHearingTest ->
NavEntry(screen) {
UpdateHearingTestRoute(airPodsViewModel)
}
Screen.VersionInfo ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
VersionScreen(airPodsViewModel)
}
Screen.HearingProtection ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HearingProtectionScreen(
viewModel = airPodsViewModel,
navigateToPurchase = ::navigateToPurchase
)
}
Screen.Purchase ->
NavEntry(screen) {
val vm: PurchaseViewModel = viewModel()
PurchaseScreen(vm, backStack)
}
Screen.Equalizer ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
EqualizerRoute(airPodsViewModel)
}
is Screen.LongPress ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
LongPress(
viewModel = airPodsViewModel,
name = screen.bud,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.CallControl ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
CallControlScreen(
viewModel = airPodsViewModel,
action = screen.action,
onCallControlValueChanged = { flipped ->
airPodsViewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf(
0x00,
0x03
)
)
}
)
}
is Screen.MicrophoneSettings ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
MicrophoneSettingsRoute(viewModel = airPodsViewModel)
}
is Screen.ReleaseNotes ->
NavEntry(screen) {
ReleaseNotesScreen(
updates = updates,
releaseNotesShown = {
if (showReleaseNotes) {
navigate(Screen.AirPodsSettings)
backStack.remove(screen)
updatesShown()
} else {
backStack.removeAt(backStack.lastIndex)
}
}
)
}
}
},
transitionSpec = {
slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 }
},
popTransitionSpec = {
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
},
predictivePopTransitionSpec = { swipeEdge ->
if (m3eEnabled) {
val enterOffset: (Int) -> Int =
if (swipeEdge == EDGE_LEFT) {
{ -it / 6 }
} else {
{ it / 6 }
}
val exitOffset: (Int) -> Int =
if (swipeEdge == EDGE_LEFT) {
{ it / 8 }
} else {
{ -it / 8 }
}
fadeIn(
animationSpec = tween(250)
) +
slideInHorizontally(
initialOffsetX = enterOffset,
animationSpec = tween(250)
) togetherWith
fadeOut(
targetAlpha = 0.75f,
animationSpec = tween(250)
) +
scaleOut(
targetScale = 0.85f,
animationSpec = tween(250)
) +
slideOutHorizontally(
targetOffsetX = exitOffset,
animationSpec = tween(250)
)
} else {
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
}
},
)
}
}
@@ -1,158 +0,0 @@
package me.kavishdevar.librepods.presentation.navigation
import android.util.Log
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.kyant.backdrop.backdrops.LayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.MaterialIcons
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun NavigationRoot(
showReleaseNotes: Boolean = false,
updatesShown: () -> Unit = {},
showOnboarding: Boolean = false,
onboardingComplete: () -> Unit = {},
airPodsViewModel: AirPodsViewModel
) {
val backStack = remember {
mutableStateListOf(
when {
showOnboarding -> Screen.Onboarding
showReleaseNotes -> Screen.ReleaseNotes
else -> Screen.AirPodsSettings
}
)
}
val currentScreen = backStack.last()
val state by airPodsViewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val title = when (currentScreen) {
Screen.Onboarding -> ""
Screen.AirPodsSettings -> if (state.isLocallyConnected) state.deviceName else stringResource(R.string.app_name)
Screen.Accessibility -> stringResource(R.string.accessibility)
Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio)
Screen.AppSettings -> stringResource(R.string.settings)
// Screen.CameraControl -> stringResource(R.string.camera_control)
Screen.Equalizer -> stringResource(R.string.equalizer)
Screen.HeadTracking -> stringResource(R.string.head_tracking)
Screen.HearingAid -> stringResource(R.string.hearing_aid)
Screen.HearingAidAdjustments -> stringResource(R.string.adjustments)
Screen.HearingProtection -> stringResource(R.string.hearing_protection)
is Screen.LongPress -> currentScreen.bud
Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses)
Screen.Purchase -> stringResource(R.string.unlock_advanced_features)
Screen.Rename -> stringResource(R.string.name)
Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode)
Screen.Troubleshooting -> stringResource(R.string.troubleshooting)
Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test)
Screen.VersionInfo -> stringResource(R.string.version)
is Screen.CallControl -> currentScreen.action
Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode)
Screen.ReleaseNotes -> ""
}
// is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold
val actionButtons = when (currentScreen) {
Screen.AirPodsSettings -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
if (m3eEnabled) {
FilledTonalIconButton(
onClick = { backStack.add(Screen.AppSettings) },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "settings",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
)
}
} else {
StyledIconButton(
onClick = { backStack.add(Screen.AppSettings) },
icon = "􀍟",
backdrop = scaffoldBackdrop
)
}
}
)
Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
if (m3eEnabled) {
FilledTonalIconToggleButton(
checked = state.headTrackingActive,
onCheckedChange = { if (it) airPodsViewModel.startHeadTracking() else airPodsViewModel.stopHeadTracking() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
imageVector = if (state.headTrackingActive) MaterialIcons.pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
)
}
} else {
StyledIconButton(
onClick = {
if (!state.headTrackingActive) {
airPodsViewModel.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started")
} else {
airPodsViewModel.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped")
}
},
icon = if (state.headTrackingActive) "􀊅" else "􀊃",
backdrop = scaffoldBackdrop
)
}
}
)
else -> listOf()
}
StyledScaffold(
visible = currentScreen.showTopBar,
title = title,
showBackButton = backStack.size > 1,
onNavigateBack = { backStack.removeAt(backStack.lastIndex) },
actionButtons = actionButtons
) {
AppNavGraph(
showReleaseNotes = showReleaseNotes,
updatesShown = updatesShown,
showOnboarding = showOnboarding,
onboardingComplete = onboardingComplete,
backStack = backStack,
airPodsViewModel = airPodsViewModel,
)
}
}
@@ -1,84 +0,0 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed interface Screen: NavKey {
val showTopBar: Boolean
get() = true
@Serializable
data object Onboarding: Screen {
override val showTopBar: Boolean = false
}
@Serializable
data object AirPodsSettings: Screen
@Serializable
data object Rename: Screen
@Serializable
data object AppSettings: Screen
@Serializable
data object Troubleshooting: Screen
@Serializable
data object HeadTracking: Screen
@Serializable
data object Accessibility: Screen
@Serializable
data object TransparencyCustomization: Screen
@Serializable
data object HearingAid: Screen
@Serializable
data object HearingAidAdjustments: Screen
@Serializable
data object AdaptiveStrength: Screen
// @Serializable
// data object CameraControl: Screen
@Serializable
data object OpenSourceLicenses: Screen
@Serializable
data object UpdateHearingTest: Screen
@Serializable
data object VersionInfo: Screen
@Serializable
data object HearingProtection: Screen
@Serializable
data object Purchase: Screen
@Serializable
data object Equalizer: Screen
@Serializable
data class LongPress(
val bud: String
): Screen
@Serializable
data class CallControl(
val action: String
): Screen
@Serializable
data object MicrophoneSettings: Screen
@Serializable
data object ReleaseNotes: Screen {
override val showTopBar: Boolean = false
}
}
@@ -1,122 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
val sliderValue = remember {
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
}
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
StyledSlider(
label = stringResource(R.string.customize_adaptive_audio),
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
job?.cancel()
job = scope.launch {
delay(150)
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
byteArrayOf((100 - it).toInt().toByte())
)
}
},
valueRange = 0f..100f,
snapPoints = listOf(0f, 50f, 100f),
startIcon = "􀊥",
endIcon = "􀊩",
independent = true,
description = stringResource(R.string.adaptive_audio_description),
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,613 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.MaterialTypography
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSettingsScreen(
viewModel: AppSettingsViewModel = viewModel(),
navigateToPurchase: () -> Unit,
navigateToTroubleshooting: () -> Unit,
navigateToOpenSourceLicenses: () -> Unit,
navigateToReleaseNotesScreen: () -> Unit
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val contactBottomSheet = remember { mutableStateOf(false) }
val subjectState = remember { TextFieldState() }
val descriptionState = remember { TextFieldState() }
val subjectFocusRequester = remember { FocusRequester() }
val descriptionFocusRequester = remember { FocusRequester() }
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.layerBackdrop(backdrop)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme()
if (!state.isPremium && state.connectionSuccessful) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
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))
)
)
}
}
StyledToggle(
title = stringResource(R.string.appearance),
label = stringResource(R.string.use_material3e),
checked = state.m3eEnabled,
onCheckedChange = viewModel::setm3eEnabled,
enabled = state.isPremium
)
if (state.connectionSuccessful) {
StyledToggle(
title = stringResource(R.string.widget),
label = stringResource(R.string.show_phone_battery_in_widget),
description = stringResource(R.string.show_phone_battery_in_widget_description),
checked = state.showPhoneBatteryInWidget,
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
enabled = state.isPremium
)
StyledList(title = stringResource(R.string.popup_animations)) {
StyledToggle(
label = stringResource(R.string.show_bottom_sheet_popup),
description = stringResource(R.string.show_bottom_sheet_popup_description),
checked = state.showBottomSheetPopup,
onCheckedChange = viewModel::setShowBottomSheetPopup,
)
StyledToggle(
label = stringResource(R.string.show_island_popup),
description = stringResource(R.string.show_island_popup_description),
checked = state.showIslandPopup,
onCheckedChange = viewModel::setShowIslandPopup,
)
}
Spacer(modifier = Modifier.height(16.dp))
StyledList (title = stringResource(R.string.conversational_awareness)) {
StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description),
checked = state.conversationalAwarenessPauseMusicEnabled,
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description),
checked = state.relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
enabled = state.isPremium,
)
}
Spacer(modifier = Modifier.height(16.dp))
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
LaunchedEffect(conversationalAwarenessVolume) {
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
}
StyledSlider(
label = stringResource(R.string.conversational_awareness_volume),
value = conversationalAwarenessVolume,
valueRange = 10f..85f,
snapPoints = listOf(44f),
startLabel = "10%",
endLabel = "85%",
onValueChange = { newValue ->
viewModel.setConversationalAwarenessVolume(
newValue
)
},
independent = true,
enabled = state.isPremium
)
// if (!BuildConfig.PLAY_BUILD) {
// Spacer(modifier = Modifier.height(16.dp))
//
// StyledListItem(
// to = "",
// titleRes = stringResource(R.string.camera_control),
// name = stringResource(R.string.set_custom_camera_package),
// navController = navController,
// onClick = {
// if (state.isPremium) viewModel.setShowCameraDialog(true)
// },
// independent = true,
// descriptionRes = stringResource(R.string.camera_control_app_description)
// )
// }
Spacer(modifier = Modifier.height(16.dp))
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
StyledToggle(
title = stringResource(R.string.ear_detection),
label = stringResource(R.string.disconnect_when_not_wearing),
description = stringResource(R.string.disconnect_when_not_wearing_description),
checked = state.disconnectWhenNotWearing,
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
enabled = state.isPremium
)
}
StyledList(title = stringResource(R.string.takeover_airpods_state)) {
StyledToggle(
label = stringResource(R.string.takeover_disconnected),
description = stringResource(R.string.takeover_disconnected_desc),
checked = state.takeoverWhenDisconnected,
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc),
checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc),
checked = state.takeoverWhenMusic,
onCheckedChange = viewModel::setTakeoverWhenMusic,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc),
checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall,
enabled = state.isPremium
)
}
Spacer(modifier = Modifier.height(16.dp))
StyledList(title = stringResource(R.string.takeover_phone_state)) {
StyledToggle(
label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc),
checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc),
checked = state.takeoverWhenMediaStart,
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
enabled = state.isPremium
)
}
StyledToggle(
title = stringResource(R.string.advanced_options), // shouldn't be here, but okay
label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(16.dp))
} else {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 2.dp)
) {
Text(
text = stringResource(R.string.customizations_unavailable),
style = MaterialTypography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
)
}
}
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
StyledToggle(
label = stringResource(R.string.act_as_an_apple_device) + " (${
stringResource(
R.string.requires_xposed
)
})",
description = stringResource(R.string.act_as_an_apple_device_description),
checked = state.vendorIdHook,
onCheckedChange = { enabled ->
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
viewModel.setVendorIdHook(enabled)
}
)
}
if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp))
StyledList {
StyledListItem(
name = stringResource(R.string.troubleshooting),
onClick = navigateToTroubleshooting,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
StyledList(title = stringResource(R.string.contact)) {
StyledListItem(
name = stringResource(R.string.email),
onClick = { contactBottomSheet.value = true },
)
StyledListItem(
name = stringResource(R.string.discord),
onClick = {
val intent =
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
context.startActivity(intent)
},
)
StyledListItem(
name = stringResource(R.string.github_issues),
onClick = {
val appVersion =
Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
val appSource = Uri.encode(
when {
BuildConfig.PLAY_BUILD -> "Play"
else -> "GitHub"
}
)
val url = "https://github.com/kavishdevar/librepods/issues/new" +
"?template=01-bug-report-android.yml" +
"&app-source=$appSource" +
"&app-version=$appVersion" +
"&device=$device" +
"&android-version=$androidVersion"
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(intent)
},
)
}
Spacer(modifier = Modifier.height(20.dp))
DeviceInfoCard()
Spacer(modifier = Modifier.height(16.dp))
AppInfoCard(navigateToReleaseNotesScreen)
Spacer(modifier = Modifier.height(16.dp))
StyledListItem(
name = stringResource(R.string.open_source_licenses),
onClick = navigateToOpenSourceLicenses,
)
Spacer(modifier = Modifier.height(bottomPadding))
if (state.showCameraDialog) {
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
Text(
stringResource(R.string.set_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}, text = {
Column {
Text(
stringResource(R.string.enter_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = state.cameraPackageValue,
onValueChange = {
viewModel.setCameraPackageValue(it)
viewModel.setCameraPackageError(null)
},
modifier = Modifier.fillMaxWidth(),
isError = state.cameraPackageError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
capitalization = KeyboardCapitalization.None
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
0xFF3C6DF5
),
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
),
supportingText = {
if (state.cameraPackageError != null) {
Text(
state.cameraPackageError ?: "",
color = MaterialTheme.colorScheme.error
)
}
},
label = { Text(stringResource(R.string.custom_camera_package)) })
}
}, confirmButton = {
val successText = stringResource(R.string.custom_camera_package_set_success)
TextButton(
onClick = {
viewModel.saveCameraPackage()
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
}) {
Text(
"Save",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
}, dismissButton = {
TextButton(
onClick = { viewModel.setShowCameraDialog(false) }) {
Text(
"Cancel",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
})
}
}
StyledBottomSheet(
visible = contactBottomSheet.value,
onDismiss = { contactBottomSheet.value = false },
backdrop = backdrop
) { innerBackdrop, progress ->
val animatedPadding = lerp(16.dp, 2.dp, progress)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = animatedPadding)
.padding(bottom = 16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
StyledIconButton(
icon = "\uDBC0\uDD84",
backdrop = innerBackdrop,
onClick = { contactBottomSheet.value = false }
)
Text (
text = stringResource(R.string.describe_your_issue),
style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
StyledIconButton(
icon = "\uDBC0\uDE1F",
backdrop = innerBackdrop,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray,
enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(),
onClick = {
contactBottomSheet.value = false
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}")
putExtra(
Intent.EXTRA_TEXT,
"${descriptionState.text}" +
"\n\n----------" +
"\nPhone details:" +
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nDISPLAY_VERSION: ${Build.DISPLAY}" +
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
"\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
"\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
)
}
context.startActivity(intent)
subjectState.clearText()
descriptionState.clearText()
}
)
}
Spacer(modifier = Modifier.height(8.dp))
StyledInputField(
inputState = subjectState,
focusRequester = subjectFocusRequester,
placeholder = stringResource(R.string.subject),
forceApple = true
)
Spacer(modifier = Modifier.height(12.dp))
StyledInputField(
inputState = descriptionState,
focusRequester = descriptionFocusRequester,
placeholder = stringResource(R.string.describe_your_issue),
singleLine = false,
forceApple = true
)
}
}
}
@@ -1,97 +0,0 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val scrollState = rememberScrollState()
val bytes =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(
2
)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
val flipped = try {
bytes[1] == 0x02.toByte()
} catch (e: Exception) {
false
}
val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice)
val muteUnmuteText = stringResource(R.string.mute_unmute)
var singlePressAction by remember { mutableStateOf(if ((action == muteUnmuteText) == !flipped) pressOnceText else pressTwiceText) }
val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } }
val flippedValue = action != muteUnmuteText
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = pressOnceText,
selected = pressOnceIsAction,
onClick = {
singlePressAction = pressOnceText
onCallControlValueChanged(flippedValue)
}
)
StyledListItem(
name = pressTwiceText,
selected = !pressOnceIsAction,
onClick = {
singlePressAction = pressTwiceText
onCallControlValueChanged(!flippedValue)
}
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,762 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.visible
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.demoState
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun EqualizerRoute(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = state,
topPadding = topPadding,
bottomPadding = bottomPadding,
setCustomEqEnabled = viewModel::setCustomEqEnabled,
setCustomEq = viewModel::setCustomEq
)
}
}
@OptIn(FlowPreview::class)
@Composable
fun EqualizerScreen(
state: AirPodsUiState,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
setCustomEqEnabled: (Boolean) -> Unit,
setCustomEq: (Int, Int, Int) -> Unit
) {
val customEq = state.customEq
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val height = 200.dp
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
val offsets = remember(state.customEq) {
listOf(
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
)
}
LaunchedEffect(offsets) {
snapshotFlow {
Triple(
offsets[0].floatValue,
offsets[1].floatValue,
offsets[2].floatValue
)
}
.debounce(100.milliseconds) // cool, should've been using this since the very beginning
.collect { (lowF, midF, highF) ->
val low =
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
val mid =
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
val high =
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
setCustomEq(low, mid, high)
}
}
Spacer(modifier = Modifier.height(topPadding))
val enabled = customEq.isEnabled()
StyledList {
StyledListItem(
name = stringResource(R.string.recommended),
selected = !enabled,
onClick = { setCustomEqEnabled(false) }
)
StyledListItem(
name = stringResource(R.string.custom),
selected = enabled,
onClick = { setCustomEqEnabled(true) }
)
}
Spacer(modifier = Modifier.height(12.dp))
Crossfade (
customEq.isEnabled()
) { visible ->
Column(
modifier = Modifier
.fillMaxWidth()
.visible(visible),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
EqualizerCard(
lowOffset = offsets[0],
midOffset = offsets[1],
highOffset = offsets[2]
)
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
StyledButton(
onClick = {
offsets[0].floatValue = 0f
offsets[1].floatValue = 0f
offsets[2].floatValue = 0f
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
isInteractive = false,
enabled = resetButtonEnabled.value
) {
Text(
text = stringResource(R.string.reset),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@Composable
fun EqualizerCard(
lowOffset: MutableState<Float>,
midOffset: MutableState<Float>,
highOffset: MutableState<Float>
) {
val height = 200.dp
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
) {
val dashColor = if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D)
val backdrop = rememberLayerBackdrop()
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
) {
Spacer(modifier = Modifier.height(42.dp))
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .padding(18.dp),
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(12.dp)
// ) {
// Box(
// modifier = Modifier
// .size(64.dp)
// .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp))
// )
// Column(
// modifier = Modifier
// .weight(1f),
// verticalArrangement = Arrangement.Center
// ) {
// Text(
// text = "Written into Changes",
// style = TextStyle(
// fontSize = 16.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Bold,
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
// )
// )
// Spacer(modifier = Modifier.height(4.dp))
// Text(
// text = "Avalon Emerson",
// style = TextStyle(
// fontSize = 14.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Normal,
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
// )
// )
// }
// val paused = remember { mutableStateOf(false) }
// Box(
// modifier = Modifier
// .size(48.dp)
// .background(Color(0x600091FF), CircleShape)
// .clickable(
// interactionSource = remember { MutableInteractionSource() },
// indication = null,
// ) {
// paused.value = !paused.value
// },
// contentAlignment = Alignment.Center
// ) {
// Crossfade(
// targetState = paused.value,
// label = "media_icon"
// ) { p ->
// Text(
// text = if (p) "􀊄" else "􀊆",
// style = TextStyle(
// fontSize = 24.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Normal,
// color = Color(0xFF0091FF),
// textAlign = TextAlign.Center
// )
// )
// }
// }
// }
//
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888),
// modifier = Modifier
// .padding(horizontal = 20.dp)
// .padding(bottom = 16.dp)
// )
Box(
modifier = Modifier.fillMaxWidth()
) {
fun colorFromY(y: Float): Color {
val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f)
val stops = listOf(
0.0f to Color(0xFFFFA300),
0.25f to Color(0xFFFCE600),
0.5f to Color(0xFF00FAAF),
0.75f to Color(0xFF00FAFF),
1.0f to Color(0xFF00B5FF)
)
val (start, end) = stops.zipWithNext()
.first { f <= it.second.first }
val c = (f - start.first) / (end.first - start.first)
return lerp(start.second, end.second, c)
}
fun pathBrush(
startY: Float,
endY: Float,
): Brush {
val stops = (0..20).map { i ->
val t = i / 20f
val y = lerp(startY, endY, t)
t to colorFromY(y)
}
return Brush.linearGradient(
colorStops = stops.toTypedArray()
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.layerBackdrop(backdrop)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = 20.dp)
) {
Row(
modifier = Modifier
.fillMaxSize()
) {
val dashCount = (height / 10.dp).toInt()
repeat(3) {
Box(
modifier = Modifier
.fillMaxSize()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
for (i in 1..(dashCount)) {
val t = i.toFloat() / dashCount
val centerDistance = abs(0.5f - t)
val alpha = 1f - (centerDistance * 2f)
Box(
modifier = Modifier
.height(9.dp)
.width(0.75.dp)
.background(
dashColor.copy(alpha),
RoundedCornerShape(28.dp)
)
)
}
}
}
}
}
val backgroundColor = MaterialTheme.colorScheme.surface
Canvas(
modifier = Modifier
.fillMaxSize()
) {
val canvasWidth = size.width
drawLine(
color = backgroundColor,
start = Offset(
x = 0f,
y = lowOffset.value + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(lowOffset.value),
start = Offset(
x = 0f,
y = lowOffset.value + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
),
strokeWidth = 8f
)
val lowToMidPath = Path()
lowToMidPath.moveTo(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
)
lowToMidPath.cubicTo(
x1 = canvasWidth * 1 / 6f + 108.dp.value,
y1 = lowOffset.value + maxOffset,
x2 = canvasWidth * 0.5f - 108.dp.value,
y2 = midOffset.value + maxOffset,
x3 = canvasWidth * 0.5f,
y3 = midOffset.value + maxOffset
)
drawPath(
color = backgroundColor,
path = lowToMidPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
lowOffset.value,
midOffset.value
),
path = lowToMidPath,
style = Stroke(width = 8f)
)
val midToHighPath = Path()
midToHighPath.moveTo(
x = 0.5f * canvasWidth,
y = midOffset.value + maxOffset
)
midToHighPath.cubicTo(
x1 = canvasWidth * 0.5f + 108.dp.value,
y1 = midOffset.value + maxOffset,
x2 = canvasWidth * 5 / 6f - 108.dp.value,
y2 = highOffset.value + maxOffset,
x3 = canvasWidth * 5 / 6f,
y3 = highOffset.value + maxOffset
)
drawPath(
color = backgroundColor,
path = midToHighPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
midOffset.value,
highOffset.value
),
path = midToHighPath,
style = Stroke(width = 8f)
)
drawLine(
color = backgroundColor,
start = Offset(
x = 5 / 6f * canvasWidth,
y = highOffset.value + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = highOffset.value + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(highOffset.value),
start = Offset(
x = 5 / 6f * canvasWidth,
y = highOffset.value + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = highOffset.value + maxOffset
),
strokeWidth = 8f
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "Low".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "Mid".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "High".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically
) {
for (i in 0..2) {
Row(
modifier = Modifier
.weight(1f),
horizontalArrangement = Arrangement.Center
) {
val pressed = remember { mutableStateOf(false) }
Box(
modifier = Modifier
.offset {
IntOffset(
x = 0,
y = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.roundToInt()
)
},
contentAlignment = Alignment.Center
) {
Crossfade(
pressed.value
) {
Box(
modifier = Modifier
.size(96.dp)
.then(
if (it) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { CircleShape },
highlight = {
Highlight.Ambient
},
onDrawSurface = {
drawCircle(
color = Color.White.copy(
0.2f
),
radius = size.height
)
drawCircle(
color = colorFromY(
when (i) {
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
else -> 0f
}
),
style = Stroke(2.dp.value),
radius = size.height / 2
)
},
effects = {
lens(
refractionHeight = 32f.dp.value,
refractionAmount = size.height
)
}
)
} else Modifier
)
)
}
Box(
modifier = Modifier
.size(18.dp)
.background(
colorFromY(
when (i) {
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
else -> 0f
}
),
CircleShape
)
.border(
2.5.dp,
MaterialTheme.colorScheme.surfaceContainer,
CircleShape
)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
when (i) {
0 -> {
lowOffset.value =
(lowOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
1 -> {
midOffset.value =
(midOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
2 -> {
highOffset.value =
(highOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
}
},
onDragStarted = {
pressed.value = true
},
onDragStopped = {
pressed.value = false
}
)
)
}
}
}
}
}
}
}
}
@Preview(name = "Apple")
@Composable
fun EqualizerScreenPreviewApple() {
LibrePodsTheme(
m3eEnabled = false
) {
Box (
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = demoState,
setCustomEqEnabled = { },
setCustomEq = {_, _, _ -> }
)
}
}
}
@Preview(name = "Material")
@Composable
fun EqualizerScreenPreviewMaterial() {
LibrePodsTheme(
m3eEnabled = true
) {
val state = remember { mutableStateOf(demoState) }
Box (
modifier = Modifier
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = state.value,
setCustomEqEnabled = { state.value = state.value.copy(customEq = state.value.customEq.copy(state = if (it) 2 else 1)) },
setCustomEq = {low, mid, high -> state.value = state.value.copy(customEq = state.value.customEq.copy(low = low, mid = mid, high = high))}
)
}
}
}
@@ -1,405 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens
import android.graphics.Paint
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) {
viewModel.startHeadTracking()
onDispose {
viewModel.stopHeadTracking()
}
}
val isDarkTheme = isSystemInDarkTheme()
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope()
var lastClickTime by remember { mutableLongStateOf(0L) }
var shouldExplode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(topPadding))
Column (
modifier = Modifier
.fillMaxWidth()
.layerBackdrop(backdrop)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
StyledToggle(
label = "Head Gestures",
checked = state.headGesturesEnabled,
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
enabled = state.isPremium || state.headGesturesEnabled,
description = stringResource(R.string.head_gestures_details),
header = true
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
"Velocity",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
)
Plot()
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(gestureText) {
if (gestureText.isNotEmpty()) {
lastClickTime = System.currentTimeMillis()
delay(3000)
if (System.currentTimeMillis() - lastClickTime >= 3000) {
shouldExplode = true
}
}
}
}
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
StyledButton(
onClick = {
gestureText = gestureTextValue
coroutineScope.launch {
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
}
},
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
maxScale = 0.05f
) {
Text(
"Test Head Gestures",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = MaterialTheme.colorScheme.onSecondaryContainer
),
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
) {
AnimatedContent(
targetState = gestureText,
transitionSpec = {
(fadeIn(
animationSpec = tween(300)
) + slideInVertically(
initialOffsetY = { 40 },
animationSpec = tween(300)
)).togetherWith(fadeOut(animationSpec = tween(150)))
}
) { text ->
if (shouldExplode) {
LaunchedEffect(Unit) {
CoroutineScope(coroutineScope.coroutineContext).launch {
delay(750)
gestureText = ""
}
}
Text(
text = text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
color = MaterialTheme.colorScheme.onBackground
)
} else {
Text(
text = text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
),
modifier = Modifier
.fillMaxWidth()
)
}
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@Composable
private fun Plot() {
val acceleration by HeadTracking.acceleration.collectAsState()
val maxPoints = 100
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
val darkTheme = isSystemInDarkTheme()
var maxAbs by remember { mutableFloatStateOf(1000f) }
LaunchedEffect(acceleration) {
points.add(Pair(acceleration.horizontal, acceleration.vertical))
if (points.size > maxPoints) {
points.removeAt(0)
}
val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) }
maxAbs = maxOf(currentMax * 1.2f, 1000f)
}
Card(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(28.dp)
) {
val horizontalColor = MaterialTheme.colorScheme.primary
val verticalColor = MaterialTheme.colorScheme.onPrimary
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Canvas(
modifier = Modifier.fillMaxSize()
) {
val width = size.width
val height = size.height
val xScale = width / maxPoints
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
val zeroY = height / 2
val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
for (i in 0..maxPoints step 10) {
val x = i * xScale
drawLine(
color = gridColor,
start = Offset(x, 0f),
end = Offset(x, height),
strokeWidth = 1.dp.toPx()
)
}
val gridStep = maxAbs / 4
for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) {
val y = zeroY - value * yScale
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(width, y),
strokeWidth = 1.dp.toPx()
)
}
drawLine(
color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
start = Offset(0f, zeroY),
end = Offset(width, zeroY),
strokeWidth = 1.5f.dp.toPx()
)
if (points.size > 1) {
for (i in 0 until points.size - 1) {
val x1 = i * xScale
val x2 = (i + 1) * xScale
drawLine(
color = horizontalColor,
start = Offset(x1, zeroY - points[i].first * yScale),
end = Offset(x2, zeroY - points[i + 1].first * yScale),
strokeWidth = 2.dp.toPx()
)
drawLine(
color = verticalColor,
start = Offset(x1, zeroY - points[i].second * yScale),
end = Offset(x2, zeroY - points[i + 1].second * yScale),
strokeWidth = 2.dp.toPx()
)
}
}
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.RIGHT
}
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
drawText("0", 30.dp.toPx(), height/2, paint)
drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint)
}
val legendY = 15.dp.toPx()
val textOffsetY = legendY + 5.dp.toPx() / 2
drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.LEFT
}
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
}
drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.LEFT
}
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
}
}
}
}
}
@@ -1,243 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) {
val verticalScrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop()
val showDialog = remember { mutableStateOf(false) }
val initialLoad = remember { mutableStateOf(true) }
val state by viewModel.uiState.collectAsState()
val hearingAidEnabled = remember {
val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
}
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.layerBackdrop(backdrop)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
// val mediaAssistEnabled = remember { mutableStateOf(false) }
// val adjustMediaEnabled = remember { mutableStateOf(false) }
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
LaunchedEffect(hearingAidEnabled.value) {
if (hearingAidEnabled.value && !initialLoad.value) {
showDialog.value = true
} else if (!hearingAidEnabled.value && !initialLoad.value) {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
hearingAidEnabled.value = false
}
initialLoad.value = false
}
// fun onAdjustPhoneChange(value: Boolean) {
// // TODO
// }
// fun onAdjustMediaChange(value: Boolean) {
// // TODO
// }
StyledList (title = stringResource(R.string.hearing_aid)) {
StyledToggle(
label = stringResource(R.string.hearing_aid),
checked = hearingAidEnabled.value,
onCheckedChange = { hearingAidEnabled.value = it },
)
StyledListItem(
name = stringResource(R.string.adjustments),
onClick = onNavigateHearingAidAdjustments,
)
}
Text(
text = stringResource(R.string.hearing_aid_description),
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
StyledListItem(
name = stringResource(R.string.update_hearing_test),
onClick = onNavigateHearingTest,
)
// not implemented yet
// StyledToggle(
// titleRes = stringResource(R.string.media_assist),
// label = stringResource(R.string.media_assist),
// checkedState = mediaAssistEnabled,
// independent = true,
// descriptionRes = stringResource(R.string.media_assist_description)
// )
// Spacer(modifier = Modifier.height(8.dp))
// Column (
// modifier = Modifier
// .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp))
// ) {
// StyledToggle(
// label = stringResource(R.string.adjust_media),
// checkedState = adjustMediaEnabled,
// onCheckedChange = { onAdjustMediaChange(it) },
// independent = false
// )
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888),
// modifier = Modifier
// .padding(horizontal = 12.dp)
// )
// StyledToggle(
// label = stringResource(R.string.adjust_calls),
// checkedState = adjustPhoneEnabled,
// onCheckedChange = { onAdjustPhoneChange(it) },
// independent = false
// )
// }
Spacer(modifier = Modifier.height(bottomPadding))
}
ConfirmationDialog(
showDialog = showDialog,
title = "Enable Hearing Aid",
message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.",
confirmText = "Enable",
dismissText = "Cancel",
onConfirm = {
showDialog.value = false
val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
if (!enrolled) {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} else {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
}
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch {
try {
if (state.hearingAidData.isEmpty()) {
Log.w(TAG, "read failed")
return@launch
}
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
if (parsed == null) {
Log.w(TAG, "transparency parse failed")
return@launch
}
val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}")
}
}
},
onDismiss = {
hearingAidEnabled.value = false
showDialog.value = false
},
backdrop = backdrop
)
}
@@ -1,118 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (state.vendorIdHook) {
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte())
)
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(12.dp))
}
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
0
)?.toInt() == 1,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
)
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,35 +0,0 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer),
contentAlignment = Alignment.Center
) {
CircularWavyProgressIndicator(
modifier = Modifier
.size(120.dp)
)
}
}
@Preview
@Composable
fun LoadingScreenPreview() {
LibrePodsTheme {
LoadingScreen()
}
}
@@ -1,101 +0,0 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun MicrophoneSettingsRoute(
viewModel: AirPodsViewModel
) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
MicrophoneSettingsScreen(
selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0,
topPadding = topPadding,
bottomPadding = bottomPadding,
onMicrophoneSettingsChanged = {
viewModel.setControlCommandInt(id, it)
}
)
}
}
@Composable
fun MicrophoneSettingsScreen(
selectedMode: Int,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
onMicrophoneSettingsChanged: (Int) -> Unit
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = stringResource(R.string.microphone_automatic),
selected = selectedMode == 0,
onClick = { onMicrophoneSettingsChanged(0) }
)
StyledListItem(
name = stringResource(R.string.microphone_always_right),
selected = selectedMode == 1,
onClick = { onMicrophoneSettingsChanged(1) }
)
StyledListItem(
name = stringResource(R.string.microphone_always_left),
selected = selectedMode == 2,
onClick = { onMicrophoneSettingsChanged(2) }
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,87 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun OpenSourceLicensesScreen() {
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val context = LocalContext.current
val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader()
.use { it.readText() }
}
LibrariesContainer(
libraries = libraries,
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,233 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState()
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val scrollState = rememberScrollState()
Column (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = stringResource(R.string.noise_control),
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = {
viewModel.setLongPressAction(
name,
StemAction.CYCLE_NOISE_CONTROL_MODES
)
}
)
StyledListItem(
name = stringResource(R.string.digital_assistant),
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = {
viewModel.setLongPressAction(
name,
StemAction.DIGITAL_ASSISTANT
)
},
enabled = state.isPremium
)
}
if (!state.isPremium) {
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Spacer(modifier = Modifier.height(32.dp))
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
StyledList(
title = stringResource(R.string.noise_control),
description = stringResource(R.string.press_and_hold_noise_control_description)
) {
if (state.offListeningMode) {
StyledListItem(
name = stringResource(R.string.off),
description = stringResource(R.string.listening_mode_off_description),
selected = (currentByte and 0x01) != 0,
onClick = {
viewModel.toggleListeningMode(0x01)
},
orientation = ListItemOrientation.Vertical,
leadingContent = {
Icon(
painter = painterResource(R.drawable.noise_cancellation),
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.height(42.dp)
.wrapContentWidth()
)
}
)
}
StyledListItem(
name = stringResource(R.string.transparency),
description = stringResource(R.string.listening_mode_transparency_description),
selected = (currentByte and 0x04) != 0,
onClick = {
viewModel.toggleListeningMode(0x04)
},
orientation = ListItemOrientation.Vertical,
leadingContent = {
Icon(
painter = painterResource(R.drawable.transparency),
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.height(42.dp)
.wrapContentWidth()
)
}
)
StyledListItem(
name = stringResource(R.string.adaptive),
description = stringResource(R.string.listening_mode_adaptive_description),
selected = (currentByte and 0x08) != 0,
onClick = {
viewModel.toggleListeningMode(0x08)
},
orientation = ListItemOrientation.Vertical,
leadingContent = {
Icon(
painter = painterResource(R.drawable.adaptive),
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.height(42.dp)
.wrapContentWidth()
)
}
)
StyledListItem(
name = stringResource(R.string.noise_cancellation),
description = stringResource(R.string.listening_mode_noise_cancellation_description),
selected = (currentByte and 0x02) != 0,
onClick = {
viewModel.toggleListeningMode(0x02)
},
orientation = ListItemOrientation.Vertical,
leadingContent = {
Icon(
painter = painterResource(R.drawable.noise_cancellation),
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.height(42.dp)
.wrapContentWidth()
)
}
)
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,217 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.navigation.Screen
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun PurchaseScreen(
viewModel: PurchaseViewModel = viewModel(),
backStack: SnapshotStateList<Screen>
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.layerBackdrop(backdrop)
.verticalScroll(scrollState)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
LaunchedEffect(state.isPremium) {
if (state.isPremium) {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
}
}
if (!state.isPremium) {
StyledList(title = stringResource(R.string.free_features)) {
StyledListItem(
name = stringResource(R.string.ear_detection),
description = stringResource(R.string.ear_detection_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.battery),
description = stringResource(R.string.battery_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.noise_control),
description = stringResource(R.string.noise_control_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
if (XposedState.isAvailable) {
StyledListItem(
name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})",
description = stringResource(R.string.hearing_aid_description)
.substringBefore("\n\n"),
enabled = false,
orientation = ListItemOrientation.Vertical
)
}
}
Spacer(modifier = Modifier.height(24.dp))
StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) {
StyledListItem(
name = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.digital_assistant_on_long_press),
description = stringResource(R.string.digital_assistant_on_long_press_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.head_gestures),
description = stringResource(R.string.head_gestures_details),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.advanced_device_settings),
description = stringResource(R.string.advanced_device_settings_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.automatic_connection),
description = stringResource(R.string.automatic_connection_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.customizations),
description = stringResource(R.string.customizations_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.support_the_development),
description = stringResource(R.string.support_development_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
}
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = {
viewModel.purchase(context)
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary,
materialButtonStyle = MaterialButtonStyle.Filled
) {
Text(
stringResource(R.string.buy_price, state.price),
style = MaterialTheme.typography.bodyMediumEmphasized,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(8.dp))
StyledButton(
onClick = {
viewModel.restorePurchases()
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
isInteractive = false,
materialButtonStyle = MaterialButtonStyle.Outlined
) {
Text(
stringResource(R.string.restore_purchases),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,94 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun RenameScreen(viewModel: AirPodsViewModel) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val name = sharedPreferences.getString("name", "")?: ""
val textFieldState = rememberTextFieldState(initialText = name)
LaunchedEffect(textFieldState.text) {
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
viewModel.setName(textFieldState.text.toString())
}
StyledInputField(
textFieldState,
focusRequester
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,365 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "TransparencySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = rememberSaveable { mutableStateOf(false) }
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val eq = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8) { 0.5f }) }
val initialized = rememberSaveable { mutableStateOf(false) }
val transparencySettings = remember {
mutableStateOf(
TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
)
)
}
LaunchedEffect(
enabled.value,
amplificationSliderValue.floatValue,
balanceSliderValue.floatValue,
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
eq.value
) {
if (!initialized.value) return@LaunchedEffect
transparencySettings.value = TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
)
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
LaunchedEffect(state.transparencyData) {
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
eq.value = parsedSettings.leftEQ.copyOf()
}
initialized.value = true
}
if (state.vendorIdHook) {
StyledToggle(
label = stringResource(R.string.transparency_mode),
checked = enabled.value,
description = stringResource(R.string.customize_transparency_mode_description),
onCheckedChange = { enabled.value = it }
)
Spacer(modifier = Modifier.height(4.dp))
StyledSlider(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = it
},
startIcon = "􀊥",
endIcon = "􀊩",
independent = true
)
StyledSlider(
label = stringResource(R.string.balance),
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = it
},
snapPoints = listOf(-1f, 0f, 1f),
startLabel = stringResource(R.string.left),
endLabel = stringResource(R.string.right),
independent = true,
)
StyledSlider(
label = stringResource(R.string.tone),
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = it
},
startLabel = stringResource(R.string.darker),
endLabel = stringResource(R.string.brighter),
independent = true,
)
StyledSlider(
label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it
},
startLabel = stringResource(R.string.less),
endLabel = stringResource(R.string.more),
independent = true,
)
StyledToggle(
label = stringResource(R.string.conversation_boost),
checked = conversationBoostEnabled.value,
description = stringResource(R.string.conversation_boost_description),
onCheckedChange = { conversationBoostEnabled.value = it }
)
Text(
text = stringResource(R.string.equalizer),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(16.dp, bottom = 4.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
for (i in 0 until 8) {
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
) {
Text(
text = String.format("%.2f", eqValue.floatValue),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Slider(
value = eqValue.floatValue,
onValueChange = { newVal ->
eqValue.floatValue = newVal
val newEQ = eq.value.copyOf()
newEQ[i] = eqValue.floatValue
eq.value = newEQ
},
valueRange = 0f..100f,
modifier = Modifier
.fillMaxWidth(0.9f)
.height(36.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
Box(
modifier = Modifier
.size(24.dp)
.shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape)
)
},
track = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp),
contentAlignment = Alignment.CenterStart
)
{
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(trackColor, RoundedCornerShape(4.dp))
)
Box(
modifier = Modifier
.fillMaxWidth(eqValue.floatValue / 100f)
.height(4.dp)
.background(
activeTrackColor,
RoundedCornerShape(4.dp)
)
)
}
}
)
Text(
text = stringResource(R.string.band_label, i + 1),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,920 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.LogCollector
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun CustomIconButton(
onClick: () -> Unit,
content: @Composable () -> Unit
) {
Box(
modifier = Modifier
.clickable(onClick = onClick)
.padding(8.dp),
contentAlignment = Alignment.Center
) {
content()
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun TroubleshootingScreen() {
val context = LocalContext.current
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val logCollector = remember { LogCollector(context) }
val savedLogs = remember { mutableStateListOf<File>() }
var isCollectingLogs by remember { mutableStateOf(false) }
var showTroubleshootingSteps by remember { mutableStateOf(false) }
var currentStep by remember { mutableIntStateOf(0) }
var logContent by remember { mutableStateOf("") }
var selectedLogFile by remember { mutableStateOf<File?>(null) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showDeleteAllDialog by remember { mutableStateOf(false) }
var isLoadingLogContent by remember { mutableStateOf(false) }
var logContentLoaded by remember { mutableStateOf(false) }
LaunchedEffect(isCollectingLogs) {
while (isCollectingLogs) {
delay(250)
delay(250)
}
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var showBottomSheet by remember { mutableStateOf(false) }
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD)
var instructionText by remember { mutableStateOf("") }
val isDarkTheme = isSystemInDarkTheme()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
val logsDir = File(context.filesDir, "logs")
if (logsDir.exists()) {
savedLogs.clear()
savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") }
?.sortedByDescending { it.lastModified() } ?: emptyList())
}
}
}
val saveLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
if (uri != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(logContent.toByteArray())
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"Failed to save log: ${e.localizedMessage}",
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
LaunchedEffect(currentStep) {
instructionText = when (currentStep) {
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
1 -> "Please put your AirPods in the case and close it, so they disconnect completely."
2 -> "Preparing to collect logs... Please wait."
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
4 -> "Log collection complete! You can now save or share the logs."
else -> ""
}
}
fun openLogBottomSheet(file: File) {
selectedLogFile = file
logContent = ""
isLoadingLogContent = false
logContentLoaded = false
showBottomSheet = true
}
val backdrop = rememberLayerBackdrop()
Box(
modifier = Modifier.fillMaxSize()
) {
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(scrollState)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
Text(
text = stringResource(R.string.saved_logs),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
if (savedLogs.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.no_logs_found),
fontSize = 16.sp,
color = textColor
)
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Total Logs: ${savedLogs.size}",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
if (savedLogs.size > 1) {
TextButton(
onClick = { showDeleteAllDialog = true },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete All")
}
}
}
savedLogs.forEach { logFile ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.clickable {
openLogBottomSheet(logFile)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = logFile.name,
fontSize = 16.sp,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
.format(Date(logFile.lastModified())),
fontSize = 14.sp,
color = textColor.copy(alpha = 0.6f)
)
}
CustomIconButton(
onClick = {
selectedLogFile = logFile
showDeleteDialog = true
}
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(
visible = !showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(300))
) {
Button(
onClick = { showTroubleshootingSteps = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
enabled = !isCollectingLogs
) {
Text(stringResource(R.string.collect_logs))
}
}
AnimatedVisibility(
visible = showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(animationSpec = tween(300)) { it / 2 },
exit = fadeOut(animationSpec = tween(300)) +
slideOutVertically(animationSpec = tween(300)) { it / 2 }
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.troubleshooting_steps),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(16.dp)
) {
val textAlpha = animateFloatAsState(
targetValue = 1f,
animationSpec = tween(durationMillis = 300),
label = "textAlpha"
)
Text(
text = instructionText,
fontSize = 16.sp,
color = textColor.copy(alpha = textAlpha.value),
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(16.dp))
when (currentStep) {
0 -> {
Button(
onClick = {
coroutineScope.launch {
logCollector.openXposedSettings(context)
delay(2000)
currentStep = 1
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Open Xposed Settings")
}
}
1 -> {
Button(
onClick = {
currentStep = 2
isCollectingLogs = true
coroutineScope.launch {
try {
logCollector.clearLogs()
logCollector.addLogMarker(LogCollector.LogMarkerType.START)
logCollector.killBluetoothService()
withContext(Dispatchers.Main) {
delay(500)
currentStep = 3
}
val timestamp = SimpleDateFormat(
"yyyyMMdd_HHmmss",
Locale.US
).format(Date())
logContent =
logCollector.startLogCollection(
listener = { /* Removed live log display */ },
connectionDetectedCallback = {
launch {
delay(5000)
withContext(Dispatchers.Main) {
if (isCollectingLogs) {
logCollector.stopLogCollection()
currentStep = 4
isCollectingLogs =
false
}
}
}
}
)
val logFile =
logCollector.saveLogToInternalStorage(
"airpods_log_$timestamp.txt",
logContent
)
logFile?.let {
withContext(Dispatchers.Main) {
savedLogs.add(0, it)
selectedLogFile = it
Toast.makeText(
context,
"Log saved: ${it.name}",
Toast.LENGTH_SHORT
).show()
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"Error collecting logs: ${e.message}",
Toast.LENGTH_SHORT
).show()
isCollectingLogs = false
currentStep = 0
}
}
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Continue")
}
}
2, 3 -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = accentColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
fontSize = 14.sp,
color = textColor
)
if (currentStep == 3) {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
coroutineScope.launch {
logCollector.addLogMarker(
LogCollector.LogMarkerType.CUSTOM,
"Manual stop requested by user"
)
delay(1000)
logCollector.stopLogCollection()
delay(500)
withContext(Dispatchers.Main) {
currentStep = 4
isCollectingLogs = false
Toast.makeText(
context,
"Log collection stopped",
Toast.LENGTH_SHORT
).show()
}
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier
.fillMaxWidth()
) {
Text("Stop Collection")
}
}
}
}
4 -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Button(
onClick = {
selectedLogFile?.let { file ->
val fileUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
val shareIntent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(
Intent.EXTRA_STREAM,
fileUri
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
"Share log file"
)
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.width(150.dp)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Share")
}
Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = {
selectedLogFile?.let { file ->
saveLauncher.launch(
file.absolutePath
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.width(150.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_save),
contentDescription = "Save"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Save")
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
currentStep = 0
showTroubleshootingSteps = false
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Done")
}
}
}
}
}
}
if (showDeleteDialog && selectedLogFile != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Log File") },
text = {
Text("Are you sure you want to delete this log file? This action cannot be undone.")
},
confirmButton = {
TextButton(
onClick = {
selectedLogFile?.let { file ->
if (file.delete()) {
savedLogs.remove(file)
Toast.makeText(
context,
"Log file deleted",
Toast.LENGTH_SHORT
)
.show()
} else {
Toast.makeText(
context,
"Failed to delete log file",
Toast.LENGTH_SHORT
).show()
}
}
showDeleteDialog = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
if (showDeleteAllDialog) {
AlertDialog(
onDismissRequest = { showDeleteAllDialog = false },
title = { Text("Delete All Logs") },
text = {
Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
},
confirmButton = {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
var deletedCount = 0
savedLogs.forEach { file ->
if (file.delete()) {
deletedCount++
}
}
withContext(Dispatchers.Main) {
if (deletedCount > 0) {
savedLogs.clear()
Toast.makeText(
context,
"Deleted $deletedCount log files",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
"Failed to delete log files",
Toast.LENGTH_SHORT
).show()
}
}
}
showDeleteAllDialog = false
}
) {
Text("Delete All", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteAllDialog = false }) {
Text("Cancel")
}
}
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
tonalElevation = 8.dp
) {
LaunchedEffect(selectedLogFile) {
if (!logContentLoaded) {
delay(300)
withContext(Dispatchers.IO) {
isLoadingLogContent = true
logContent = try {
selectedLogFile?.readText() ?: ""
} catch (e: Exception) {
"Error loading log content: ${e.message}"
}
isLoadingLogContent = false
logContentLoaded = true
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 32.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
) {
Text(
text = selectedLogFile?.name ?: "Log Content",
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
color = textColor
)
Text(
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
.format(Date(selectedLogFile?.lastModified() ?: 0)),
fontSize = 14.sp,
color = textColor.copy(alpha = 0.7f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
}
if (isLoadingLogContent) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = accentColor)
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(
color = Color.Black,
shape = RoundedCornerShape(8.dp)
)
) {
val horizontalScrollState = rememberScrollState()
val verticalScrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
.horizontalScroll(horizontalScrollState)
.verticalScroll(verticalScrollState)
) {
Text(
text = logContent,
fontSize = 14.sp,
color = Color.LightGray,
lineHeight = 20.sp,
fontFamily = FontFamily.Monospace,
softWrap = false
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = {
selectedLogFile?.let { file ->
val fileUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
"Share log file"
)
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Share")
}
Button(
onClick = {
selectedLogFile?.let { file ->
saveLauncher.launch(file.absolutePath)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(id = R.drawable.ic_save),
contentDescription = "Save"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Save")
}
}
}
}
}
}
DisposableEffect(Unit) {
onDispose {
logCollector.stopLogCollection()
}
}
}
@@ -1,332 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.demoState
private const val TAG = "UpdateHearingTestScreen"
@Composable
fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = state,
topPadding = topPadding,
bottomPadding = bottomPadding,
setATTCharacteristicValue = viewModel::setATTCharacteristicValue
)
}
}
@Composable
fun UpdateHearingTestScreen(
state: AirPodsUiState,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit
) {
val verticalScrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(topPadding))
Text(
text = stringResource(R.string.hearing_test_value_instruction),
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center,
)
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val rightEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val debounceJob = remember { mutableStateOf<Job?>(null) }
val initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = leftAmplification.floatValue,
rightAmplification = rightAmplification.floatValue,
leftTone = tone.floatValue,
rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
)
}
LaunchedEffect(state.hearingAidData) {
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
tone.floatValue = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
initialized.value = true
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
LaunchedEffect(
leftEQ.value,
rightEQ.value,
conversationBoostEnabled.value,
leftAmplification.floatValue,
rightAmplification.floatValue,
tone.floatValue,
ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialized.value) return@LaunchedEffect
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = leftAmplification.floatValue,
rightAmplification = rightAmplification.floatValue,
leftTone = tone.floatValue,
rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue)
}
val frequencies =
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.width(60.dp))
Text(
text = stringResource(R.string.left),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMediumEmphasized
)
Text(
text = stringResource(R.string.right),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMediumEmphasized
)
}
frequencies.forEachIndexed { index, freq ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = freq,
modifier = Modifier
.width(60.dp)
.align(Alignment.CenterVertically),
textAlign = TextAlign.End,
style = MaterialTheme.typography.labelSmall
)
OutlinedTextField(
value = leftEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = leftEQ.value.copyOf()
newArray[index] = parsed
leftEQ.value = newArray
Log.d(TAG, "Left EQ updated at index $index to $parsed")
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = rightEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = rightEQ.value.copyOf()
newArray[index] = parsed
rightEQ.value = newArray
Log.d(TAG, "Right EQ updated at index $index to $parsed")
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@Preview(name = "Apple")
@Composable
fun UpdateHearingTestScreenPreviewApple() {
LibrePodsTheme(
m3eEnabled = false
) {
Box (
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = demoState,
setATTCharacteristicValue = { _, _ -> }
)
}
}
}
@Preview(name = "Material")
@Composable
fun UpdateHearingTestScreenPreviewMaterial() {
LibrePodsTheme(
m3eEnabled = true
) {
Box (
modifier = Modifier
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = demoState,
setATTCharacteristicValue = { _, _ -> }
)
}
}
}
@@ -1,81 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun VersionScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList(title = stringResource(R.string.version)) {
StyledListItem(
name = stringResource(R.string.version) + " 1",
description = state.version1,
enabled = false
)
StyledListItem(
name = stringResource(R.string.version) + " 2",
description = state.version2,
enabled = false
)
StyledListItem(
name = stringResource(R.string.version) + " 3",
description = state.version3,
enabled = false
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,786 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.viewmodel
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.CustomEq
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService
@Suppress("ArrayInDataClass")
data class AirPodsUiState(
val deviceName: String = "AirPods",
val isLocallyConnected: Boolean = false,
val instance: AirPodsInstance? = null,
val capabilities: Set<Capability> = emptySet(),
val controlStates: Map<ControlCommandIdentifiers, ByteArray> = emptyMap(),
val offListeningMode: Boolean = true,
val battery: List<Battery> = emptyList(),
val ancMode: Int = 3,
val modelName: String = "",
val actualModel: String = "",
val serialNumbers: List<String> = emptyList(),
val version1: String = "",
val version2: String = "",
val version3: String = "",
val headTrackingActive: Boolean = false,
val headGesturesEnabled: Boolean = true,
val eqData: FloatArray = floatArrayOf(),
val automaticEarDetectionEnabled: Boolean = true,
val automaticConnectionEnabled: Boolean = true,
val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val loudSoundReductionEnabled: Boolean = false,
val transparencyData: ByteArray = byteArrayOf(),
val hearingAidData: ByteArray = byteArrayOf(),
val isPremium: Boolean = false,
val vendorIdHook: Boolean = false,
val dynamicEndOfCharge: Boolean = false,
val connectionSuccessful: Boolean = false,
val timeUntilFOSSPremiumExpiry: Long = 0L,
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
)
val demoInstance = AirPodsInstance(
name = "AirPods Pro",
model = AirPodsModels.getModelByModelNumber("A3064")!!,
actualModelNumber = "A3064",
serialNumber = "JXF9Q94A40",
leftSerialNumber = "L-DEMO",
rightSerialNumber = "R-DEMO",
version1 = "90.3388000000000000.1786",
version2 = "90.3388000000000000.1786",
version3 = "9441861",
)
val demoState = AirPodsUiState(
deviceName = demoInstance.name,
isLocallyConnected = true,
capabilities = demoInstance.model.capabilities,
battery = listOf(
Battery(BatteryComponent.LEFT, 80, BatteryStatus.OPTIMIZED_CHARGING),
Battery(BatteryComponent.RIGHT, 18, BatteryStatus.CHARGING),
Battery(BatteryComponent.CASE, 76, BatteryStatus.NOT_CHARGING)
),
ancMode = 3,
offListeningMode = false,
modelName = demoInstance.model.displayName,
actualModel = demoInstance.actualModelNumber,
serialNumbers = listOf(
demoInstance.serialNumber?: "",
demoInstance.leftSerialNumber?: "",
demoInstance.rightSerialNumber?: ""
),
version1 = demoInstance.version1?: "",
version2 = demoInstance.version2?: "",
version3 = demoInstance.version3?: "",
headTrackingActive = true,
headGesturesEnabled = true,
automaticEarDetectionEnabled = true,
automaticConnectionEnabled = true,
leftAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
rightAction = StemAction.DIGITAL_ASSISTANT,
loudSoundReductionEnabled = true,
isPremium = true,
vendorIdHook = true,
dynamicEndOfCharge = true,
connectionSuccessful = true,
customEq = CustomEq(state = 2, low = 65, mid = 50, high = 70),
controlStates = mapOf(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG to byteArrayOf(0x01),
ControlCommandIdentifiers.STEM_CONFIG to byteArrayOf(0x00),
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL to byteArrayOf(0x00),
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL to byteArrayOf(0x00),
ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL to byteArrayOf(0x00),
ControlCommandIdentifiers.VOLUME_SWIPE_MODE to byteArrayOf(0x01),
ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG to byteArrayOf(0x00, 0x03),
ControlCommandIdentifiers.CHIME_VOLUME to byteArrayOf(0x46, 0x50),
ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG to byteArrayOf(0x01),
ControlCommandIdentifiers.HEARING_AID to byteArrayOf(0x01, 0x02),
ControlCommandIdentifiers.HPS_GAIN_SWIPE to byteArrayOf(0x01),
ControlCommandIdentifiers.HEARING_ASSIST_CONFIG to byteArrayOf(0x02),
ControlCommandIdentifiers.HRM_STATE to byteArrayOf(0x01),
ControlCommandIdentifiers.AUTO_ANC_STRENGTH to byteArrayOf(0x45),
ControlCommandIdentifiers.ONE_BUD_ANC_MODE to byteArrayOf(0x01),
ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG to byteArrayOf(0x01),
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG to byteArrayOf(0x01),
ControlCommandIdentifiers.PPE_CAP_LEVEL_CONFIG to byteArrayOf(0x52),
ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE to byteArrayOf(0x01),
ControlCommandIdentifiers.LISTENING_MODE to byteArrayOf(0x04)
)
)
class AirPodsViewModel(
) : ViewModel() {
private lateinit var sharedPreferences: SharedPreferences
private lateinit var appContext: Context
private lateinit var service: AirPodsService
private lateinit var controlRepo: ControlCommandRepository
var isReady by mutableStateOf(false)
private set
fun init(service: AirPodsService, controlRepo: ControlCommandRepository, sharedPreferences: SharedPreferences, appContext: Context) {
this.service = service
this.controlRepo = controlRepo
this.sharedPreferences = sharedPreferences
this.appContext = appContext
observeBroadcasts()
loadName()
loadInstance()
loadSharedPreferences()
observeAACP()
loadCurrentStatus()
loadEq()
loadATT()
observeATT()
observeSharedPreferences()
observeBilling()
if (isDemoMode) activateDemoMode()
isReady = true
}
private val _uiState = MutableStateFlow(AirPodsUiState())
val uiState: StateFlow<AirPodsUiState> = _uiState
private var isDemoMode = false
private val listeners =
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
private val xposedRemotePref = XposedRemotePrefProvider.create()
private lateinit var broadcastReceiver: BroadcastReceiver
// private val _cameraAction = MutableStateFlow(
// sharedPreferences.getString("camera_action", null)
// ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } })
//
// val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
//
// fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
// sharedPreferences.edit {
// if (action == null) remove("camera_action")
// else putString("camera_action", action.name)
// }
// _cameraAction.value = action
// }
fun setCustomEq(low: Int, mid: Int, high: Int) {
require(low in 0..100)
require(mid in 0..100)
require(high in 0..100)
val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high)
service.aacpManager.sendCustomEqPacket(updatedEq)
_uiState.update {
it.copy(
customEq = updatedEq
)
}
}
fun setCustomEqEnabled(enabled: Boolean) {
service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1))
_uiState.update {
it.copy(
customEq = it.customEq.copy(state = if (enabled) 2 else 1)
)
}
}
override fun onCleared() {
listeners.forEach { (id, listener) ->
controlRepo.remove(id, listener)
}
service.aacpManager.customEqCallback = null
appContext.unregisterReceiver(broadcastReceiver)
}
private fun loadName() {
val name = sharedPreferences.getString("name", "AirPods Pro")!!
_uiState.update { it.copy(deviceName = name) }
}
private fun observeBilling() {
if (isDemoMode) return
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
if (premium) {
sharedPreferences.edit {
remove("premium_expiry_time")
if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
}
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
} else {
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false)
_uiState.update { it.copy(isPremium = false) }
}
}
}
}
}
private fun observeSharedPreferences() {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) {
"name" -> loadName()
"off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd",
"head_gestures", "left_long_press_action", "right_long_press_action",
"dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences()
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
}
private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action ?: return
if (!isDemoMode) when (action) {
AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> {
_uiState.update {
it.copy(isLocallyConnected = true)
}
}
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
_uiState.update {
it.copy(isLocallyConnected = false)
}
}
AirPodsNotifications.BATTERY_DATA -> {
_uiState.update {
it.copy(battery = service.getBattery())
}
}
AirPodsNotifications.EQ_DATA -> {
val data = intent.getFloatArrayExtra("eqData") ?: floatArrayOf()
_uiState.update {
it.copy(eqData = data)
}
}
AirPodsNotifications.AIRPODS_INFORMATION_UPDATED -> {
loadInstance()
}
}
}
}
val filter = IntentFilter().apply {
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.BATTERY_DATA)
addAction(AirPodsNotifications.EQ_DATA)
addAction(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED)
}
appContext.registerReceiver(
broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED
)
}
fun setControlCommandValue(
identifier: ControlCommandIdentifiers, value: ByteArray
) {
if (!isDemoMode) controlRepo.setValue(identifier, value)
_uiState.update {
it.copy(
controlStates = it.controlStates + (identifier to value)
)
}
}
fun setControlCommandBoolean(
identifier: ControlCommandIdentifiers, enabled: Boolean
) {
setControlCommandValue(
identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
)
}
fun setControlCommandInt(
identifier: ControlCommandIdentifiers, value: Int
) {
setControlCommandValue(identifier, byteArrayOf(value.toByte()))
}
fun setControlCommandByte(
identifier: ControlCommandIdentifiers, value: Byte
) {
setControlCommandValue(identifier, byteArrayOf(value))
}
fun observeControl(identifier: ControlCommandIdentifiers) {
val listener = controlRepo.observe(identifier) { value ->
_uiState.update { state ->
val current = state.controlStates[identifier]
if (current?.contentEquals(value) == true) return@update state
if (identifier == ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE) {
state.copy(
dynamicEndOfCharge = value[0] == 0x01.toByte(),
controlStates = state.controlStates + (identifier to value)
)
} else {
state.copy(
controlStates = state.controlStates + (identifier to value)
)
}
}
}
listeners[identifier] = listener
}
// I'm lazy, sorry.
fun observeAACP() {
val identifiersList = listOf(
ControlCommandIdentifiers.MIC_MODE,
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
ControlCommandIdentifiers.LISTENING_MODE,
ControlCommandIdentifiers.AUTO_ANSWER_MODE,
ControlCommandIdentifiers.CHIME_VOLUME,
ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
ControlCommandIdentifiers.HEARING_AID,
ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
ControlCommandIdentifiers.HPS_GAIN_SWIPE,
ControlCommandIdentifiers.HEARING_ASSIST_CONFIG,
ControlCommandIdentifiers.ALLOW_OFF_OPTION,
ControlCommandIdentifiers.STEM_CONFIG,
ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG,
ControlCommandIdentifiers.ALLOW_AUTO_CONNECT,
ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
ControlCommandIdentifiers.OWNS_CONNECTION,
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE
)
for (identifier in identifiersList) {
observeControl(identifier)
}
service.aacpManager.customEqCallback = { customEq ->
_uiState.update { it.copy(customEq = customEq) }
}
}
fun loadCurrentStatus() {
if (isDemoMode) return
service.let { service ->
_uiState.update {
it.copy(
isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true,
battery = service.getBattery(),
ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1,
controlStates = controlRepo.getMap()
)
}
}
}
private fun loadSharedPreferences() {
val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true)
val automaticEarDetectionEnabled =
sharedPreferences.getBoolean("automatic_ear_detection", true)
val automaticConnectionEnabled =
sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true)
val leftAction = StemAction.valueOf(
sharedPreferences.getString(
"left_long_press_action",
"CYCLE_NOISE_CONTROL_MODES"
) ?: "CYCLE_NOISE_CONTROL_MODES"
)
val rightAction = StemAction.valueOf(
sharedPreferences.getString(
"right_long_press_action",
"CYCLE_NOISE_CONTROL_MODES"
) ?: "CYCLE_NOISE_CONTROL_MODES"
)
val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false)
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
_uiState.update {
it.copy(
offListeningMode = offListeningModeEnabled,
automaticEarDetectionEnabled = automaticEarDetectionEnabled,
automaticConnectionEnabled = automaticConnectionEnabled,
headGesturesEnabled = headGesturesEnabled,
leftAction = leftAction,
rightAction = rightAction,
vendorIdHook = vendorIdHook,
dynamicEndOfCharge = dynamicEndOfCharge,
connectionSuccessful = connectionSuccessful,
)
}
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
if (BuildConfig.PLAY_BUILD) {
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
val now = System.currentTimeMillis()
when {
// existing temporary premium
expiryTime > 0L -> {
if (expiryTime <= now) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = 0L,
isPremium = false
)
}
} else {
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = expiryTime - now,
isPremium = true
)
}
}
}
// First migration from accidental FOSS Play build
fossUpgraded && !_uiState.value.isPremium -> {
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
sharedPreferences.edit {
putLong("premium_expiry_time", newExpiry)
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = newExpiry - now,
isPremium = true
)
}
}
}
}
}
fun setOffListeningMode(enabled: Boolean) {
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled)
_uiState.update {
it.copy(offListeningMode = enabled)
}
}
fun setHeadGesturesEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("head_gestures", enabled) }
_uiState.update {
it.copy(headGesturesEnabled = enabled)
}
}
fun setDynamicEndOfCharge(enabled: Boolean) {
service.aacpManager.sendControlCommand(ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE.value, enabled)
sharedPreferences.edit { putBoolean("dynamic_end_of_charge", enabled) }
_uiState.update {
it.copy(dynamicEndOfCharge = enabled)
}
}
private fun loadEq() {
_uiState.update {
it.copy(
customEq = service.aacpManager.customEq
)
}
}
private fun loadInstance() {
val instance = service.airpodsInstance ?: AirPodsInstance(
name = "AirPods",
model = AirPodsModels.getModelByModelNumber("A3049")!!,
actualModelNumber = "A3049",
serialNumber = null,
leftSerialNumber = null,
rightSerialNumber = null,
version1 = null,
version2 = null,
version3 = null,
)
_uiState.update {
it.copy(
capabilities = instance.model.capabilities,
instance = instance,
modelName = instance.model.displayName,
actualModel = instance.actualModelNumber,
serialNumbers = listOf(
instance.serialNumber ?: "",
instance.leftSerialNumber ?: "",
instance.rightSerialNumber ?: ""
),
version1 = instance.version1 ?: "",
version2 = instance.version2 ?: "",
version3 = instance.version3 ?: ""
)
}
}
fun reconnectFromSavedMac() {
service.reconnectFromSavedMac()
}
fun setName(name: String) {
service.setName(name)
}
fun startHeadTracking() {
service.startHeadTracking()
_uiState.update { it.copy(headTrackingActive = true) }
}
fun stopHeadTracking() {
service.stopHeadTracking()
_uiState.update { it.copy(headTrackingActive = false) }
}
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
when (handle) {
// ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction.
ATTHandles.LOUD_SOUND_REDUCTION -> {
_uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01)
}
ATTHandles.HEARING_AID -> {
_uiState.value = _uiState.value.copy(hearingAidData = value)
}
ATTHandles.TRANSPARENCY -> {
_uiState.value = _uiState.value.copy(transparencyData = value)
}
}
viewModelScope.launch(Dispatchers.IO) {
try {
service.attManager.writeCharacteristic(handle, value)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun loadATT() {
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
loudSoundReduction[0].toInt() == 1
} else false
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
_uiState.update {
it.copy(
loudSoundReductionEnabled = loudSoundReductionEnabled,
transparencyData = transparencyData,
hearingAidData = hearingAidData
)
}
}
fun observeATT() {
viewModelScope.launch(Dispatchers.IO) {
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
}
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)
}
}
}
}
}
fun setAutomaticEarDetectionEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.EAR_DETECTION_CONFIG, enabled)
_uiState.update {
it.copy(
automaticEarDetectionEnabled = enabled
)
}
}
fun setAutomaticConnectionEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("automatic_connection_ctrl_cmd", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, enabled)
_uiState.update {
it.copy(
automaticConnectionEnabled = enabled
)
}
}
fun activateDemoMode() {
isDemoMode = true
_uiState.update {demoState}
}
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
}
fun setLongPressAction(side: String, action: StemAction) {
val prefKey = if (side.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
sharedPreferences.edit { putString(prefKey, action.name) }
_uiState.update {
if (side.lowercase() == "left") it.copy(leftAction = action) else it.copy(rightAction = action)
}
}
private fun countEnabledModes(byteValue: Int): Int {
var count = 0
if ((byteValue and 0x01) != 0) count++
if ((byteValue and 0x02) != 0) count++
if ((byteValue and 0x04) != 0) count++
if ((byteValue and 0x08) != 0) count++
return count
}
fun toggleListeningMode(modeBit: Int) {
val currentByte = uiState.value.controlStates[ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
val newValue = if ((currentByte and modeBit) != 0) {
val temp = currentByte and modeBit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or modeBit
}
setControlCommandByte(ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, newValue.toByte())
sharedPreferences.edit { putInt("long_press_byte", newValue) }
}
fun disconnect() {
if (isDemoMode) {
isDemoMode = false
_uiState.update {
it.copy(isLocallyConnected = false)
}
} else {
service.disconnectAirPods()
if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
Toast.makeText(
appContext, "App has disconnected, disconnect from Android Settings.",
Toast.LENGTH_LONG
).show()
}
}
}
}
@@ -1,260 +0,0 @@
package me.kavishdevar.librepods.presentation.viewmodel
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import kotlin.math.roundToInt
data class AppSettingsUiState(
val showPhoneBatteryInWidget: Boolean = false,
val conversationalAwarenessPauseMusicEnabled: Boolean = false,
val relativeConversationalAwarenessVolumeEnabled: Boolean = true,
val disconnectWhenNotWearing: Boolean = false,
val takeoverWhenDisconnected: Boolean = false,
val takeoverWhenIdle: Boolean = false,
val takeoverWhenMusic: Boolean = false,
val takeoverWhenCall: Boolean = false,
val takeoverWhenRingingCall: Boolean = false,
val takeoverWhenMediaStart: Boolean = false,
val useAlternateHeadTrackingPackets: Boolean = true,
val conversationalAwarenessVolume: Float = 43f,
val showCameraDialog: Boolean = false,
val cameraPackageValue: String = "",
val cameraPackageError: String? = null,
val vendorIdHook: Boolean = false,
val isPremium: Boolean = false,
val connectionSuccessful: Boolean = false,
val showBottomSheetPopup: Boolean = true,
val showIslandPopup: Boolean = true,
val timeUntilFOSSPremiumExpiry: Long = 0L,
val m3eEnabled: Boolean = false
)
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val _uiState = MutableStateFlow(AppSettingsUiState())
val uiState = _uiState.asStateFlow()
private val xposedRemotePref = XposedRemotePrefProvider.create()
val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key ->
if (key == "connection_successful") {
_uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) }
}
}
init {
loadSettings()
observeBilling()
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener)
}
override fun onCleared() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
}
private fun observeBilling() {
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
if (premium) {
sharedPreferences.edit {
remove("premium_expiry_time")
if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
}
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
} else {
// No billing premium, only update if no temporary premium is active
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
_uiState.update { it.copy(isPremium = false) }
}
}
}
}
}
private fun loadSettings() {
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
val now = System.currentTimeMillis()
when {
// existing temporary premium
expiryTime > 0L -> {
if (expiryTime <= now) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = 0L,
isPremium = false
)
}
} else {
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = expiryTime - now,
isPremium = true
)
}
}
}
// First migration from accidental FOSS Play build
fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
sharedPreferences.edit {
putLong("premium_expiry_time", newExpiry)
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = newExpiry - now,
isPremium = true
)
}
}
}
_uiState.update { currentState ->
currentState.copy(
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false),
takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false),
takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false),
takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false),
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", false),
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false),
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false),
showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true),
showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true),
m3eEnabled = sharedPreferences.getBoolean("m3e_enabled", true)
)
}
}
fun setShowPhoneBatteryInWidget(enabled: Boolean) {
sharedPreferences.edit { putBoolean("show_phone_battery_in_widget", enabled) }
_uiState.update { it.copy(showPhoneBatteryInWidget = enabled) }
}
fun setConversationalAwarenessPauseMusicEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled) }
_uiState.update { it.copy(conversationalAwarenessPauseMusicEnabled = enabled) }
}
fun setRelativeConversationalAwarenessVolumeEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled) }
_uiState.update { it.copy(relativeConversationalAwarenessVolumeEnabled = enabled) }
}
fun setDisconnectWhenNotWearing(enabled: Boolean) {
sharedPreferences.edit { putBoolean("disconnect_when_not_wearing", enabled) }
_uiState.update { it.copy(disconnectWhenNotWearing = enabled) }
}
fun setTakeoverWhenDisconnected(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_disconnected", enabled) }
_uiState.update { it.copy(takeoverWhenDisconnected = enabled) }
}
fun setTakeoverWhenIdle(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_idle", enabled) }
_uiState.update { it.copy(takeoverWhenIdle = enabled) }
}
fun setTakeoverWhenMusic(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_music", enabled) }
_uiState.update { it.copy(takeoverWhenMusic = enabled) }
}
fun setTakeoverWhenCall(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_call", enabled) }
_uiState.update { it.copy(takeoverWhenCall = enabled) }
}
fun setTakeoverWhenRingingCall(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", enabled) }
_uiState.update { it.copy(takeoverWhenRingingCall = enabled) }
}
fun setTakeoverWhenMediaStart(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_media_start", enabled) }
_uiState.update { it.copy(takeoverWhenMediaStart = enabled) }
}
fun setUseAlternateHeadTrackingPackets(enabled: Boolean) {
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", enabled) }
_uiState.update { it.copy(useAlternateHeadTrackingPackets = enabled) }
}
fun setConversationalAwarenessVolume(volume: Float) {
sharedPreferences.edit { putInt("conversational_awareness_volume", volume.roundToInt()) }
_uiState.update { it.copy(conversationalAwarenessVolume = volume) }
}
fun setShowCameraDialog(show: Boolean) {
_uiState.update { it.copy(showCameraDialog = show) }
}
fun setCameraPackageValue(value: String) {
_uiState.update { it.copy(cameraPackageValue = value) }
}
fun setCameraPackageError(error: String?) {
_uiState.update { it.copy(cameraPackageError = error) }
}
fun saveCameraPackage() {
if (_uiState.value.cameraPackageValue.isBlank()) {
sharedPreferences.edit { remove("custom_camera_package") }
} else {
sharedPreferences.edit { putString("custom_camera_package", _uiState.value.cameraPackageValue) }
}
setShowCameraDialog(false)
}
fun setVendorIdHook(enabled: Boolean) {
xposedRemotePref.putBoolean("vendor_id_hook", enabled)
_uiState.update { it.copy(vendorIdHook = enabled) }
}
fun setShowBottomSheetPopup(enabled: Boolean) {
sharedPreferences.edit { putBoolean("show_bottom_sheet_popup", enabled) }
_uiState.update { it.copy(showBottomSheetPopup = enabled) }
}
fun setShowIslandPopup(enabled: Boolean) {
sharedPreferences.edit { putBoolean("show_island_popup", enabled) }
_uiState.update { it.copy(showIslandPopup = enabled) }
}
fun setm3eEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("m3e_enabled", enabled) }
_uiState.update { it.copy(m3eEnabled = enabled) }
}
}
@@ -1,99 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.widgets
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.RemoteViews
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
class NoiseControlWidget : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
val views = RemoteViews(context.packageName, R.layout.noise_control_widget)
val offIntent = Intent(context, NoiseControlWidget::class.java).apply {
action = "ACTION_SET_ANC_MODE"
putExtra("ANC_MODE", 1)
}
val transparencyIntent = Intent(context, NoiseControlWidget::class.java).apply {
action = "ACTION_SET_ANC_MODE"
putExtra("ANC_MODE", 3)
}
val adaptiveIntent = Intent(context, NoiseControlWidget::class.java).apply {
action = "ACTION_SET_ANC_MODE"
putExtra("ANC_MODE", 4)
}
val ancIntent = Intent(context, NoiseControlWidget::class.java).apply {
action = "ACTION_SET_ANC_MODE"
putExtra("ANC_MODE", 2)
}
views.setOnClickPendingIntent(
R.id.widget_off_button,
PendingIntent.getBroadcast(context, 0, offIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
views.setOnClickPendingIntent(
R.id.widget_transparency_button,
PendingIntent.getBroadcast(context, 1, transparencyIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
views.setOnClickPendingIntent(
R.id.widget_adaptive_button,
PendingIntent.getBroadcast(context, 2, adaptiveIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
views.setOnClickPendingIntent(
R.id.widget_anc_button,
PendingIntent.getBroadcast(context, 3, ancIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
ServiceManager.getService()?.updateNoiseControlWidget()
appWidgetManager.updateAppWidget(appWidgetIds, views)
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == "ACTION_SET_ANC_MODE") {
val mode = intent.getIntExtra("ANC_MODE", 1)
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
val service = ServiceManager.getService()
if (service == null) {
Log.w("NoiseControlWidget", "Service unavailable")
return
}
service.aacpManager
.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
mode.toByte()
)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,76 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
/**
* Utilities for Bluetooth cryptography operations, particularly for
* verifying Resolvable Private Addresses (RPA) used by AirPods.
*/
object BluetoothCryptography {
/**
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
*
* @param addr The Bluetooth address to verify
* @param irk The Identity Resolving Key to use for verification
* @return true if the address is verified as an RPA matching the IRK
*/
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
val prand = rpa.copyOfRange(3, 6)
val hash = rpa.copyOfRange(0, 3)
val computedHash = ah(irk, prand)
return hash.contentEquals(computedHash)
}
/**
* Performs E function (AES-128) as specified in Bluetooth Core Specification
*
* @param key The key for encryption
* @param data The data to encrypt
* @return The encrypted data
*/
@SuppressLint("GetInstance")
fun e(key: ByteArray, data: ByteArray): ByteArray {
val swappedKey = key.reversedArray()
val swappedData = data.reversedArray()
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
val secretKey = SecretKeySpec(swappedKey, "AES")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
return cipher.doFinal(swappedData).reversedArray()
}
/**
* Performs the ah function as specified in Bluetooth Core Specification
*
* @param k The IRK key
* @param r The random part of the address
* @return The hash part of the address
*/
fun ah(k: ByteArray, r: ByteArray): ByteArray {
val rPadded = ByteArray(16)
r.copyInto(rPadded, 0, 0, 3)
val encrypted = e(k, rPadded)
return encrypted.copyOfRange(0, 3)
}
}
@@ -1,101 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlin.math.roundToInt
data class Orientation(val pitch: Float = 0f, val yaw: Float = 0f)
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
object HeadTracking {
private val _orientation = MutableStateFlow(Orientation())
val orientation = _orientation.asStateFlow()
private val _acceleration = MutableStateFlow(Acceleration())
val acceleration = _acceleration.asStateFlow()
private val calibrationSamples = mutableListOf<Triple<Int, Int, Int>>()
private var isCalibrated = false
private var o1Neutral = 19000
private var o2Neutral = 0
private var o3Neutral = 0
private const val CALIBRATION_SAMPLE_COUNT = 10
private const val ORIENTATION_OFFSET = 5500
fun processPacket(packet: ByteArray) {
val o1 = bytesToInt(packet[43], packet[44])
val o2 = bytesToInt(packet[45], packet[46])
val o3 = bytesToInt(packet[47], packet[48])
val horizontalAccel = bytesToInt(packet[51], packet[52]).toFloat()
val verticalAccel = bytesToInt(packet[53], packet[54]).toFloat()
if (!isCalibrated) {
calibrationSamples.add(Triple(o1, o2, o3))
if (calibrationSamples.size >= CALIBRATION_SAMPLE_COUNT) {
calibrate()
}
return
}
val orientation = calculateOrientation(o1, o2, o3)
_orientation.value = orientation
_acceleration.value = Acceleration(verticalAccel, horizontalAccel)
}
private fun calibrate() {
if (calibrationSamples.size < 3) return
// Add offset during calibration
o1Neutral = calibrationSamples.map { it.first + ORIENTATION_OFFSET }.average().roundToInt()
o2Neutral = calibrationSamples.map { it.second + ORIENTATION_OFFSET }.average().roundToInt()
o3Neutral = calibrationSamples.map { it.third + ORIENTATION_OFFSET }.average().roundToInt()
isCalibrated = true
}
@Suppress("UnusedVariable")
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
if (!isCalibrated) return Orientation()
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
val pitch = (o2Norm + o3Norm) / 2f / 32000f * 180f
val yaw = (o2Norm - o3Norm) / 2f / 32000f * 180f
return Orientation(pitch, yaw)
}
private fun bytesToInt(b1: Byte, b2: Byte): Int {
return (b2.toInt() shl 8) or (b1.toInt() and 0xFF)
}
fun reset() {
calibrationSamples.clear()
isCalibrated = false
_orientation.value = Orientation()
_acceleration.value = Acceleration()
}
}
@@ -1,761 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.content.Context
import android.util.Log
import androidx.compose.runtime.NoLiveLiterals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.services.ServiceManager
import java.io.BufferedReader
import java.io.File
import java.io.FileOutputStream
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import kotlin.io.encoding.ExperimentalEncodingApi
@NoLiveLiterals
class RadareOffsetFinder(context: Context) {
companion object {
private const val TAG = "RadareOffsetFinder"
private const val RADARE2_URL = "https://github.com/devnoname120/radare2/releases/download/5.9.8-android-aln/radare2-5.9.9-android-aarch64-aln.tar.gz"
private const val HOOK_OFFSET_PROP = "persist.librepods.hook_offset"
private const val CFG_REQ_OFFSET_PROP = "persist.librepods.cfg_req_offset"
private const val CSM_CONFIG_OFFSET_PROP = "persist.librepods.csm_config_offset"
private const val PEER_INFO_REQ_OFFSET_PROP = "persist.librepods.peer_info_req_offset"
private const val SDP_OFFSET_PROP = "persist.librepods.sdp_offset"
private const val EXTRACT_DIR = "/"
private const val RADARE2_BIN_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/bin"
private const val RADARE2_LIB_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/lib"
private const val BUSYBOX_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/busybox"
private val LIBRARY_PATHS = listOf(
"/apex/com.android.bt/lib64/libbluetooth_jni.so",
"/apex/com.android.btservices/lib64/libbluetooth_jni.so",
"/system/lib64/libbluetooth_jni.so",
"/system/lib64/libbluetooth_qti.so",
"/system_ext/lib64/libbluetooth_qti.so"
)
fun findBluetoothLibraryPath(): String? {
for (path in LIBRARY_PATHS) {
if (File(path).exists()) {
Log.d(TAG, "Found Bluetooth library at $path")
return path
}
}
Log.e(TAG, "Could not find Bluetooth library")
return null
}
fun clearHookOffsets(): Boolean {
try {
val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c",
"/system/bin/setprop $HOOK_OFFSET_PROP '' && " +
"/system/bin/setprop $CFG_REQ_OFFSET_PROP '' && " +
"/system/bin/setprop $CSM_CONFIG_OFFSET_PROP '' && " +
"/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP '' &&" +
"/system/bin/setprop $SDP_OFFSET_PROP ''"
))
val exitCode = process.waitFor()
if (exitCode == 0) {
Log.d(TAG, "Successfully cleared hook offset properties")
return true
} else {
Log.e(TAG, "Failed to clear hook offset properties, exit code: $exitCode")
}
} catch (e: Exception) {
Log.e(TAG, "Error clearing hook offset properties", e)
}
return false
}
fun clearSdpOffset(): Boolean {
try {
val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP ''"
))
val exitCode = process.waitFor()
if (exitCode == 0) {
Log.d(TAG, "Successfully cleared SDP offset property")
return true
} else {
Log.e(TAG, "Failed to clear SDP offset property, exit code: $exitCode")
}
} catch (e: Exception) {
Log.e(TAG, "Error clearing SDP offset property", e)
}
return false
}
fun isSdpOffsetAvailable(): Boolean {
val sharedPreferences = ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE) // ik not good practice- too lazy
if (sharedPreferences?.getBoolean("skip_setup", false) == true) {
Log.d(TAG, "Setup skipped, returning true for SDP offset.")
return true
}
try {
val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", SDP_OFFSET_PROP))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val propValue = reader.readLine()
process.waitFor()
if (propValue != null && propValue.isNotEmpty()) {
Log.d(TAG, "SDP offset property exists: $propValue")
return true
}
} catch (e: Exception) {
Log.e(TAG, "Error checking if SDP offset property exists", e)
}
Log.d(TAG, "No SDP offset available")
return false
}
}
private val radare2TarballFile = File(context.cacheDir, "radare2.tar.gz")
private val _progressState = MutableStateFlow<ProgressState>(ProgressState.Idle)
val progressState: StateFlow<ProgressState> = _progressState
sealed class ProgressState {
object Idle : ProgressState()
object CheckingExisting : ProgressState()
object Downloading : ProgressState()
data class DownloadProgress(val progress: Float) : ProgressState()
object Extracting : ProgressState()
object MakingExecutable : ProgressState()
object FindingOffset : ProgressState()
object SavingOffset : ProgressState()
object Cleaning : ProgressState()
data class Error(val message: String) : ProgressState()
data class Success(val offset: Long) : ProgressState()
}
fun isHookOffsetAvailable(): Boolean {
Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString())
if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) {
Log.d(TAG, "Setup skipped, returning true.")
return true
}
_progressState.value = ProgressState.CheckingExisting
try {
val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", HOOK_OFFSET_PROP))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val propValue = reader.readLine()
process.waitFor()
if (propValue != null && propValue.isNotEmpty()) {
Log.d(TAG, "Hook offset property exists: $propValue")
_progressState.value = ProgressState.Idle
return true
}
} catch (e: Exception) {
Log.e(TAG, "Error checking if offset property exists", e)
_progressState.value = ProgressState.Error("Failed to check if offset property exists: ${e.message}")
}
Log.d(TAG, "No hook offset available")
_progressState.value = ProgressState.Idle
return false
}
suspend fun setupAndFindOffset(): Boolean {
val offset = findOffset()
return offset > 0
}
suspend fun findOffset(): Long = withContext(Dispatchers.IO) {
try {
_progressState.value = ProgressState.Downloading
if (!downloadRadare2TarballIfNeeded()) {
_progressState.value = ProgressState.Error("Failed to download radare2 tarball")
Log.e(TAG, "Failed to download radare2 tarball")
return@withContext 0L
}
_progressState.value = ProgressState.Extracting
if (!extractRadare2Tarball()) {
_progressState.value = ProgressState.Error("Failed to extract radare2 tarball")
Log.e(TAG, "Failed to extract radare2 tarball")
return@withContext 0L
}
_progressState.value = ProgressState.MakingExecutable
if (!makeExecutable()) {
_progressState.value = ProgressState.Error("Failed to make binaries executable")
Log.e(TAG, "Failed to make binaries executable")
return@withContext 0L
}
_progressState.value = ProgressState.FindingOffset
val offset = findFunctionOffset()
if (offset == 0L) {
_progressState.value = ProgressState.Error("Failed to find function offset")
Log.e(TAG, "Failed to find function offset")
return@withContext 0L
}
_progressState.value = ProgressState.SavingOffset
if (!saveOffset(offset)) {
_progressState.value = ProgressState.Error("Failed to save offset")
Log.e(TAG, "Failed to save offset")
return@withContext 0L
}
_progressState.value = ProgressState.Cleaning
cleanupExtractedFiles()
_progressState.value = ProgressState.Success(offset)
return@withContext offset
} catch (e: Exception) {
_progressState.value = ProgressState.Error("Error: ${e.message}")
Log.e(TAG, "Error in findOffset", e)
return@withContext 0L
}
}
private suspend fun downloadRadare2TarballIfNeeded(): Boolean = withContext(Dispatchers.IO) {
if (radare2TarballFile.exists() && radare2TarballFile.length() > 0) {
Log.d(TAG, "Radare2 tarball already downloaded to ${radare2TarballFile.absolutePath}")
return@withContext true
}
try {
val url = URL(RADARE2_URL)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 60000
connection.readTimeout = 60000
val contentLength = connection.contentLength.toFloat()
val inputStream = connection.inputStream
val outputStream = FileOutputStream(radare2TarballFile)
val buffer = ByteArray(4096)
var bytesRead: Int
var totalBytesRead = 0L
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) {
val progress = totalBytesRead.toFloat() / contentLength
_progressState.value = ProgressState.DownloadProgress(progress)
}
}
outputStream.close()
inputStream.close()
Log.d(TAG, "Download successful to ${radare2TarballFile.absolutePath}")
return@withContext true
} catch (e: Exception) {
Log.e(TAG, "Failed to download radare2 tarball", e)
return@withContext false
}
}
private suspend fun extractRadare2Tarball(): Boolean = withContext(Dispatchers.IO) {
try {
val isAlreadyExtracted = checkIfAlreadyExtracted()
if (isAlreadyExtracted) {
Log.d(TAG, "Radare2 files already extracted correctly, skipping extraction")
return@withContext true
}
Log.d(TAG, "Removing existing extract directory")
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
Runtime.getRuntime().exec(arrayOf("su", "-c", "mkdir -p $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
Log.d(TAG, "Extracting ${radare2TarballFile.absolutePath} to $EXTRACT_DIR")
val process = Runtime.getRuntime().exec(
arrayOf("su", "-c", "tar xvf ${radare2TarballFile.absolutePath} -C $EXTRACT_DIR")
)
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "Extract output: $line")
}
while (errorReader.readLine().also { line = it } != null) {
Log.e(TAG, "Extract error: $line")
}
val exitCode = process.waitFor()
if (exitCode == 0) {
Log.d(TAG, "Extraction completed successfully")
return@withContext true
} else {
Log.e(TAG, "Extraction failed with exit code $exitCode")
return@withContext false
}
} catch (e: Exception) {
Log.e(TAG, "Failed to extract radare2", e)
return@withContext false
}
}
private suspend fun checkIfAlreadyExtracted(): Boolean = withContext(Dispatchers.IO) {
try {
val checkDirProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "[ -d $EXTRACT_DIR/data/local/tmp/aln_unzip ] && echo 'exists'")
)
val dirExists = BufferedReader(InputStreamReader(checkDirProcess.inputStream)).readLine() == "exists"
checkDirProcess.waitFor()
if (!dirExists) {
Log.d(TAG, "Extract directory doesn't exist, need to extract")
return@withContext false
}
val tarProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "tar tf ${radare2TarballFile.absolutePath}")
)
val tarFiles = BufferedReader(InputStreamReader(tarProcess.inputStream)).readLines()
.filter { it.isNotEmpty() }
.map { it.trim() }
.toSet()
tarProcess.waitFor()
if (tarFiles.isEmpty()) {
Log.e(TAG, "Failed to get file list from tarball")
return@withContext false
}
val findProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort")
)
val extractedFiles = BufferedReader(InputStreamReader(findProcess.inputStream)).readLines()
.filter { it.isNotEmpty() }
.map { it.trim() }
.toSet()
findProcess.waitFor()
if (extractedFiles.isEmpty()) {
Log.d(TAG, "No files found in extract directory, need to extract")
return@withContext false
}
for (tarFile in tarFiles) {
if (tarFile.endsWith("/")) continue
val filePathInExtractDir = "$EXTRACT_DIR/$tarFile"
val fileCheckProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "[ -f $filePathInExtractDir ] && echo 'exists'")
)
val fileExists = BufferedReader(InputStreamReader(fileCheckProcess.inputStream)).readLine() == "exists"
fileCheckProcess.waitFor()
if (!fileExists) {
Log.d(TAG, "File $filePathInExtractDir from tarball missing in extract directory")
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
return@withContext false
}
}
Log.d(TAG, "All ${tarFiles.size} files from tarball exist in extract directory")
return@withContext true
} catch (e: Exception) {
Log.e(TAG, "Error checking extraction status", e)
return@withContext false
}
}
private suspend fun makeExecutable(): Boolean = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Making binaries executable in $RADARE2_BIN_PATH")
val chmod1Result = Runtime.getRuntime().exec(
arrayOf("su", "-c", "chmod -R 755 $RADARE2_BIN_PATH")
).waitFor()
Log.d(TAG, "Making binaries executable in $BUSYBOX_PATH")
val chmod2Result = Runtime.getRuntime().exec(
arrayOf("su", "-c", "chmod -R 755 $BUSYBOX_PATH")
).waitFor()
if (chmod1Result == 0 && chmod2Result == 0) {
Log.d(TAG, "Successfully made binaries executable")
return@withContext true
} else {
Log.e(TAG, "Failed to make binaries executable, exit codes: $chmod1Result, $chmod2Result")
return@withContext false
}
} catch (e: Exception) {
Log.e(TAG, "Error making binaries executable", e)
return@withContext false
}
}
private suspend fun findFunctionOffset(): Long = withContext(Dispatchers.IO) {
val libraryPath = findBluetoothLibraryPath() ?: return@withContext 0L
var offset = 0L
try {
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim()
val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim()
val envSetup = """
export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH"
export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH"
""".trimIndent()
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep fcr_chk_chan"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 output: $line")
if (line?.contains("fcr_chk_chan") == true) {
val parts = line.split(" ")
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
offset = parts[0].substring(2).toLong(16)
Log.d(TAG, "Found offset at ${parts[0]}")
break
}
}
}
while (errorReader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 error: $line")
}
val exitCode = process.waitFor()
if (exitCode != 0) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup)
// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup)
// findAndSaveSdpOffset(libraryPath, envSetup) Should not be run by default, only when user asks for it.
} catch (e: Exception) {
Log.e(TAG, "Failed to find function offset", e)
return@withContext 0L
}
if (offset == 0L) {
Log.e(TAG, "Failed to extract function offset from output, aborting")
return@withContext 0L
}
Log.d(TAG, "Successfully found offset: 0x${offset.toString(16)}")
return@withContext offset
}
private suspend fun findAndSaveL2cuProcessCfgReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
try {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_process_our_cfg_req"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
var offset = 0L
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 output: $line")
if (line?.contains("l2cu_process_our_cfg_req") == true) {
val parts = line.split(" ")
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
offset = parts[0].substring(2).toLong(16)
Log.d(TAG, "Found l2cu_process_our_cfg_req offset at ${parts[0]}")
break
}
}
}
while (errorReader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 error: $line")
}
val exitCode = process.waitFor()
if (exitCode != 0) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $CFG_REQ_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to find or save l2cu_process_our_cfg_req offset", e)
}
}
private suspend fun findAndSaveL2cCsmConfigOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
try {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2c_csm_config"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
var offset = 0L
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 output: $line")
if (line?.contains("l2c_csm_config") == true) {
val parts = line.split(" ")
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
offset = parts[0].substring(2).toLong(16)
Log.d(TAG, "Found l2c_csm_config offset at ${parts[0]}")
break
}
}
}
while (errorReader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 error: $line")
}
val exitCode = process.waitFor()
if (exitCode != 0) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved l2c_csm_config offset: $hexString")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to find or save l2c_csm_config offset", e)
}
}
private suspend fun findAndSaveL2cuSendPeerInfoReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
try {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_send_peer_info_req"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
var offset = 0L
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 output: $line")
if (line?.contains("l2cu_send_peer_info_req") == true) {
val parts = line.split(" ")
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
offset = parts[0].substring(2).toLong(16)
Log.d(TAG, "Found l2cu_send_peer_info_req offset at ${parts[0]}")
break
}
}
}
while (errorReader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 error: $line")
}
val exitCode = process.waitFor()
if (exitCode != 0) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to find or save l2cu_send_peer_info_req offset", e)
}
}
private suspend fun findAndSaveSdpOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
try {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep DmSetLocalDiRecord"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
var offset = 0L
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 output: $line")
if (line?.contains("DmSetLocalDiRecord") == true) {
val parts = line.split(" ")
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
offset = parts[0].substring(2).toLong(16)
Log.d(TAG, "Found DmSetLocalDiRecord offset at ${parts[0]}")
break
}
}
}
while (errorReader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 error: $line")
}
val exitCode = process.waitFor()
if (exitCode != 0) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved DmSetLocalDiRecord offset: $hexString")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to find or save DmSetLocalDiRecord offset", e)
}
}
private suspend fun saveOffset(offset: Long): Boolean = withContext(Dispatchers.IO) {
try {
val hexString = "0x${offset.toString(16)}"
Log.d(TAG, "Saving offset to system property: $hexString")
val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $HOOK_OFFSET_PROP $hexString"
))
val exitCode = process.waitFor()
if (exitCode == 0) {
val verifyProcess = Runtime.getRuntime().exec(arrayOf(
"/system/bin/getprop", HOOK_OFFSET_PROP
))
val propValue = BufferedReader(InputStreamReader(verifyProcess.inputStream)).readLine()
verifyProcess.waitFor()
if (propValue != null && propValue.isNotEmpty()) {
Log.d(TAG, "Successfully saved offset to system property: $propValue")
return@withContext true
} else {
Log.e(TAG, "Property was set but couldn't be verified")
}
} else {
Log.e(TAG, "Failed to set property, exit code: $exitCode")
}
return@withContext false
} catch (e: Exception) {
Log.e(TAG, "Failed to save offset", e)
return@withContext false
}
}
private fun cleanupExtractedFiles() {
try {
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
Log.d(TAG, "Cleaned up extracted files at $EXTRACT_DIR/data/local/tmp/aln_unzip")
} catch (e: Exception) {
Log.e(TAG, "Failed to cleanup extracted files", e)
}
}
suspend fun findSdpOffset(): Boolean = withContext(Dispatchers.IO) {
try {
_progressState.value = ProgressState.Downloading
if (!downloadRadare2TarballIfNeeded()) {
_progressState.value = ProgressState.Error("Failed to download radare2 tarball")
Log.e(TAG, "Failed to download radare2 tarball")
return@withContext false
}
_progressState.value = ProgressState.Extracting
if (!extractRadare2Tarball()) {
_progressState.value = ProgressState.Error("Failed to extract radare2 tarball")
Log.e(TAG, "Failed to extract radare2 tarball")
return@withContext false
}
_progressState.value = ProgressState.MakingExecutable
if (!makeExecutable()) {
_progressState.value = ProgressState.Error("Failed to make binaries executable")
Log.e(TAG, "Failed to make binaries executable")
return@withContext false
}
_progressState.value = ProgressState.FindingOffset
val libraryPath = findBluetoothLibraryPath()
if (libraryPath == null) {
_progressState.value = ProgressState.Error("Failed to find Bluetooth library")
Log.e(TAG, "Failed to find Bluetooth library")
return@withContext false
}
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim()
val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim()
val envSetup = """
export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH"
export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH"
""".trimIndent()
findAndSaveSdpOffset(libraryPath, envSetup)
_progressState.value = ProgressState.Cleaning
cleanupExtractedFiles()
_progressState.value = ProgressState.Success(0L)
return@withContext true
} catch (e: Exception) {
_progressState.value = ProgressState.Error("Error: ${e.message}")
Log.e(TAG, "Error in findSdpOffset", e)
return@withContext false
}
}
}
@@ -1,23 +1,67 @@
package me.kavishdevar.librepods
import android.app.Application
import androidx.health.connect.client.HealthConnectClient
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.room3.Room
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.database.LibrePodsDatabase
import me.kavishdevar.librepods.repository.AppDataRepository
import me.kavishdevar.librepods.repository.AppleRepository
import me.kavishdevar.librepods.repository.HeartRateRepository
import me.kavishdevar.librepods.repository.RecordingRepository
import me.kavishdevar.librepods.repository.WidgetConfigRepository
import me.kavishdevar.librepods.utils.GestureFeedback
import me.kavishdevar.librepods.utils.XposedServiceHolder
import me.kavishdevar.librepods.utils.XposedState
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
lateinit var database: LibrePodsDatabase
private set
val appleRepository by lazy { AppleRepository(database.appleDao()) }
val appDataRepository by lazy { AppDataRepository(database.appSettingsDao(), database.appStateDao()) }
val widgetConfigRepository by lazy { WidgetConfigRepository(database.widgetConfigDao()) }
val recordingRepository by lazy { RecordingRepository(applicationContext) }
val heartRateRepository by lazy { HeartRateRepository(database.heartRateDao()) }
val healthConnectClient: HealthConnectClient? by lazy {
val status = HealthConnectClient.getSdkStatus(this)
if (status == HealthConnectClient.SDK_AVAILABLE) {
HealthConnectClient.getOrCreate(this)
} else {
null
}
}
override fun onCreate() {
System.loadLibrary("hiddenapi")
database = Room.databaseBuilder(
applicationContext,
LibrePodsDatabase::class.java,
"librepods.db"
).build()
XposedServiceHelper.registerListener(this)
runBlocking(Dispatchers.IO) {
appDataRepository.awaitInitialized()
}
BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
GestureFeedback.init(this)
super<Application>.onCreate()
}
@@ -0,0 +1,105 @@
package me.kavishdevar.librepods.audio
import android.media.MediaCodec
import android.media.MediaCodec.BufferInfo
import android.media.MediaCodecInfo
import android.media.MediaFormat
import java.nio.ByteBuffer
class EldDecoder {
companion object {
private val ASC = byteArrayOf(
0xF8.toByte(),
0xE6.toByte(),
0x30,
0x00
)
}
private val codec = MediaCodec.createDecoderByType(
MediaFormat.MIMETYPE_AUDIO_AAC
)
private val info = BufferInfo()
init {
val format = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC,
64_000,
1
)
format.setInteger(
MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectELD
)
format.setByteBuffer(
"csd-0",
ByteBuffer.wrap(ASC)
)
codec.configure(format, null, null, 0)
codec.start()
}
fun decode(
accessUnit: ByteArray,
onPcm: (ByteArray) -> Unit
) {
val input = codec.dequeueInputBuffer(10_000)
if (input >= 0) {
codec.getInputBuffer(input)?.apply {
clear()
put(accessUnit)
}
codec.queueInputBuffer(
input,
0,
accessUnit.size,
0,
0
)
}
while (true) {
val output = codec.dequeueOutputBuffer(info, 0)
when {
output >= 0 -> {
val buffer = codec.getOutputBuffer(output)!!
val pcm = ByteArray(info.size)
buffer.position(info.offset)
buffer.limit(info.offset + info.size)
buffer.get(pcm)
codec.releaseOutputBuffer(output, false)
onPcm(pcm)
}
output == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
// ignore
}
output == MediaCodec.INFO_TRY_AGAIN_LATER -> {
break
}
else -> {
break
}
}
}
}
fun close() {
codec.stop()
codec.release()
}
}
@@ -0,0 +1,72 @@
package me.kavishdevar.librepods.audio
import java.io.File
import java.io.RandomAccessFile
class WavWriter(
file: File,
private val sampleRate: Int = 64_000,
private val channels: Int = 1,
private val bitsPerSample: Int = 16
) : AutoCloseable {
private val raf = RandomAccessFile(file, "rw")
private var dataSize = 0L
init {
writeHeader()
}
fun write(pcm: ByteArray) {
raf.write(pcm)
dataSize += pcm.size
}
override fun close() {
raf.seek(4)
raf.writeIntLE((36 + dataSize).toInt())
raf.seek(40)
raf.writeIntLE(dataSize.toInt())
raf.close()
}
private fun writeHeader() {
raf.writeBytes("RIFF")
raf.writeIntLE(0)
raf.writeBytes("WAVE")
raf.writeBytes("fmt ")
raf.writeIntLE(16)
raf.writeShortLE(1)
raf.writeShortLE(channels.toShort())
raf.writeIntLE(sampleRate)
val byteRate = sampleRate * channels * bitsPerSample / 8
raf.writeIntLE(byteRate)
val blockAlign = channels * bitsPerSample / 8
raf.writeShortLE(blockAlign.toShort())
raf.writeShortLE(bitsPerSample.toShort())
raf.writeBytes("data")
raf.writeIntLE(0)
}
}
private fun RandomAccessFile.writeIntLE(value: Int) {
write(value and 0xff)
write((value ushr 8) and 0xff)
write((value ushr 16) and 0xff)
write((value ushr 24) and 0xff)
}
private fun RandomAccessFile.writeShortLE(value: Short) {
write(value.toInt() and 0xff)
write((value.toInt() ushr 8) and 0xff)
}
@@ -21,7 +21,6 @@ package me.kavishdevar.librepods.billing
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.content.edit
import androidx.core.net.toUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -31,17 +30,19 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.LibrePodsApplication
import me.kavishdevar.librepods.R
import kotlin.time.Duration.Companion.seconds
class FOSSBillingProvider(context: Context): BillingProvider {
private val _isPremium = MutableStateFlow(false)
private val appDataRepository = (context.applicationContext as LibrePodsApplication).appDataRepository
private val _isPremium = MutableStateFlow(appDataRepository.state.value.isPremium)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
override val price: StateFlow<String> = _price
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var purchaseJob: Job? = null
@@ -57,21 +58,18 @@ class FOSSBillingProvider(context: Context): BillingProvider {
purchaseJob?.cancel()
purchaseJob = scope.launch {
delay(5_000)
delay(5.seconds)
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
appDataRepository.updateState { it.copy(isPremium = true) }
}
}
override fun queryPurchases() {
val stored = sharedPreferences.getBoolean("foss_upgraded", false)
if (stored != _isPremium.value) {
_isPremium.value = stored
}
_isPremium.value = appDataRepository.state.value.isPremium
}
override fun restorePurchases() {
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
appDataRepository.updateState { it.copy(isPremium = true) }
}
}
@@ -41,6 +41,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.LibrePodsApplication
const val TAG = "PlayBillingProvider"
@@ -49,10 +50,11 @@ private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
class PlayBillingProvider(
context: Context
) : BillingProvider, PurchasesUpdatedListener {
private val appDataRepository = (context.applicationContext as LibrePodsApplication).appDataRepository
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _isPremium = MutableStateFlow(false)
private val _isPremium = MutableStateFlow(appDataRepository.state.value.isPremium)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("unknown")
@@ -177,6 +179,8 @@ class PlayBillingProvider(
_isPremium.value = hasPremium
appDataRepository.updateState { it.copy(isPremium = hasPremium) }
scope.launch {
purchases
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
@@ -0,0 +1,149 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
import kotlinx.serialization.Serializable
import me.kavishdevar.librepods.utils.redactMac
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
fun createBluetoothSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
arrayOf(device, type, true, true, psm, uuid),
arrayOf(device, type, 1, true, true, psm, uuid),
arrayOf(type, 1, true, true, device, psm, uuid),
arrayOf(type, true, true, device, psm, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("createSocket<$psm>", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("createSocket<$psm>", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("createSocket<$psm>", "Trying constructor signature #${index + 1}")
attemptedConstructors++
val paramTypes =
params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) {
Log.e("createSocket<$psm>", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
}
}
val errorMessage =
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("createSocket<$psm>", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
}
/**
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
*
* @param addr The Bluetooth address to verify
* @param irk The Identity Resolving Key to use for verification
* @return true if the address is verified as an RPA matching the IRK
*/
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
val prand = rpa.copyOfRange(3, 6)
val hash = rpa.copyOfRange(0, 3)
val computedHash = ah(irk, prand)
return hash.contentEquals(computedHash)
}
/**
* Performs E function (AES-128) as specified in Bluetooth Core Specification
*
* @param key The key for encryption
* @param data The data to encrypt
* @return The encrypted data
*/
@SuppressLint("GetInstance")
fun e(key: ByteArray, data: ByteArray): ByteArray {
val swappedKey = key.reversedArray()
val swappedData = data.reversedArray()
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
val secretKey = SecretKeySpec(swappedKey, "AES")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
return cipher.doFinal(swappedData).reversedArray()
}
/**
* Performs the ah function as specified in Bluetooth Core Specification
*
* @param k The IRK key
* @param r The random part of the address
* @return The hash part of the address
*/
fun ah(k: ByteArray, r: ByteArray): ByteArray {
val rPadded = ByteArray(16)
r.copyInto(rPadded, 0, 0, 3)
val encrypted = e(k, rPadded)
return encrypted.copyOfRange(0, 3)
}
@JvmInline
@Serializable
value class MacAddress(val value: String) {
init {
require(value.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
"Invalid MAC address format: $value"
}
}
override fun toString(): String {
return value
}
fun toRedactedString(): String {
return value.redactMac()
}
fun toNotificationId(): Int {
return value
.replace(":", "")
.takeLast(8)
.toUInt(16)
.toInt()
}
}
@@ -0,0 +1,122 @@
package me.kavishdevar.librepods.bluetooth.aacp
import android.util.Log
import com.google.protobuf.ByteString
import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.RTBuddyPacket
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceSetting
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
import me.kavishdevar.librepods.devices.PacketDestination
import kotlin.time.Duration
class RTBuddyManager(
private val sendPacket: (AACPPacket) -> Boolean,
) {
companion object {
private const val TAG = "RTBuddyManager"
}
private var sequence = 0
private fun nextSequence(): Int {
val next = sequence
sequence = (sequence + 1) and 0x7FFFFFFF
return next
}
private fun observeSequence(data: SensorDataWX) {
Log.d(TAG, "Received SensorDataWX seq=${data.seq}")
}
fun handlePacket(packet: RTBuddyPacket) {
when (val payload = packet.rtBuddyPayload) {
is SensorDataWxBuddyPayload -> {
val data = payload.data
observeSequence(data)
// handleSensorData(data)
}
else -> {
Log.d(
TAG,
"Unhandled RTBuddy payload: ${payload.descriptor}"
)
}
}
}
// private fun handleSensorData(data: SensorDataWX) {
// if (data.hasServiceSettings()) {
// when (data.serviceSettings.service) {
// SensorServiceType.ACTIVITY,
// SensorServiceType.DEVMOTION6 -> {
// // Head tracking data/configuration
// }
//
// SensorServiceType.HEARTRATE -> {
// // Heart rate
// }
//
// else -> {
// Log.d(
// TAG,
// "Unhandled sensor service: ${data.serviceSettings.service}"
// )
// }
// }
// }
// }
fun sendSensorData(
data: SensorDataWX,
): Boolean {
val dataWithSequence = data.toBuilder()
.setSeq(nextSequence())
.build()
val packet = RTBuddyPacket.create(
rtBuddyPayload = SensorDataWxBuddyPayload(
data = dataWithSequence,
),
destination = PacketDestination.DEVICE,
)
return sendPacket(packet)
}
private fun sendSensorServiceSetting(
service: SensorServiceType,
configuration: ByteArray,
): Boolean {
val data = SensorDataWX.newBuilder()
.setServiceSettings(
SensorServiceSetting.newBuilder()
.setService(service)
.setSetting(2)
.setConfiguration(
ByteString.copyFrom(configuration)
)
)
.build()
return sendSensorData(data)
}
fun setSensorServiceReportInterval(sensorServiceType: SensorServiceType, interval: Duration): Boolean = sendSensorServiceSetting(
service = sensorServiceType,
configuration = byteArrayOf(0x01) + interval.toMicrosUINT32LE()
)
}
private fun Duration.toMicrosUINT32LE(): ByteArray {
val microseconds = this.inWholeMicroseconds
return byteArrayOf(
microseconds.toByte(),
(microseconds shr 8).toByte(),
(microseconds shr 16).toByte(),
(microseconds shr 24).toByte()
)
}
@@ -0,0 +1,69 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode
import me.kavishdevar.librepods.devices.PacketDestination
data class AACPPacketType(val value: Byte) {
companion object {
val CONNECT = AACPPacketType(0x00)
val CONNECT_RESPONSE = AACPPacketType(0x01)
val DISCONNECT = AACPPacketType(0x02)
val DISCONNECT_RESPONSE = AACPPacketType(0x03)
val MESSAGE = AACPPacketType(0x04)
fun fromByte(value: Byte): AACPPacketType {
return when (value) {
0x00.toByte() -> CONNECT
0x01.toByte() -> CONNECT_RESPONSE
0x02.toByte() -> DISCONNECT
0x03.toByte() -> DISCONNECT_RESPONSE
0x04.toByte() -> MESSAGE
else -> AACPPacketType(value)
}
}
}
}
sealed interface AACPPacket {
/**
* The entire packet; contains the opcode and header
*/
val rawPacket: ByteArray
get() = byteArrayOf(type.value, 0x00, service, 0x00, opcode.value, 0x00) + payload
val destination: PacketDestination
val type: AACPPacketType
val service: Byte
val opcode: Opcode
val payload: ByteArray
companion object {
fun createUnknownPacket(
opcode: Opcode,
payload: ByteArray,
destination: PacketDestination = PacketDestination.DEVICE,
type: AACPPacketType = AACPPacketType.MESSAGE,
service: Byte = 0x04,
): AACPPacket {
return UnknownAACPPacket(
service = service,
type = type,
opcode = opcode,
payload = payload,
destination = destination
)
}
}
}
data class UnknownAACPPacket (
override val service: Byte = 0x04,
override val type: AACPPacketType = AACPPacketType.MESSAGE,
override val opcode: Opcode,
override val payload: ByteArray,
override val destination: PacketDestination
): AACPPacket
@@ -0,0 +1,39 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSource
import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSourceType
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.devices.PacketDestination
data class AudioSourcePacket(
val audioSource: AudioSource,
override val payload: ByteArray,
): AACPPacket {
override val destination: PacketDestination = PacketDestination.HOST
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode = MessageOpcode.STEM_PRESS
companion object {
fun parse(
packet: ByteArray,
): AudioSourcePacket {
if (packet.size < 9) {
throw IllegalArgumentException("Data array too short to parse Audio Source Response")
}
val payload = packet.copyOfRange(6, packet.size)
val macAddress = MacAddress(payload.sliceArray(0..5).toHexString(HexFormat{ upperCase = true }).chunked(2).joinToString(":"))
val typeByte = payload[6]
val type = AudioSourceType.fromByte(typeByte)
val audioSource = AudioSource(macAddress, type)
return AudioSourcePacket(audioSource, payload)
}
}
}
@@ -0,0 +1,52 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import android.util.Log
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode
import me.kavishdevar.librepods.devices.Battery
import me.kavishdevar.librepods.devices.BatteryComponent
import me.kavishdevar.librepods.devices.BatteryStatus
import me.kavishdevar.librepods.devices.PacketDestination
private const val TAG = "BatteryInfoPacket"
data class BatteryInfoPacket(
val batteries: Set<Battery>,
override val payload: ByteArray,
): AACPPacket {
override val destination: PacketDestination = PacketDestination.HOST
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode: Opcode = MessageOpcode.BATTERY_INFO
companion object {
fun parse(
packet: ByteArray,
): BatteryInfoPacket {
val payload = packet.sliceArray(6 until packet.size)
var offset = 0
val batteryCount = payload[offset].toInt()
val batteries = mutableSetOf<Battery>()
offset += 1
for (i in 0 until batteryCount) {
val componentByte = payload[offset]
val component = BatteryComponent.fromAirPodsByte(componentByte)
val levelByte = payload[offset + 2]
val level = levelByte.toInt()
val statusByte = payload[offset + 3]
val status = BatteryStatus.fromAirPodsByte(statusByte)
Log.i(TAG, "parsed battery#$i: component=${component.name}, level=$level, status=${status.name}")
batteries.add(Battery(component, level, status))
offset += 5
}
Log.i(TAG, "parsed Battery Info: $batteries")
return BatteryInfoPacket(batteries, payload)
}
}
}
@@ -0,0 +1,59 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import android.util.Log
import me.kavishdevar.librepods.bluetooth.aacp.types.ConnectedDevice
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.devices.PacketDestination
private const val TAG = "ConnectedDevicesPacket"
data class ConnectedDevicesPacket(
val connectedDevices: List<ConnectedDevice>,
override val payload: ByteArray,
): AACPPacket {
override val destination: PacketDestination = PacketDestination.HOST
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode = MessageOpcode.CONNECTED_DEVICES
companion object {
fun parse(
packet: ByteArray,
): ConnectedDevicesPacket {
if (packet.size < 8) {
throw IllegalArgumentException("Data array too short to parse Connected Devices Response")
}
val payload = packet.copyOfRange(6, packet.size)
val deviceCount = payload[2].toInt()
val devices = mutableListOf<ConnectedDevice>()
var offset = 3
for (i in 0 until deviceCount) {
if (offset + 8 > payload.size) {
Log.w(
TAG,
"Data array too short to parse all connected devices, returning what we have"
)
break
}
val macAddress = MacAddress(payload.sliceArray(offset until offset + 6).toHexString(HexFormat{ upperCase = true }).chunked(2).joinToString(":"))
val info1 = payload[offset + 6]
val info2 = payload[offset + 7]
val existingDevice = devices.find { it.macAddress == macAddress }
devices.add(ConnectedDevice(macAddress, info1, info2, existingDevice?.type))
offset += 8
}
return ConnectedDevicesPacket(devices, payload)
}
}
}
@@ -0,0 +1,62 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommand
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.devices.PacketDestination
data class ControlCommandPacket(
val controlCommand: ControlCommand,
override val destination: PacketDestination,
): AACPPacket {
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode = MessageOpcode.CONTROL_COMMAND
override val payload: ByteArray = controlCommand.toAACPPayload()
companion object {
fun parse(
data: ByteArray,
destination: PacketDestination = PacketDestination.HOST
): ControlCommandPacket {
val payload = if (data.toHexString().startsWith("040004000900")) {
data.copyOfRange(6, data.size)
} else data
val controlCommand = ControlCommand.fromAACPPayload(payload)
return ControlCommandPacket(controlCommand, destination)
}
fun create(
controlCommand: ControlCommand,
destination: PacketDestination = PacketDestination.DEVICE
): ControlCommandPacket {
return ControlCommandPacket(
controlCommand = controlCommand,
destination = destination,
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ControlCommandPacket
if (controlCommand != other.controlCommand) return false
if (destination != other.destination) return false
if (!rawPacket.contentEquals(other.rawPacket)) return false
return true
}
override fun hashCode(): Int {
var result = controlCommand.hashCode()
result = 31 * result + destination.hashCode()
result = 31 * result + rawPacket.contentHashCode()
return result
}
}
@@ -0,0 +1,50 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.devices.PacketDestination
data class CustomEqPacket(
val customEq: CustomEq,
override val destination: PacketDestination
): AACPPacket {
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode = MessageOpcode.CONTROL_COMMAND
override val payload: ByteArray = customEq.toAACPPayload()
companion object {
fun parse(
packet: ByteArray,
destination: PacketDestination = PacketDestination.HOST
): CustomEqPacket {
val payload = packet.copyOfRange(6, packet.size)
val length = payload[0].toInt()
require(length == 5) { "Invalid length for CustomEqPacket: $length" }
val state = payload[3].toInt()
val low = payload[4].toInt()
val mid = payload[5].toInt()
val high = payload[6].toInt()
val customEq = CustomEq(state, low, mid, high)
return CustomEqPacket(customEq, destination)
}
// TODO: not all customeq messages are sending custom eq. there is one to request the current custom eq settings.
// this AACPPacket class only supports sending/receiving the custom eq settings, not other messages.
fun create(
customEq: CustomEq,
destination: PacketDestination = PacketDestination.DEVICE
): CustomEqPacket {
return CustomEqPacket(
customEq = customEq,
destination = destination,
)
}
}
}
@@ -0,0 +1,79 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import android.util.Log
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.devices.ComponentStatus
import me.kavishdevar.librepods.devices.DeviceComponent
import me.kavishdevar.librepods.devices.DeviceComponentState
import me.kavishdevar.librepods.devices.PacketDestination
private const val TAG = "EarDetectionResponsePacket"
data class EarDetectionResponsePacket(
val componentStates: Set<DeviceComponentState>,
val isLeftPrimary: Boolean
): AACPPacket {
override val destination: PacketDestination = PacketDestination.HOST
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode = MessageOpcode.EAR_DETECTION
override val payload: ByteArray = byteArrayOf(
componentStates.first { it.component == DeviceComponent.LEFT }.status.toAirPodsByte(),
componentStates.first { it.component == DeviceComponent.RIGHT }.status.toAirPodsByte()
).let {
if (isLeftPrimary) it else it.reversedArray()
}
companion object {
fun parse(
packet: ByteArray,
isLeftPrimary: Boolean
): EarDetectionResponsePacket {
val payload = packet.copyOfRange(6, packet.size)
Log.d(
TAG,
"parsing Ear Detection Response: ${packet.joinToString(" ") { "%02X".format(it) }}"
)
val primaryStatus = payload[0]
val secondaryStatus = payload[1]
val componentStates = mutableSetOf<DeviceComponentState>()
if (isLeftPrimary) {
componentStates.addAll(
listOf(
DeviceComponentState(
DeviceComponent.LEFT,
ComponentStatus.fromAirPodsByte(primaryStatus)
),
DeviceComponentState(
DeviceComponent.RIGHT,
ComponentStatus.fromAirPodsByte(secondaryStatus)
)
)
)
} else {
componentStates.addAll(
listOf(
DeviceComponentState(
DeviceComponent.LEFT,
ComponentStatus.fromAirPodsByte(secondaryStatus)
),
DeviceComponentState(
DeviceComponent.RIGHT,
ComponentStatus.fromAirPodsByte(primaryStatus)
)
)
)
}
Log.i(TAG, "parsed Ear Detection Response: $componentStates")
return EarDetectionResponsePacket(componentStates, isLeftPrimary)
}
}
}
@@ -0,0 +1,71 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import android.util.Log
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.devices.AirPodsModel
import me.kavishdevar.librepods.devices.AirPodsSpecs
import me.kavishdevar.librepods.devices.AppleMetadata
import me.kavishdevar.librepods.devices.PacketDestination
private const val TAG = "InformationPacket"
data class InformationPacket(
val metadata: AppleMetadata,
override val payload: ByteArray
): AACPPacket {
override val destination: PacketDestination = PacketDestination.HOST
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode = MessageOpcode.INFORMATION
companion object {
fun parse(
packet: ByteArray,
): InformationPacket {
val payload = packet.copyOfRange(6, packet.size)
var index = 0
while (index < payload.size && payload[index] != 0x00.toByte()) index++
val strings = mutableListOf<String>()
while (index < payload.size) {
// skip 0x00 bytes
while (index < payload.size && payload[index] == 0x00.toByte()) index++
if (index >= payload.size) break
val start = index
// find next 0x00 byte
while (index < payload.size && payload[index] != 0x00.toByte()) index++
val str = payload.sliceArray(start until index).decodeToString()
strings.add(str)
}
Log.i(TAG, "parse: strings: $strings")
strings.removeAt(0)
val model = AirPodsModel.fromModelNumber(strings.getOrNull(1)?: "A3063")
return InformationPacket(
metadata = AppleMetadata(
name = strings.getOrNull(0) ?: "",
model = model,
modelName = AirPodsSpecs.getSpec(model).displayName,
modelNumber = strings.getOrNull(1) ?: "",
manufacturer = strings.getOrNull(2) ?: "",
serialNumber = strings.getOrNull(3) ?: "",
leftSerialNumber = strings.getOrNull(8) ?: "",
rightSerialNumber = strings.getOrNull(9) ?: "",
version1 = strings.getOrNull(4) ?: "",
version2 = strings.getOrNull(5) ?: "",
version3 = strings.getOrNull(10) ?: "",
hardwareRevision = strings.getOrNull(6) ?: "",
updaterIdentifier = strings.getOrNull(7) ?: ""
),
payload = payload
)
}
}
}
@@ -0,0 +1,55 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import android.util.Log
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.devices.PacketDestination
private const val TAG = "MagicKeyResponsePacket"
data class MagicKeyResponsePacket(
val magicKeys: Map<MagicKeyType, ByteArray>,
override val payload: ByteArray,
): AACPPacket {
override val destination: PacketDestination = PacketDestination.HOST
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode = MessageOpcode.MAGIC_KEYS_RESPONSE
companion object {
fun parse(
packet: ByteArray,
): MagicKeyResponsePacket {
val payload = packet.copyOfRange(6, packet.size)
val keyCount = payload[0].toInt()
val keys = mutableMapOf<MagicKeyType, ByteArray>()
var offset = 1
for (i in 0 until keyCount) {
Log.d(TAG, "Parsing Proximity Key $i")
if (offset + 3 >= payload.size) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
val keyType = payload[offset]
val keyLength = payload[offset + 2].toInt()
Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength")
offset += 4
if (offset + keyLength > payload.size) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
val key = ByteArray(keyLength)
System.arraycopy(payload, offset, key, 0, keyLength)
try {
keys[MagicKeyType.fromByte(keyType)] = key
} catch (e: Exception) {
Log.e(TAG, "incorrect key type received: $keyType, ${key.toHexString()}", e)
}
offset += keyLength
Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.toHexString()}")
}
return MagicKeyResponsePacket(keys, payload)
}
}
}
@@ -0,0 +1,121 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyPayload
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
import me.kavishdevar.librepods.bluetooth.aacp.types.UnknownBuddyPayload
import me.kavishdevar.librepods.devices.PacketDestination
data class RTBuddyPacket(
val rtBuddyPayload: RTBuddyPayload,
override val payload: ByteArray,
override val destination: PacketDestination,
) : AACPPacket {
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode: Opcode = MessageOpcode.BUDDY_COMMAND
companion object {
fun parse(
data: ByteArray,
destination: PacketDestination = PacketDestination.HOST,
): RTBuddyPacket {
val payload = if (
data.size >= 6 &&
data[0] == AACPPacketType.MESSAGE.value &&
data[4] == MessageOpcode.BUDDY_COMMAND.value
) {
data.copyOfRange(6, data.size)
} else {
data
}
require(payload.size >= 6) {
"RTBuddy packet is too short: ${payload.size}"
}
val descriptorValue =
(payload[0].toUInt() and 0xFFu) or
((payload[1].toUInt() and 0xFFu) shl 8) or
((payload[2].toUInt() and 0xFFu) shl 16) or
((payload[3].toUInt() and 0xFFu) shl 24)
val length =
(payload[4].toInt() and 0xFF) or
((payload[5].toInt() and 0xFF) shl 8)
require(payload.size >= 6 + length) {
"RTBuddy payload truncated: expected $length bytes, got ${payload.size - 6}"
}
val descriptor = RTBuddyDescriptor.fromValue(descriptorValue)
val data = payload.copyOfRange(6, 6 + length)
val rtBuddyPayload: RTBuddyPayload = when (descriptor) {
RTBuddyDescriptor.SENSOR_DATA_WX -> {
SensorDataWxBuddyPayload(
data = SensorDataWX.parseFrom(data),
)
}
else -> {
UnknownBuddyPayload(
descriptor = descriptor,
descriptorValue = descriptorValue,
data = data,
)
}
}
return RTBuddyPacket(
rtBuddyPayload = rtBuddyPayload,
payload = payload,
destination = destination,
)
}
fun create(
rtBuddyPayload: RTBuddyPayload,
destination: PacketDestination = PacketDestination.DEVICE,
): RTBuddyPacket {
val data = when (rtBuddyPayload) {
is SensorDataWxBuddyPayload ->
rtBuddyPayload.data.toByteArray()
is UnknownBuddyPayload ->
rtBuddyPayload.data
}
val descriptorValue = when (rtBuddyPayload) {
is SensorDataWxBuddyPayload ->
rtBuddyPayload.descriptor.value
is UnknownBuddyPayload ->
rtBuddyPayload.descriptorValue
}
require(data.size <= 0xFFFF) {
"RTBuddy payload too large: ${data.size}"
}
val payload = byteArrayOf(
(descriptorValue and 0xFFu).toByte(),
((descriptorValue shr 8) and 0xFFu).toByte(),
((descriptorValue shr 16) and 0xFFu).toByte(),
((descriptorValue shr 24) and 0xFFu).toByte(),
(data.size and 0xFF).toByte(),
((data.size shr 8) and 0xFF).toByte(),
) + data
return RTBuddyPacket(
rtBuddyPayload = rtBuddyPayload,
payload = payload,
destination = destination,
)
}
}
}
@@ -0,0 +1,28 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.devices.PacketDestination
data class RenamePacket(
val name: String,
override val destination: PacketDestination = PacketDestination.DEVICE,
): AACPPacket {
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode = MessageOpcode.RENAME
override val payload: ByteArray = byteArrayOf(0x01, name.length.toByte()) + name.toByteArray() + byteArrayOf(0x00)
companion object {
fun create(
name: String,
destination: PacketDestination = PacketDestination.DEVICE
): RenamePacket {
return RenamePacket(
name = name,
destination = destination,
)
}
}
}
@@ -0,0 +1,36 @@
package me.kavishdevar.librepods.bluetooth.aacp.packet
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressBud
import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressType
import me.kavishdevar.librepods.devices.PacketDestination
data class StemPressPacket(
val stemPressBud: StemPressBud,
val stemPressType: StemPressType,
override val payload: ByteArray,
): AACPPacket {
override val destination: PacketDestination = PacketDestination.HOST
override val type: AACPPacketType = AACPPacketType.MESSAGE
override val service: Byte = 0x04
override val opcode = MessageOpcode.STEM_PRESS
companion object {
fun parse(
packet: ByteArray,
): StemPressPacket {
if (packet.size != 8) {
throw IllegalArgumentException("Data array too short to parse Stem Press Response")
}
val payload = packet.copyOfRange(6, packet.size)
val bud = StemPressBud.fromByte(payload[1])?: throw IllegalArgumentException("Invalid bud value: ${payload[1]}")
val type = StemPressType.fromByte(payload[0])?: throw IllegalArgumentException("Invalid type value: ${payload[0]}")
return StemPressPacket(bud, type, payload)
}
}
}
@@ -0,0 +1,20 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
import me.kavishdevar.librepods.bluetooth.MacAddress
enum class AudioSourceType(val value: Byte) {
NONE(0x00),
CALL(0x01),
MEDIA(0x02),
UNKNOWN_1(0x04),
UNKNOWN_2(0x06),
UNKNOWN(-1);
companion object {
fun fromByte(byte: Byte): AudioSourceType = entries.find { it.value == byte }?: UNKNOWN
}
}
data class AudioSource(
val mac: MacAddress, val type: AudioSourceType
)
@@ -0,0 +1,70 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
import kotlinx.serialization.Serializable
// TODO: use a map instead, this is inefficient
@Serializable
data class CapabilityEntry(
val capability: Capability,
val value: ByteArray
)
@Serializable
enum class Capability(
val value: Byte,
val valueSize: Int = 1
) {
UNKNOWN_01(0x01),
UNKNOWN_02(0x02),
UNKNOWN_03(0x03, 4),
SELECTIVE_SPEECH_LISTENING(0x04, 4),
ENHANCED_TRANSPARENCY_VERSION(0x06, 4),
UNKNOWN_07(0x07, 4),
UNKNOWN_09(0x09),
UNKNOWN_0A(0x0A),
UNKNOWN_0B(0x0B),
UNKNOWN_0F(0x0F),
UNKNOWN_10(0x10),
PERSONAL_MEDICAL_EQUIPMENT(0x11),
CASE_SOUND(0x12),
HIDE_OFF_LISTENING_MODE(0x13),
UNKNOWN_14(0x14),
SIRI_MULTITONE(0x15),
HIDE_EAR_DETECTION(0x16),
EAR_TIP_FIT_TEST(0x17),
AUTO_ANC(0x18),
UNKNOWN_PAUSE_MEDIA_ON_SLEEP(0x19),
WIRED_LOSSLESS_AUDIO(0x20),
SLEEP_DETECTION(0x21),
HEARING_AID(0x22),
CAMERA_CONTROL(0x23),
OVAD_STREAMING(0x24),
FAR_FIELD_UPLINK(0x25),
HEART_RATE_MONITOR(0x26),
HEARING_PROTECTION_PPE(0x28),
DYNAMIC_END_OF_CHARGE(0x29),
HEARING_PROTECTION(0x30, 4),
HEARING_AID_V2(0x31),
UNKNOWN_WIRED_LOSSLESS_AUDIO_2(0x34),
SMART_ROUTING_VERSION(0x35),
FAR_FIELD_UPLINK_MODERN(0x36),
PREFERENCE_EQ(0x37),
EXTENDED_CLICK_HOLD(0x38),
UNKNOWN_SPATIAL_AUDIO_SUPPORT(0x40),
UNKNOWN_CALL_MANAGEMENT(0x50),
UNKNOWN_60(0x60),
UNKNOWN_ADAPTIVE_VOLUME(0x90.toByte()),
UNKNOWN_A0(0xA0.toByte()),
UNKNOWN_AUTO_ANC_2(0xB0.toByte()),
UNKNOWN_HEARING_AID_2(0xC0.toByte()),
HEARING_TEST(0xD0.toByte()),
UNKNOWN_SENSOR_DATA_2(0xE0.toByte()),
BOBBLE(0xF0.toByte());
companion object {
private val map = entries.associateBy(Capability::value)
fun fromByte(value: Byte): Capability? = map[value]
}
}
@@ -0,0 +1,7 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
import me.kavishdevar.librepods.bluetooth.MacAddress
data class ConnectedDevice(
val macAddress: MacAddress, val info1: Byte, val info2: Byte, var type: String?
)
@@ -0,0 +1,115 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
data class ControlCommand(
val identifier: ControlCommandIdentifier, val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ControlCommand
if (identifier != other.identifier) return false
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
var result: Int = identifier.value.toInt()
result = 31 * result + value.contentHashCode()
return result
}
companion object {
fun fromAACPPayload(data: ByteArray): ControlCommand {
val identifier = ControlCommandIdentifier.fromByte(data[0])
?: throw IllegalArgumentException("Unknown ControlCommandIdentifier: ${data[0].toHexString()}")
val value = data.copyOfRange(1, data.size)
val trimmed = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
return ControlCommand(identifier, if (trimmed.isEmpty()) byteArrayOf(0x00) else trimmed)
}
}
fun toAACPPayload(): ByteArray {
val payload = ByteArray(5)
payload[0] = this.identifier.value
System.arraycopy(this.value, 0, payload, 1, this.value.size.coerceAtMost(4))
return payload
}
}
enum class ControlCommandIdentifier(val value: Byte) {
MIC_MODE(0x01),
SCAN(0x02),
RESET(0x03),
BASIC_DOUBLE_TAP_MODE(0x04),
BUTTON_SEND_MODE(0x05),
OWNS_CONNECTION(0x06),
TAP_INTERVAL(0x07),
BUD_ROLE(0x08),
DEBUG_GET_DATA(0x09),
EAR_DETECTION_CONFIG(0x0A),
JITTER_BUFFER(0x0B),
DOUBLE_TAP_MODE(0x0C),
LISTENING_MODE(0x0D),
HEART_RATE_MONITOR_1(0x0E),
HEART_RATE_MONITOR_2(0x0F),
UNKNOWN10(0x10),
SWITCH_CONTROL(0x11),
VOICE_TRIGGER(0x12),
DOAP_MODE(0x13),
SINGLE_CLICK_MODE(0x14),
DOUBLE_CLICK_MODE(0x15),
CLICK_HOLD_MODE(0x16),
DOUBLE_CLICK_INTERVAL(0x17),
CLICK_HOLD_INTERVAL(0x18),
UNKNOWN19(0x19),
LISTENING_MODE_CONFIGS(0x1A),
ONE_BUD_ANC_MODE(0x1B),
CROWN_ROTATION_DIRECTION(0x1C),
UNKNOWN1D(0x1D),
AUTO_ANSWER_MODE(0x1E),
CHIME_VOLUME(0x1F),
SMART_ROUTING_MODE(0x20),
UNKNOWN21(0x21),
HFP_UPLINK_MODE(0x22),
VOLUME_SWIPE_INTERVAL(0x23),
CALL_MANAGEMENT_CONFIG(0x24),
VOLUME_SWIPE_MODE(0x25),
ADAPTIVE_VOLUME_CONFIG(0x26),
SOFTWARE_MUTE_CONFIG(0x27),
CONVERSATION_DETECT_CONFIG(0x28),
SSL(0x29),
UNKNOWN2A(0x2A),
UNKNOWN2B(0x2B),
HEARING_AID(0x2C),
UNKNOWN2D(0x2D),
AUTO_ANC_STRENGTH(0x2E),
HPS_GAIN_SWIPE(0x2F),
HRM_STATE(0x30),
IN_CASE_TONE_CONFIG(0x31),
SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34),
SLEEP_DETECTION_CONFIG(0x35),
ALLOW_AUTO_CONNECT(0x36),
PPE_TOGGLE_CONFIG(0x37),
PPE_CAP_LEVEL_CONFIG(0x38),
RAW_GESTURES_CONFIG(0x39),
ALLOW_TEMPORARY_MANAGED_PAIRING(0x3A),
DYNAMIC_END_OF_CHARGE(0x3B),
SYSTEM_SIRI_MODE(0x3C),
HEARING_AID_GENERIC(0x3D),
UPLINK_EQ_BUD(0x3E),
UPLINK_EQ_SOURCE(0x3F),
IN_CASE_TONE_VOLUME(0x40),
DISABLE_BUTTON_INPUT(0x41),
EXTENDED_HOLD_AND_RELEASE(0x42);
companion object {
fun fromByte(byte: Byte): ControlCommandIdentifier? = entries.find { it.value == byte }
}
}
@@ -1,18 +1,21 @@
package me.kavishdevar.librepods.data
package me.kavishdevar.librepods.bluetooth.aacp.types
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlinx.serialization.Serializable
enum class CustomEqBand { LOW, MID, HIGH }
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
@Serializable
data class CustomEq(
val state: Int,
val low: Int,
val mid: Int,
val high: Int
) {
fun isEnabled(): Boolean {
return state == 2
}
fun toPacket(): ByteArray {
fun toAACPPayload(): ByteArray {
return byteArrayOf(
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
0x05, 0x00, // length (LE)
0x01, state.toByte(),
low.toByte(), mid.toByte(), high.toByte()
@@ -0,0 +1,21 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
sealed interface AppleEvent {
data class StemPress(
val pressType: StemPressType,
val bud: StemPressBud
): AppleEvent
data class ShowNearbyUi(
val sender: String
): AppleEvent
data class OwnershipToFalseRequest(
val sender: String,
val reverseTapped: Boolean
): AppleEvent
data class HeadGesturesResult(
val yes: Boolean,
)
}
@@ -0,0 +1,10 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
enum class MagicKeyType(val value: Byte) {
IRK(0x01), ENC_KEY(0x04);
companion object {
fun fromByte(byte: Byte): MagicKeyType = entries.find { it.value == byte }
?: throw IllegalArgumentException("Unknown MagicKeyType: $byte")
}
}
@@ -0,0 +1,91 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
sealed interface Opcode { val value: Byte }
data class UnknownOpcode(override val value: Byte): Opcode
enum class ConnectOpcode(override val value: Byte): Opcode {
SOMETHING (0x01);
companion object {
fun fromByte(value: Byte): Opcode =
entries.firstOrNull { it.value == value }?: UnknownOpcode(value)
}
}
enum class MessageOpcode(override val value: Byte): Opcode {
CAPABILITIES_REQUEST(0x01),
CAPABILITIES(0x02),
BATTERY_INFO_REQUEST(0x03),
BATTERY_INFO(0x04),
EAR_DETECTION_REQUEST(0x05),
EAR_DETECTION(0x06),
BUD_ROLE_REQUEST(0x07),
BUD_ROLE(0x08),
CONTROL_COMMAND(0x09),
DEVICE_LIST(0x0B),
MAC_ADDRESS(0x0C),
AUDIO_SOURCE_REQUEST(0x0D),
AUDIO_SOURCE(0x0E),
REQUEST_NOTIFICATIONS(0x0F),
SMART_ROUTING(0x10),
SMART_ROUTING_RESPONSE(0x11),
EASY_PAIR_REQUEST(0x12),
EASY_PAIR(0x13),
CONNECT_PRIORITY_LIST(0x14),
TRIANGLE_LINK_STATUS_REQUEST(0x15),
TRIANGLE_LINK_STATUS(0x16),
BUDDY_COMMAND(0x17),
STEM_PRESS(0x19),
RENAME(0x1A),
TIMESTAMP(0x1B),
INFORMATION(0x1D),
EXTERNAL_ACCESSORY_SESSION(0x1E),
SESSION_STATE(0x1F),
REMOTE_FIRMWARE_AUTH(0x20),
UNKNOWN_21(0x21),
CASE_INFO_REQUEST(0x22),
CASE_INFO(0x23),
DEVICE_INFO(0x24),
CERTIFICATES_REQUEST(0x26),
CERTIFICATES(0x27),
GYRO_INFO(0x28),
SET_COUNTRY_CODE(0x29),
STREAM_STATE_INFO(0x2B),
GAPA_CHALLENGE(0x2C),
CONNECTED_DEVICES_REQUEST(0x2D),
CONNECTED_DEVICES(0x2E),
MAGIC_KEYS_REQUEST(0x30),
MAGIC_KEYS_RESPONSE(0x31),
MAGIC_KEYS_2(0x32),
UNKNOWN_40(0x40),
SMART_ROUTING_V2_INFO(0x44),
FAST_CONNECT_COMPLETE(0x45),
BUD_SWAP_PROCEDURE(0x47),
SWAP_IMMINENT_CONFIRM(0x48),
BUD_SWAP_COMPLETE(0x49),
SWAP_COMPLETE_CONFIRM(0x4A),
CONVERSATION_AWARENESS(0x4B),
ADAPTIVE_VOLUME(0x4C),
SOURCE_FEATURE_CAPABILITIES(0x4D),
FEATURE_PROXCARD_STATUS(0x4E),
UARP_DATA(0x4F),
PERF_STATS(0x50),
SOURCE_CONTEXT(0x52),
HEADPHONE_ACCOMMODATION(0x53),
SET_BAND_EDGES(0x54),
UNKNOWN_55(0x55),
USB_SPATIAL_SENSOR_DATA_REQUEST(0x56),
SLEEP_DETECTION_UPDATE(0x57),
MICROPHONE_STREAM(0x58),
DYNAMIC_END_OF_CHARGE(0x59),
PERSONAL_TRANSLATION(0x60),
SET_FEATURE_FLAGS(0x62),
CUSTOM_EQ(0x63),
APPLECARE(0x64);
companion object {
fun fromByte(value: Byte): Opcode =
entries.firstOrNull { it.value == value }?: UnknownOpcode(value)
}
}
@@ -0,0 +1,35 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
enum class RTBuddyDescriptor(val value: UInt) {
ACOUSTIC(0x00000001u),
SCP(0x00000002u),
BUDDY(0x00000004u),
VIRTUAL_CLI_PRIMARY(0x00000008u),
VIRTUAL_CLI_SECONDARY(0x00000010u),
APP_DIAGNOSTICS(0x00000020u),
LOGGING_TRIGGER(0x00000040u),
DEBUG_DATA(0x00000080u),
TOUCH(0x00000100u),
LOG_CONFIG(0x00000200u),
LOG_MSG(0x00000400u),
SENSOR(0x00000800u),
SWITCH_CONTROL(0x00001000u),
MISMATCHED_BUDS(0x00002000u),
UNKNOWN_4000(0x00004000u),
B2P(0x00008000u),
CONTINUITY(0x00010000u),
BATTERY_HEALTH(0x00020000u),
SENSOR_V2(0x00040000u),
OBC_V2(0x00080000u),
SENSOR_DATA_WX(0x00100000u),
UNKNOWN_00200000(0x00200000u),
UNKNOWN_00400000(0x00400000u),
DIGITAL_ENGRAVING_INFO(0x00800000u),
ACTIVE_MODE_DATA(0x01000000u),
UNKNOWN(0xFFFFFFFFu); // better way?
companion object {
fun fromValue(value: UInt): RTBuddyDescriptor = entries.firstOrNull { it.value == value } ?: UNKNOWN
}
}
@@ -0,0 +1,11 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
sealed interface RTBuddyPayload {
val descriptor: RTBuddyDescriptor
}
data class UnknownBuddyPayload(
override val descriptor: RTBuddyDescriptor,
val descriptorValue: UInt,
val data: ByteArray,
) : RTBuddyPayload
@@ -0,0 +1,9 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
data class SensorDataWxBuddyPayload(
val data: SensorDataWX,
): RTBuddyPayload {
override val descriptor: RTBuddyDescriptor = RTBuddyDescriptor.SENSOR_DATA_WX
}
@@ -0,0 +1,18 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
enum class StemPressType(val value: Byte) {
SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08);
companion object {
fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte }
}
}
// TODO: make DeviceComponent, BatteryComponent, and StemPressBud the same with helpers to parse from byte for specific messages
enum class StemPressBud(val value: Byte) {
LEFT(0x01), RIGHT(0x02);
companion object {
fun fromByte(byte: Byte): StemPressBud? = entries.find { it.value == byte }
}
}
@@ -0,0 +1,159 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth.att
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.devices.Device
enum class ATTHandle(val value: Int) {
TRANSPARENCY(0x18),
LOUD_SOUND_REDUCTION(0x1B),
HEARING_AID(0x2A)
}
class ATTManager (
private val device: Device<*, *, *>
) {
val macParts = device.macAddress.value.split(":")
private val TAG = "ATTManager[${macParts[0]}:${macParts[1]}:${macParts[2]}]"
private var socket: BluetoothSocket? = null
suspend fun readCharacteristic(handle: ATTHandle): ByteArray? {
// TODO
return null
if (socket != null) {
Log.d(TAG, "Closing existing socket before reading characteristic")
try {
socket!!.close()
Log.d(TAG, "Existing socket closed successfully")
} catch (e: Exception) {
e.printStackTrace()
}
socket = null
}
Log.d(TAG, "Creating new socket for reading characteristic")
socket = try {
device.createSocket(ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), 31)
} catch (e: Exception) {
Log.e(TAG, "Error creating socket: ${e.message}")
return null
}
try {
socket?.connect()
Log.d(TAG, "Socket connected successfully")
val output = socket?.outputStream ?: return null
Log.d(TAG, "Sending read request for handle: ${handle.value}")
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
withContext(Dispatchers.IO) {
output.write(pdu)
Log.d(TAG, "Read request sent: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
output.flush()
Log.d(TAG, "Output stream flushed after sending read request")
}
val buffer = ByteArray(1024)
val bytesRead = withContext(Dispatchers.IO) {
socket?.inputStream?.read(buffer)?: -1
}
Log.d(TAG, "Read response received, bytes read: ${buffer.copyOfRange(0, bytesRead).toHexString()}")
return if (bytesRead > 0) {
buffer.copyOfRange(1, bytesRead)
} else {
null
}
} catch (e: Exception) {
e.printStackTrace()
Log.e(TAG, "Error during read operation: ${e.message}")
} finally {
try {
Log.d(TAG, "Closing socket after read operation")
socket?.close()
socket = null
Log.d(TAG, "Socket closed successfully after read operation")
} catch (e: Exception) {
e.printStackTrace()
}
}
return null
}
suspend fun writeCharacteristic(handle: ATTHandle, data: ByteArray): Boolean {
return false
// todo
if (socket != null) {
Log.d(TAG, "Closing existing socket before writing characteristic")
try {
socket!!.close()
Log.d(TAG, "Existing socket closed successfully")
} catch (e: Exception) {
e.printStackTrace()
}
socket = null
}
Log.d(TAG, "Creating new socket for writing characteristic")
socket = try {
device.createSocket(ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), 31)
} catch (e: Exception) {
e.printStackTrace()
return false
}
Log.d(TAG, "Socket created successfully, attempting to connect")
try {
socket?.connect()
Log.d(TAG, "Socket connected successfully")
val output = socket?.outputStream ?: return false
Log.d(TAG, "Sending write request for handle: ${handle.value}")
val pdu = byteArrayOf(0x12, handle.value.toByte(), 0x00) + data
withContext(Dispatchers.IO) {
output.write(pdu)
Log.d(TAG, "Write request sent: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
output.flush()
Log.d(TAG, "Output stream flushed after sending write request")
}
val buffer = ByteArray(1024)
val bytesRead = withContext(Dispatchers.IO) {
socket?.inputStream?.read(buffer)?: -1
}
Log.d(TAG, "Write response received, bytes read: ${buffer.copyOfRange(0, bytesRead).toHexString()}")
return bytesRead > 0
} catch (e: Exception) {
e.printStackTrace()
} finally {
try {
Log.d(TAG, "Closing socket after write operation")
socket?.close()
socket = null
Log.d(TAG, "Socket closed successfully after write operation")
} catch (e: Exception) {
e.printStackTrace()
}
}
return false
}
}
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
package me.kavishdevar.librepods.bluetooth.att.types
import android.util.Log
import androidx.compose.runtime.MutableState
@@ -25,7 +25,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -141,7 +141,7 @@ fun sendHearingAidSettings(
currentData: ByteArray,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>,
sender: (ATTHandles, ByteArray) -> Unit
sender: (ATTHandle, ByteArray) -> Unit
) {
debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
@@ -184,7 +184,7 @@ fun sendHearingAidSettings(
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
sender(ATTHandles.HEARING_AID, currentData)
sender(ATTHandle.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}
@@ -16,17 +16,18 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
package me.kavishdevar.librepods.bluetooth.att.types
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.time.Duration.Companion.milliseconds
data class TransparencySettings(
val enabled: Boolean,
@@ -141,10 +142,10 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
private var debounceJob: Job? = null
fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
fun sendTransparencySettings(writer: (ATTHandle, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
delay(100.milliseconds)
try {
val buffer = ByteBuffer.allocate(
if (transparencySettings.ownVoiceAmplification != null) 104 else 100
@@ -173,7 +174,7 @@ fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparen
}
val data = buffer.array()
writer(ATTHandles.TRANSPARENCY, data)
writer(ATTHandle.TRANSPARENCY, data)
} catch (e: IOException) {
e.printStackTrace()
}
@@ -1,22 +1,4 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth
package me.kavishdevar.librepods.bluetooth.ble
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
@@ -30,10 +12,9 @@ import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.util.Log
import me.kavishdevar.librepods.utils.BluetoothCryptography
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import kotlin.collections.iterator
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -41,7 +22,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
*/
@OptIn(ExperimentalEncodingApi::class)
class BLEManager(private val context: Context) {
class BLEManagerold(private val context: Context) {
data class AirPodsStatus(
val address: String,
@@ -212,7 +193,7 @@ class BLEManager(private val context: Context) {
@OptIn(ExperimentalEncodingApi::class)
private fun getEncryptionKeyFromPreferences(): ByteArray? {
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
val keyBase64 = sharedPreferences.getString(MagicKeyType.ENC_KEY.name, null)
return if (keyBase64 != null) {
try {
Base64.decode(keyBase64)
@@ -250,86 +231,86 @@ class BLEManager(private val context: Context) {
}
private fun processScanResult(result: ScanResult) {
try {
val scanRecord = result.scanRecord ?: return
val address = result.device.address
if (processedAddresses.contains(address)) {
return
}
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
if (manufacturerData.size <= 20) return
if (!verifiedAddresses.contains(address)) {
val irk = getIrkFromPreferences()
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
return
}
verifiedAddresses.add(address)
Log.d(TAG, "RPA verified and added to trusted list: $address")
}
processedAddresses.add(address)
lastBroadcastTime = System.currentTimeMillis()
val encryptionKey = getEncryptionKeyFromPreferences()
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
} else {
parseProximityMessage(address, manufacturerData)
}
val previousStatus = deviceStatusMap[address]
deviceStatusMap[address] = parsedStatus
airPodsStatusListener?.let { listener ->
if (previousStatus == null) {
listener.onBroadcastFromNewAddress(parsedStatus)
Log.d(TAG, "New AirPods device detected: $address")
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
currentGlobalLidState = parsedStatus.lidOpen
listener.onLidStateChanged(parsedStatus.lidOpen)
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
}
} else {
if (parsedStatus != previousStatus) {
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
}
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
val previousGlobalState = currentGlobalLidState
currentGlobalLidState = parsedStatus.lidOpen
if (previousGlobalState != parsedStatus.lidOpen) {
listener.onLidStateChanged(parsedStatus.lidOpen)
Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}")
}
}
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
listener.onEarStateChanged(
parsedStatus,
parsedStatus.isLeftInEar,
parsedStatus.isRightInEar
)
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
}
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
parsedStatus.rightBattery != previousStatus.rightBattery ||
parsedStatus.caseBattery != previousStatus.caseBattery) {
listener.onBatteryChanged(parsedStatus)
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
}
}
}
} catch (t: Throwable) {
Log.e(TAG, "Error processing scan result", t)
}
// try {
// val scanRecord = result.scanRecord ?: return
// val address = result.device.address
//
// if (processedAddresses.contains(address)) {
// return
// }
//
// val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
// if (manufacturerData.size <= 20) return
//
// if (!verifiedAddresses.contains(address)) {
// val irk = getIrkFromPreferences()
// if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
// return
// }
// verifiedAddresses.add(address)
// Log.d(TAG, "RPA verified and added to trusted list: $address")
// }
//
// processedAddresses.add(address)
// lastBroadcastTime = System.currentTimeMillis()
//
// val encryptionKey = getEncryptionKeyFromPreferences()
// val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
// val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
// parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
// } else {
// parseProximityMessage(address, manufacturerData)
// }
//
// val previousStatus = deviceStatusMap[address]
// deviceStatusMap[address] = parsedStatus
//
// airPodsStatusListener?.let { listener ->
// if (previousStatus == null) {
// listener.onBroadcastFromNewAddress(parsedStatus)
// Log.d(TAG, "New AirPods device detected: $address")
//
// if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
// currentGlobalLidState = parsedStatus.lidOpen
// listener.onLidStateChanged(parsedStatus.lidOpen)
// Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
// }
// } else {
// if (parsedStatus != previousStatus) {
// listener.onDeviceStatusChanged(parsedStatus, previousStatus)
// }
//
// if (parsedStatus.lidOpen != previousStatus.lidOpen) {
// val previousGlobalState = currentGlobalLidState
// currentGlobalLidState = parsedStatus.lidOpen
//
// if (previousGlobalState != parsedStatus.lidOpen) {
// listener.onLidStateChanged(parsedStatus.lidOpen)
// Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}")
// }
// }
//
// if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
// parsedStatus.isRightInEar != previousStatus.isRightInEar) {
// listener.onEarStateChanged(
// parsedStatus,
// parsedStatus.isLeftInEar,
// parsedStatus.isRightInEar
// )
// Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
// }
//
// if (parsedStatus.leftBattery != previousStatus.leftBattery ||
// parsedStatus.rightBattery != previousStatus.rightBattery ||
// parsedStatus.caseBattery != previousStatus.caseBattery) {
// listener.onBatteryChanged(parsedStatus)
// Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
// }
// }
// }
// } catch (t: Throwable) {
// Log.e(TAG, "Error processing scan result", t)
// }
}
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
@@ -417,7 +398,7 @@ class BLEManager(private val context: Context) {
@OptIn(ExperimentalEncodingApi::class)
private fun getIrkFromPreferences(): ByteArray? {
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
val irkBase64 = sharedPreferences.getString(MagicKeyType.IRK.name, null)
return if (irkBase64 != null) {
try {
Base64.decode(irkBase64)
@@ -0,0 +1,7 @@
package me.kavishdevar.librepods.data
enum class AirPodsNotifications(val action: String) {
AIRPODS_CONNECTED("me.kavishdevar.librepods.AIRPODS_CONNECTED"),
ANC_DATA("me.kavishdevar.librepods.ANC_DATA"),
AIRPODS_DISCONNECTED("me.kavishdevar.librepods.AIRPODS_DISCONNECTED"),
}
@@ -18,8 +18,10 @@
package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlinx.serialization.Serializable
import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressType
@Serializable
enum class StemAction {
PLAY_PAUSE,
PREVIOUS_TRACK,
@@ -30,11 +32,11 @@ enum class StemAction {
fun fromString(action: String): StemAction? {
return entries.find { it.name == action }
}
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
val defaultActions: Map<StemPressType, StemAction> = mapOf(
StemPressType.SINGLE_PRESS to PLAY_PAUSE,
StemPressType.DOUBLE_PRESS to NEXT_TRACK,
StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
)
}
}
@@ -0,0 +1,13 @@
package me.kavishdevar.librepods.data.apple
import kotlinx.serialization.Serializable
import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
@Serializable
data class AppleCache(
val capabilities: Set<CapabilityEntry> = emptySet(),
val magicKeys: Map<MagicKeyType, ByteArray> = emptyMap(),
val controlStates: Map<ControlCommandIdentifier, ByteArray> = emptyMap(),
)
@@ -0,0 +1,7 @@
package me.kavishdevar.librepods.data.apple
enum class BuddyState {
INACTIVE,
WAITING,
ACTIVE
}
@@ -0,0 +1,57 @@
package me.kavishdevar.librepods.data.audio
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import java.nio.ByteBuffer
import java.nio.ByteOrder
data class MicrophoneFrame(
val timestamp: UInt,
val accessUnit: ByteArray
) {
companion object {
fun parsePacket(packet: ByteArray): List<MicrophoneFrame> {
if (packet.size < 22) {
throw IllegalArgumentException("Microphone packet too short")
}
if (packet[4] != MessageOpcode.MICROPHONE_STREAM.value) {
throw IllegalArgumentException("Not a microphoneState packet")
}
if (packet[6] != 0x01.toByte() || packet[7] != 0x00.toByte()) {
return emptyList()
}
val frames = mutableListOf<MicrophoneFrame>()
var offset = 22
while (offset + 5 <= packet.size) {
val timestamp = ByteBuffer
.wrap(packet, offset, 4)
.order(ByteOrder.LITTLE_ENDIAN)
.int
.toUInt()
val length = packet[offset + 4].toUByte().toInt()
val start = offset + 5
val end = start + length
if (end > packet.size) {
break
}
frames += MicrophoneFrame(
timestamp = timestamp,
accessUnit = packet.copyOfRange(start, end)
)
offset = end
}
return frames
}
}
}
@@ -0,0 +1,12 @@
package me.kavishdevar.librepods.data.audio
data class MicrophoneState(
val isActive: Boolean = false,
val packetsReceived: Long = 0,
val decodeErrors: Long = 0,
val durationMs: Long = 0,
val level: Float = 0f,
)

Some files were not shown because too many files have changed in this diff Show More