mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-08-26 22:15:07 +02:00
Compare commits
5 Commits
main
...
android/rewrite
| Author | SHA1 | Date | |
|---|---|---|---|
| 56f9274145 | |||
| a68637ed7c | |||
| e950aa010d | |||
| ddd1a9c5ce | |||
| 8457880210 |
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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);
|
||||
}
|
||||
+1
-1
@@ -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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
-242
@@ -1,242 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
-56
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
-409
@@ -1,409 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-324
@@ -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 }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
-158
@@ -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
|
||||
}
|
||||
}
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
-1022
File diff suppressed because it is too large
Load Diff
-613
@@ -1,613 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-97
@@ -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))
|
||||
}
|
||||
}
|
||||
-762
@@ -1,762 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-405
@@ -1,405 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-243
@@ -1,243 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
-35
@@ -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()
|
||||
}
|
||||
}
|
||||
-101
@@ -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))
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
-217
@@ -1,217 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))
|
||||
|
||||
}
|
||||
}
|
||||
-365
@@ -1,365 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
-920
@@ -1,920 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
-332
@@ -1,332 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
-786
@@ -1,786 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-260
@@ -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) }
|
||||
}
|
||||
}
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
+9
-11
@@ -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) }
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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()
|
||||
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+122
@@ -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()
|
||||
)
|
||||
}
|
||||
+69
@@ -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
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -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
|
||||
}
|
||||
}
|
||||
+50
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
)
|
||||
+70
@@ -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]
|
||||
}
|
||||
}
|
||||
+7
@@ -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?
|
||||
)
|
||||
+115
@@ -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 }
|
||||
}
|
||||
}
|
||||
+10
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
+9
@@ -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
|
||||
}
|
||||
+18
@@ -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 Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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()
|
||||
}
|
||||
+6
-5
@@ -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()
|
||||
}
|
||||
+85
-104
@@ -1,22 +1,4 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
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"),
|
||||
}
|
||||
+8
-6
@@ -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
Reference in New Issue
Block a user