mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-08-26 22:15:07 +02:00
android: refactor... stuff (1, see description)
use StateFlows instead of broadcasts/callbacks and add support for multiple devices. some features are yet to be implemented, marked with TODO. forgot to make incremental commits
This commit is contained in:
@@ -6,6 +6,9 @@ plugins {
|
|||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.aboutLibraries)
|
alias(libs.plugins.aboutLibraries)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.room3)
|
||||||
// alias(libs.plugins.hilt)
|
// alias(libs.plugins.hilt)
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
@@ -24,10 +27,17 @@ val releaseSigningAvailable = listOf(
|
|||||||
"RELEASE_KEY_PASSWORD"
|
"RELEASE_KEY_PASSWORD"
|
||||||
).all { props[it]?.toString()?.isNotBlank() == true }
|
).all { props[it]?.toString()?.isNotBlank() == true }
|
||||||
|
|
||||||
|
room3 {
|
||||||
|
schemaDirectory("$projectDir/schemas")
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
optIn.add(
|
optIn.addAll(
|
||||||
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi"
|
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
|
||||||
|
"kotlin.uuid.ExperimentalUuidApi",
|
||||||
|
"kotlinx.coroutines.FlowPreview",
|
||||||
|
"kotlinx.serialization.ExperimentalSerializationApi"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +59,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "me.kavishdevar.librepods"
|
applicationId = "me.kavishdevar.librepods"
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 63
|
versionCode = 65
|
||||||
versionName = appVersionName
|
versionName = appVersionName
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -125,7 +135,6 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.accompanist.permissions)
|
implementation(libs.accompanist.permissions)
|
||||||
implementation(libs.androidx.compose.ui.text.google.fonts)
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.process)
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
@@ -140,12 +149,11 @@ dependencies {
|
|||||||
implementation(libs.haze)
|
implementation(libs.haze)
|
||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.materials)
|
||||||
implementation(libs.androidx.dynamicanimation)
|
implementation(libs.androidx.dynamicanimation)
|
||||||
implementation(libs.androidx.compose.ui)
|
|
||||||
implementation(libs.androidx.compose.material.icons.core)
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
implementation(libs.billing)
|
implementation(libs.billing)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
implementation(libs.androidx.compose.foundation.layout)
|
implementation(libs.androidx.compose.foundation.layout)
|
||||||
implementation(libs.aboutlibraries)
|
implementation(libs.aboutlibraries.compose)
|
||||||
implementation(libs.aboutlibraries.compose.m3)
|
implementation(libs.aboutlibraries.compose.m3)
|
||||||
implementation(libs.backdrop)
|
implementation(libs.backdrop)
|
||||||
// implementation(libs.hilt)
|
// implementation(libs.hilt)
|
||||||
@@ -158,6 +166,11 @@ dependencies {
|
|||||||
implementation(libs.androidx.navigation3.runtime)
|
implementation(libs.androidx.navigation3.runtime)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||||
implementation(libs.androidx.navigationevent)
|
implementation(libs.androidx.navigationevent)
|
||||||
|
implementation(libs.androidx.room3.runtime)
|
||||||
|
ksp(libs.androidx.room3.compiler)
|
||||||
|
implementation(libs.kotlinx.serialization.cbor)
|
||||||
|
|
||||||
|
// compileOnly(files("../../../framework-classes.jar"))
|
||||||
}
|
}
|
||||||
|
|
||||||
aboutLibraries {
|
aboutLibraries {
|
||||||
@@ -178,6 +191,7 @@ fun registerRootModuleZipTask(
|
|||||||
flavor: String,
|
flavor: String,
|
||||||
buildType: String
|
buildType: String
|
||||||
) = tasks.register<Zip>(name) {
|
) = tasks.register<Zip>(name) {
|
||||||
|
description = "Zips the root module for the $flavor-$buildType variant."
|
||||||
|
|
||||||
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
|
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
|
||||||
dependsOn(variantTask)
|
dependsOn(variantTask)
|
||||||
@@ -213,6 +227,7 @@ val zipDebug = registerRootModuleZipTask(
|
|||||||
|
|
||||||
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
|
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
|
||||||
|
|
||||||
|
description = "Collects release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
|
||||||
dependsOn(
|
dependsOn(
|
||||||
zipRelease,
|
zipRelease,
|
||||||
zipDebug,
|
zipDebug,
|
||||||
@@ -241,5 +256,6 @@ val collect = tasks.register<Copy>("collectReleaseArtifacts") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("packageReleaseArtifacts") {
|
tasks.register("packageReleaseArtifacts") {
|
||||||
|
description = "Packages release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
|
||||||
dependsOn(collect)
|
dependsOn(collect)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 1,
|
||||||
|
"identityHash": "ae2e348e0198b8d871e9e3ed6959ab30",
|
||||||
|
"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, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` 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": "debugMode",
|
||||||
|
"columnName": "debugMode",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bleScanMode",
|
||||||
|
"columnName": "bleScanMode",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bleReportDelay",
|
||||||
|
"columnName": "bleReportDelay",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "AppStateEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` 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": "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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, 'ae2e348e0198b8d871e9e3ed6959ab30')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "b6034db24a2a1b8f4d424d89fcc4495f",
|
||||||
|
"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, 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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "AppStateEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` 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": "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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, 'b6034db24a2a1b8f4d424d89fcc4495f')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,15 +26,21 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.INTERACT_ACROSS_USERS"
|
android:name="android.permission.INTERACT_ACROSS_USERS"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MODIFY_AUDIO_ROUTING"/>
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BLUETOOTH_SCAN"
|
android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
android:usesPermissionFlags="neverForLocation"
|
android:usesPermissionFlags="neverForLocation"
|
||||||
tools:ignore="UnusedAttribute" />
|
tools:ignore="UnusedAttribute" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
|
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
|
||||||
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
|
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
|
||||||
<!-- android:maxSdkVersion="30" />-->
|
<!-- android:maxSdkVersion="30" />-->
|
||||||
@@ -80,7 +86,7 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".presentation.activities.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.LibrePods">
|
android:theme="@style/Theme.LibrePods">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -98,16 +104,11 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".QuickSettingsDialogActivity"
|
android:name=".presentation.activities.NoiseControlWidgetConfigurationActivity"
|
||||||
android:exported="false"
|
android:exported="false" />
|
||||||
android:theme="@style/Theme.TransparentDialog"
|
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:excludeFromRecents="true"
|
|
||||||
android:taskAffinity=""
|
|
||||||
/>
|
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".services.AirPodsService"
|
android:name=".services.LibrePodsService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:foregroundServiceType="connectedDevice"
|
android:foregroundServiceType="connectedDevice"
|
||||||
@@ -115,7 +116,7 @@
|
|||||||
<service
|
<service
|
||||||
android:name=".services.AirPodsQSService"
|
android:name=".services.AirPodsQSService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:icon="@drawable/airpods"
|
android:icon="@drawable/ic_airpods"
|
||||||
android:label="ANC Mode"
|
android:label="ANC Mode"
|
||||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -3,21 +3,21 @@ cmake_minimum_required(VERSION 3.22.1)
|
|||||||
project("l2c_fcr_hook")
|
project("l2c_fcr_hook")
|
||||||
set(CMAKE_CXX_STANDARD 23)
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
|
||||||
add_library(bluetooth_socket SHARED
|
add_library(hiddenapi SHARED
|
||||||
bluetooth_socket.cpp
|
hiddenapi.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_options(bluetooth_socket PRIVATE
|
target_compile_options(hiddenapi PRIVATE
|
||||||
-O2
|
-O2
|
||||||
-fvisibility=hidden
|
-fvisibility=hidden
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_options(bluetooth_socket PRIVATE
|
target_link_options(hiddenapi PRIVATE
|
||||||
-Wl,--strip-all
|
-Wl,--strip-all
|
||||||
-Wl,--gc-sections
|
-Wl,--gc-sections
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(bluetooth_socket
|
target_link_libraries(hiddenapi
|
||||||
android
|
android
|
||||||
log
|
log
|
||||||
)
|
)
|
||||||
@@ -25,8 +25,8 @@ target_link_libraries(bluetooth_socket
|
|||||||
|
|
||||||
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
||||||
|
|
||||||
add_library(l2c_fcr_hook SHARED
|
add_library(fluoride_hooks SHARED
|
||||||
l2c_fcr_hook.cpp
|
fluoride_hooks.cpp
|
||||||
|
|
||||||
xz/xz_crc32.c
|
xz/xz_crc32.c
|
||||||
xz/xz_crc64.c
|
xz/xz_crc64.c
|
||||||
@@ -36,11 +36,11 @@ add_library(l2c_fcr_hook SHARED
|
|||||||
xz/xz_dec_bcj.c
|
xz/xz_dec_bcj.c
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(l2c_fcr_hook PRIVATE
|
target_include_directories(fluoride_hooks PRIVATE
|
||||||
xz
|
xz
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(l2c_fcr_hook PRIVATE
|
target_compile_definitions(fluoride_hooks PRIVATE
|
||||||
XZ_DEC_X86
|
XZ_DEC_X86
|
||||||
XZ_DEC_ARM
|
XZ_DEC_ARM
|
||||||
XZ_DEC_ARMTHUMB
|
XZ_DEC_ARMTHUMB
|
||||||
@@ -51,7 +51,7 @@ target_compile_definitions(l2c_fcr_hook PRIVATE
|
|||||||
XZ_DEC_CONCATENATED
|
XZ_DEC_CONCATENATED
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(l2c_fcr_hook
|
target_link_libraries(fluoride_hooks
|
||||||
android
|
android
|
||||||
log
|
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 <android/log.h>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -24,60 +6,17 @@
|
|||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <elf.h>
|
#include <elf.h>
|
||||||
#include <atomic>
|
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
|
|
||||||
#include "l2c_fcr_hook.h"
|
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include "xz.h"
|
#include "xz.h"
|
||||||
}
|
}
|
||||||
|
|
||||||
#define LOG_TAG "LibrePodsHook"
|
#define LOG_TAG "LibrePodsHook"
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, 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) {
|
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);
|
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)) {
|
while (fgets(line, sizeof(line), fp)) {
|
||||||
if (strstr(line, name)) {
|
if (strstr(line, name)) {
|
||||||
base = strtoull(line, nullptr, 16);
|
base = strtoull(line, nullptr, 16);
|
||||||
LOGI("getModuleBase: found base at 0x%lx", base);
|
LOGI("getModuleBase: found base at 0x%x", base);
|
||||||
break;
|
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);
|
LOGI("findSymbolOffset: no match found for %s", symbol_substring);
|
||||||
return 0;
|
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 c5 = ENC("([Ljava/lang/String;)V");
|
||||||
constexpr auto c6 = ENC("java/lang/String");
|
constexpr auto c6 = ENC("java/lang/String");
|
||||||
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
|
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
|
||||||
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
|
constexpr auto c8 = ENC("L");
|
||||||
|
|
||||||
JNIEnv* env;
|
JNIEnv* env;
|
||||||
getVm()->AttachCurrentThread(&env, nullptr);
|
getVm()->AttachCurrentThread(&env, nullptr);
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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))}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
@@ -4,16 +4,35 @@ import android.app.Application
|
|||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import androidx.room3.Room
|
||||||
import io.github.libxposed.service.XposedService
|
import io.github.libxposed.service.XposedService
|
||||||
import io.github.libxposed.service.XposedServiceHelper
|
import io.github.libxposed.service.XposedServiceHelper
|
||||||
import me.kavishdevar.librepods.billing.BillingManager
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.billing.BillingProviderFactory
|
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.WidgetConfigRepository
|
||||||
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
||||||
import me.kavishdevar.librepods.utils.XposedState
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
|
||||||
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
|
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()) }
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
System.loadLibrary("hiddenapi")
|
||||||
|
|
||||||
|
database = Room.databaseBuilder(
|
||||||
|
applicationContext,
|
||||||
|
LibrePodsDatabase::class.java,
|
||||||
|
"librepods.db"
|
||||||
|
).build()
|
||||||
|
|
||||||
XposedServiceHelper.registerListener(this)
|
XposedServiceHelper.registerListener(this)
|
||||||
BillingManager.provider = BillingProviderFactory.create(this)
|
BillingManager.provider = BillingProviderFactory.create(this)
|
||||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
+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
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
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[2].toInt()
|
||||||
|
val low = payload[3].toInt()
|
||||||
|
val mid = payload[4].toInt()
|
||||||
|
val high = payload[5].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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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 }
|
@Serializable
|
||||||
|
data class CustomEq(
|
||||||
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
|
val state: Int,
|
||||||
|
val low: Int,
|
||||||
|
val mid: Int,
|
||||||
|
val high: Int
|
||||||
|
) {
|
||||||
|
|
||||||
fun isEnabled(): Boolean {
|
fun isEnabled(): Boolean {
|
||||||
return state == 2
|
return state == 2
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toPacket(): ByteArray {
|
fun toAACPPayload(): ByteArray {
|
||||||
return byteArrayOf(
|
return byteArrayOf(
|
||||||
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
|
|
||||||
0x05, 0x00, // length (LE)
|
0x05, 0x00, // length (LE)
|
||||||
0x01, state.toByte(),
|
0x01, state.toByte(),
|
||||||
low.toByte(), mid.toByte(), high.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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/>.
|
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 android.util.Log
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
@@ -25,7 +25,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@@ -141,7 +141,7 @@ fun sendHearingAidSettings(
|
|||||||
currentData: ByteArray,
|
currentData: ByteArray,
|
||||||
hearingAidSettings: HearingAidSettings,
|
hearingAidSettings: HearingAidSettings,
|
||||||
debounceJob: MutableState<Job?>,
|
debounceJob: MutableState<Job?>,
|
||||||
sender: (ATTHandles, ByteArray) -> Unit
|
sender: (ATTHandle, ByteArray) -> Unit
|
||||||
) {
|
) {
|
||||||
debounceJob.value?.cancel()
|
debounceJob.value?.cancel()
|
||||||
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
||||||
@@ -184,7 +184,7 @@ fun sendHearingAidSettings(
|
|||||||
|
|
||||||
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
sender(ATTHandles.HEARING_AID, currentData)
|
sender(ATTHandle.HEARING_AID, currentData)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
+6
-5
@@ -16,17 +16,18 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
data class TransparencySettings(
|
data class TransparencySettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
@@ -141,10 +142,10 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
|
|||||||
|
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
|
fun sendTransparencySettings(writer: (ATTHandle, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
|
||||||
debounceJob?.cancel()
|
debounceJob?.cancel()
|
||||||
debounceJob = CoroutineScope(Dispatchers.IO).launch {
|
debounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
delay(100)
|
delay(100.milliseconds)
|
||||||
try {
|
try {
|
||||||
val buffer = ByteBuffer.allocate(
|
val buffer = ByteBuffer.allocate(
|
||||||
if (transparencySettings.ownVoiceAmplification != null) 104 else 100
|
if (transparencySettings.ownVoiceAmplification != null) 104 else 100
|
||||||
@@ -173,7 +174,7 @@ fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparen
|
|||||||
}
|
}
|
||||||
|
|
||||||
val data = buffer.array()
|
val data = buffer.array()
|
||||||
writer(ATTHandles.TRANSPARENCY, data)
|
writer(ATTHandle.TRANSPARENCY, data)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
+85
-104
@@ -1,22 +1,4 @@
|
|||||||
/*
|
package me.kavishdevar.librepods.bluetooth.ble
|
||||||
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.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
@@ -30,10 +12,9 @@ import android.content.SharedPreferences
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
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.Cipher
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
import kotlin.collections.iterator
|
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -41,7 +22,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
|
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
class BLEManager(private val context: Context) {
|
class BLEManagerold(private val context: Context) {
|
||||||
|
|
||||||
data class AirPodsStatus(
|
data class AirPodsStatus(
|
||||||
val address: String,
|
val address: String,
|
||||||
@@ -212,7 +193,7 @@ class BLEManager(private val context: Context) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
private fun getEncryptionKeyFromPreferences(): ByteArray? {
|
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) {
|
return if (keyBase64 != null) {
|
||||||
try {
|
try {
|
||||||
Base64.decode(keyBase64)
|
Base64.decode(keyBase64)
|
||||||
@@ -250,86 +231,86 @@ class BLEManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun processScanResult(result: ScanResult) {
|
private fun processScanResult(result: ScanResult) {
|
||||||
try {
|
// try {
|
||||||
val scanRecord = result.scanRecord ?: return
|
// val scanRecord = result.scanRecord ?: return
|
||||||
val address = result.device.address
|
// val address = result.device.address
|
||||||
|
//
|
||||||
if (processedAddresses.contains(address)) {
|
// if (processedAddresses.contains(address)) {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
|
// val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
|
||||||
if (manufacturerData.size <= 20) return
|
// if (manufacturerData.size <= 20) return
|
||||||
|
//
|
||||||
if (!verifiedAddresses.contains(address)) {
|
// if (!verifiedAddresses.contains(address)) {
|
||||||
val irk = getIrkFromPreferences()
|
// val irk = getIrkFromPreferences()
|
||||||
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
|
// if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
verifiedAddresses.add(address)
|
// verifiedAddresses.add(address)
|
||||||
Log.d(TAG, "RPA verified and added to trusted list: $address")
|
// Log.d(TAG, "RPA verified and added to trusted list: $address")
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
processedAddresses.add(address)
|
// processedAddresses.add(address)
|
||||||
lastBroadcastTime = System.currentTimeMillis()
|
// lastBroadcastTime = System.currentTimeMillis()
|
||||||
|
//
|
||||||
val encryptionKey = getEncryptionKeyFromPreferences()
|
// val encryptionKey = getEncryptionKeyFromPreferences()
|
||||||
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
|
// val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
|
||||||
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
|
// val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
|
||||||
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
|
// parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
|
||||||
} else {
|
// } else {
|
||||||
parseProximityMessage(address, manufacturerData)
|
// parseProximityMessage(address, manufacturerData)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
val previousStatus = deviceStatusMap[address]
|
// val previousStatus = deviceStatusMap[address]
|
||||||
deviceStatusMap[address] = parsedStatus
|
// deviceStatusMap[address] = parsedStatus
|
||||||
|
//
|
||||||
airPodsStatusListener?.let { listener ->
|
// airPodsStatusListener?.let { listener ->
|
||||||
if (previousStatus == null) {
|
// if (previousStatus == null) {
|
||||||
listener.onBroadcastFromNewAddress(parsedStatus)
|
// listener.onBroadcastFromNewAddress(parsedStatus)
|
||||||
Log.d(TAG, "New AirPods device detected: $address")
|
// Log.d(TAG, "New AirPods device detected: $address")
|
||||||
|
//
|
||||||
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
|
// if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
|
||||||
currentGlobalLidState = parsedStatus.lidOpen
|
// currentGlobalLidState = parsedStatus.lidOpen
|
||||||
listener.onLidStateChanged(parsedStatus.lidOpen)
|
// listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||||
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
|
// Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
|
||||||
}
|
// }
|
||||||
} else {
|
// } else {
|
||||||
if (parsedStatus != previousStatus) {
|
// if (parsedStatus != previousStatus) {
|
||||||
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
|
// listener.onDeviceStatusChanged(parsedStatus, previousStatus)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
|
// if (parsedStatus.lidOpen != previousStatus.lidOpen) {
|
||||||
val previousGlobalState = currentGlobalLidState
|
// val previousGlobalState = currentGlobalLidState
|
||||||
currentGlobalLidState = parsedStatus.lidOpen
|
// currentGlobalLidState = parsedStatus.lidOpen
|
||||||
|
//
|
||||||
if (previousGlobalState != parsedStatus.lidOpen) {
|
// if (previousGlobalState != parsedStatus.lidOpen) {
|
||||||
listener.onLidStateChanged(parsedStatus.lidOpen)
|
// listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||||
Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}")
|
// Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}")
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
|
// if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
|
||||||
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
|
// parsedStatus.isRightInEar != previousStatus.isRightInEar) {
|
||||||
listener.onEarStateChanged(
|
// listener.onEarStateChanged(
|
||||||
parsedStatus,
|
// parsedStatus,
|
||||||
parsedStatus.isLeftInEar,
|
// parsedStatus.isLeftInEar,
|
||||||
parsedStatus.isRightInEar
|
// parsedStatus.isRightInEar
|
||||||
)
|
// )
|
||||||
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
|
// Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
|
// if (parsedStatus.leftBattery != previousStatus.leftBattery ||
|
||||||
parsedStatus.rightBattery != previousStatus.rightBattery ||
|
// parsedStatus.rightBattery != previousStatus.rightBattery ||
|
||||||
parsedStatus.caseBattery != previousStatus.caseBattery) {
|
// parsedStatus.caseBattery != previousStatus.caseBattery) {
|
||||||
listener.onBatteryChanged(parsedStatus)
|
// listener.onBatteryChanged(parsedStatus)
|
||||||
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
|
// Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
} catch (t: Throwable) {
|
// } catch (t: Throwable) {
|
||||||
Log.e(TAG, "Error processing scan result", t)
|
// Log.e(TAG, "Error processing scan result", t)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
|
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
|
||||||
@@ -417,7 +398,7 @@ class BLEManager(private val context: Context) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
private fun getIrkFromPreferences(): ByteArray? {
|
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) {
|
return if (irkBase64 != null) {
|
||||||
try {
|
try {
|
||||||
Base64.decode(irkBase64)
|
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
|
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 {
|
enum class StemAction {
|
||||||
PLAY_PAUSE,
|
PLAY_PAUSE,
|
||||||
PREVIOUS_TRACK,
|
PREVIOUS_TRACK,
|
||||||
@@ -30,11 +32,11 @@ enum class StemAction {
|
|||||||
fun fromString(action: String): StemAction? {
|
fun fromString(action: String): StemAction? {
|
||||||
return entries.find { it.name == action }
|
return entries.find { it.name == action }
|
||||||
}
|
}
|
||||||
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
|
val defaultActions: Map<StemPressType, StemAction> = mapOf(
|
||||||
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
|
StemPressType.SINGLE_PRESS to PLAY_PAUSE,
|
||||||
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
|
StemPressType.DOUBLE_PRESS to NEXT_TRACK,
|
||||||
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
|
StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
|
||||||
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
|
StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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.CustomEq
|
||||||
|
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(),
|
||||||
|
val customEq: CustomEq = CustomEq(1, 50, 50, 50),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package me.kavishdevar.librepods.data.recording
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
data class Recording(
|
||||||
|
val uuid: Uuid,
|
||||||
|
val file: File,
|
||||||
|
val createdAt: Instant
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package me.kavishdevar.librepods.data.recording
|
||||||
|
|
||||||
|
class RecordingState {
|
||||||
|
val isRecording: Boolean = false
|
||||||
|
val recording: Recording? = null
|
||||||
|
}
|
||||||
+3
-3
@@ -2,9 +2,9 @@ package me.kavishdevar.librepods.data.updates
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreenPreviewMaterial
|
import me.kavishdevar.librepods.presentation.screens.apple.AirPodsSettingsScreenPreviewMaterial
|
||||||
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewApple
|
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewApple
|
||||||
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewMaterial
|
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewMaterial
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package me.kavishdevar.librepods.data
|
package me.kavishdevar.librepods.data.xposed
|
||||||
|
|
||||||
interface XposedRemotePref {
|
interface XposedRemotePref {
|
||||||
fun isAvailable(): Boolean
|
fun isAvailable(): Boolean
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package me.kavishdevar.librepods.data
|
package me.kavishdevar.librepods.data.xposed
|
||||||
|
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package me.kavishdevar.librepods.data
|
package me.kavishdevar.librepods.data.xposed
|
||||||
|
|
||||||
object XposedRemotePrefProvider {
|
object XposedRemotePrefProvider {
|
||||||
fun create(): XposedRemotePref = XposedRemotePrefImpl()
|
fun create(): XposedRemotePref = XposedRemotePrefImpl()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package me.kavishdevar.librepods.database
|
||||||
|
|
||||||
|
import androidx.room3.ColumnTypeConverter
|
||||||
|
import kotlinx.serialization.cbor.Cbor
|
||||||
|
import kotlinx.serialization.decodeFromByteArray
|
||||||
|
import kotlinx.serialization.encodeToByteArray
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
|
import me.kavishdevar.librepods.data.apple.AppleCache
|
||||||
|
import me.kavishdevar.librepods.devices.AppleMetadata
|
||||||
|
import me.kavishdevar.librepods.devices.AppleSettings
|
||||||
|
|
||||||
|
object Converters {
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun macAddressToString(mac: MacAddress): String = mac.value
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun stringToMacAddress(value: String): MacAddress = MacAddress(value)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun appleSettingsToBytes(settings: AppleSettings): ByteArray =
|
||||||
|
Cbor.encodeToByteArray(settings)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun bytesToAppleSettings(bytes: ByteArray): AppleSettings =
|
||||||
|
Cbor.decodeFromByteArray(bytes)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun appleMetadataToBytes(metadata: AppleMetadata): ByteArray =
|
||||||
|
Cbor.encodeToByteArray(metadata)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun bytesToAppleMetadata(bytes: ByteArray): AppleMetadata =
|
||||||
|
Cbor.decodeFromByteArray(bytes)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun appleCacheToBytes(cache: AppleCache): ByteArray =
|
||||||
|
Cbor.encodeToByteArray(cache)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun bytesToAppleCache(bytes: ByteArray): AppleCache =
|
||||||
|
Cbor.decodeFromByteArray(bytes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package me.kavishdevar.librepods.database
|
||||||
|
|
||||||
|
import androidx.room3.ColumnTypeConverters
|
||||||
|
import androidx.room3.Database
|
||||||
|
import androidx.room3.RoomDatabase
|
||||||
|
import me.kavishdevar.librepods.database.app.AppSettingsDao
|
||||||
|
import me.kavishdevar.librepods.database.app.AppSettingsEntity
|
||||||
|
import me.kavishdevar.librepods.database.app.AppStateDao
|
||||||
|
import me.kavishdevar.librepods.database.app.AppStateEntity
|
||||||
|
import me.kavishdevar.librepods.database.apple.AppleDao
|
||||||
|
import me.kavishdevar.librepods.database.apple.AppleEntity
|
||||||
|
import me.kavishdevar.librepods.database.widget.WidgetConfigDao
|
||||||
|
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
||||||
|
|
||||||
|
@ColumnTypeConverters(Converters::class)
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
AppleEntity::class,
|
||||||
|
AppSettingsEntity::class,
|
||||||
|
AppStateEntity::class,
|
||||||
|
WidgetConfigEntity::class,
|
||||||
|
],
|
||||||
|
version = 1,
|
||||||
|
)
|
||||||
|
abstract class LibrePodsDatabase: RoomDatabase() {
|
||||||
|
abstract fun appleDao(): AppleDao
|
||||||
|
|
||||||
|
abstract fun appSettingsDao(): AppSettingsDao
|
||||||
|
abstract fun appStateDao(): AppStateDao
|
||||||
|
|
||||||
|
abstract fun widgetConfigDao(): WidgetConfigDao
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package me.kavishdevar.librepods.database.app
|
||||||
|
|
||||||
|
import androidx.room3.Dao
|
||||||
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Upsert
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AppSettingsDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM AppSettingsEntity WHERE id = 0")
|
||||||
|
suspend fun get(): AppSettingsEntity?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(settings: AppSettingsEntity)
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package me.kavishdevar.librepods.database.app
|
||||||
|
|
||||||
|
import android.bluetooth.le.ScanSettings
|
||||||
|
import androidx.room3.Entity
|
||||||
|
import androidx.room3.PrimaryKey
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.NightTheme
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class AppSettingsEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: Int = 0,
|
||||||
|
|
||||||
|
val nightMode: NightTheme = NightTheme.System,
|
||||||
|
val designSystem: DesignSystem = DesignSystem.Material,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently only shows the button for Debug screen.
|
||||||
|
*/
|
||||||
|
val debugMode: Boolean = false,
|
||||||
|
|
||||||
|
val bleScanMode: Int = ScanSettings.SCAN_MODE_BALANCED,
|
||||||
|
val bleReportDelay: Long = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package me.kavishdevar.librepods.database.app
|
||||||
|
|
||||||
|
import androidx.room3.Dao
|
||||||
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Upsert
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AppStateDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM AppStateEntity WHERE id = 0")
|
||||||
|
suspend fun get(): AppStateEntity?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(state: AppStateEntity)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package me.kavishdevar.librepods.database.app
|
||||||
|
|
||||||
|
import androidx.room3.Entity
|
||||||
|
import androidx.room3.PrimaryKey
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class AppStateEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: Int = 0,
|
||||||
|
|
||||||
|
val hasCompletedOnboarding: Boolean = false,
|
||||||
|
val lastVersionShown: String? = null,
|
||||||
|
|
||||||
|
val hasConnectedToAACP: Boolean = false,
|
||||||
|
val firstSuccessfulConnectionTime: Long? = null,
|
||||||
|
|
||||||
|
val reviewPrompted: Boolean = false,
|
||||||
|
|
||||||
|
val timeUntilFOSSPremiumExpiry: Long = 0L,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package me.kavishdevar.librepods.database.apple
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.room3.Dao
|
||||||
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Upsert
|
||||||
|
import me.kavishdevar.librepods.data.apple.AppleCache
|
||||||
|
import me.kavishdevar.librepods.devices.AppleMetadata
|
||||||
|
import me.kavishdevar.librepods.devices.AppleSettings
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
|
|
||||||
|
private const val TAG = "AppleDao"
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AppleDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM AppleEntity WHERE macAddress = :macAddress")
|
||||||
|
suspend fun get(macAddress: MacAddress): AppleEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM AppleEntity")
|
||||||
|
suspend fun getAll(): List<AppleEntity>
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(device: AppleEntity)
|
||||||
|
|
||||||
|
suspend fun updateSettings(macAddress: MacAddress, settings: AppleSettings) {
|
||||||
|
Log.d(TAG, "Updating settings for $macAddress: $settings")
|
||||||
|
val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = settings, metadata = AppleMetadata(), cache = AppleCache())
|
||||||
|
upsert(device.copy(settings = settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateMetadata(macAddress: MacAddress, metadata: AppleMetadata) {
|
||||||
|
Log.d(TAG, "Updating metadata for $macAddress: $metadata")
|
||||||
|
val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = metadata, cache = AppleCache())
|
||||||
|
upsert(device.copy(metadata = metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveCache(macAddress: MacAddress, cache: AppleCache) {
|
||||||
|
Log.d(TAG, "Saving cache for $macAddress: $cache")
|
||||||
|
val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = AppleMetadata(), cache = cache)
|
||||||
|
upsert(device.copy(cache = cache))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package me.kavishdevar.librepods.database.apple
|
||||||
|
|
||||||
|
import androidx.room3.Entity
|
||||||
|
import androidx.room3.PrimaryKey
|
||||||
|
import me.kavishdevar.librepods.data.apple.AppleCache
|
||||||
|
import me.kavishdevar.librepods.devices.AppleMetadata
|
||||||
|
import me.kavishdevar.librepods.devices.AppleSettings
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class AppleEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val macAddress: MacAddress,
|
||||||
|
|
||||||
|
val settings: AppleSettings,
|
||||||
|
val metadata: AppleMetadata,
|
||||||
|
val cache: AppleCache,
|
||||||
|
)
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package me.kavishdevar.librepods.database.widget
|
||||||
|
|
||||||
|
import androidx.room3.Dao
|
||||||
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Upsert
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WidgetConfigDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM WidgetConfigEntity")
|
||||||
|
suspend fun getAll(): List<WidgetConfigEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM WidgetConfigEntity WHERE appWidgetId = :appWidgetId")
|
||||||
|
suspend fun getWidgetById(appWidgetId: Int): WidgetConfigEntity?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(widgetConfig: WidgetConfigEntity)
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package me.kavishdevar.librepods.database.widget
|
||||||
|
|
||||||
|
import androidx.room3.Entity
|
||||||
|
import androidx.room3.PrimaryKey
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class WidgetConfigEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val appWidgetId: Int,
|
||||||
|
|
||||||
|
val macAddress: MacAddress
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/*
|
||||||
|
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.devices
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
|
data class ComponentSpec(
|
||||||
|
val type: DeviceComponent,
|
||||||
|
val iconName: String,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AirPodsModel {
|
||||||
|
AIRPODS_1,
|
||||||
|
AIRPODS_2,
|
||||||
|
AIRPODS_3,
|
||||||
|
AIRPODS_4,
|
||||||
|
AIRPODS_4_ANC,
|
||||||
|
AIRPODS_PRO_1,
|
||||||
|
AIRPODS_PRO_2_LIGHTNING,
|
||||||
|
AIRPODS_PRO_2_USBC,
|
||||||
|
AIRPODS_PRO_3,
|
||||||
|
AIRPODS_MAX_LIGHTNING,
|
||||||
|
AIRPODS_MAX_USBC,
|
||||||
|
AIRPODS_MAX_2,
|
||||||
|
UNKNOWN;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromModelNumber(modelNumber: String): AirPodsModel {
|
||||||
|
return AirPodsSpecs.specs.entries.firstOrNull { (_, spec) ->
|
||||||
|
spec.modelNumbers.contains(modelNumber)
|
||||||
|
}?.key ?: UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AirPodsSpec(
|
||||||
|
val modelNumbers: Set<String>,
|
||||||
|
val name: String,
|
||||||
|
val displayName: String,
|
||||||
|
val components: Set<ComponentSpec> = emptySet(),
|
||||||
|
val genericIconName: String = "AirPodsPro3",
|
||||||
|
@DrawableRes val primaryImageRes: Int = R.drawable.img_airpods_pro_2_buds,
|
||||||
|
@DrawableRes val caseImageRes: Int? = R.drawable.img_airpods_pro_2_case,
|
||||||
|
val baseCapabilities: Set<BaseCapability>,
|
||||||
|
)
|
||||||
|
|
||||||
|
object AirPodsSpecs {
|
||||||
|
internal val specs = mapOf(
|
||||||
|
AirPodsModel.AIRPODS_1 to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A1523", "A1722"),
|
||||||
|
name = "AirPods 1",
|
||||||
|
displayName = "AirPods1",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.LEFT,
|
||||||
|
iconName = "LeftCircleFill",
|
||||||
|
label = "Left",
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.RIGHT,
|
||||||
|
iconName = "RightCircleFill",
|
||||||
|
label = "Right"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.CASE,
|
||||||
|
iconName = "AirPods1Case",
|
||||||
|
label = "Charging Case"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPods",
|
||||||
|
baseCapabilities = emptySet()
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_2 to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A2032", "A2031"),
|
||||||
|
name = "AirPods 2",
|
||||||
|
displayName = "AirPods",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.LEFT,
|
||||||
|
iconName = "LeftCircleFill",
|
||||||
|
label = "Left"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.RIGHT,
|
||||||
|
iconName = "RightCircleFill",
|
||||||
|
label = "Right"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.CASE,
|
||||||
|
iconName = "AirPods2Case",
|
||||||
|
label = "Charging Case"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPods",
|
||||||
|
baseCapabilities = emptySet()
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_3 to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A2565", "A2564"),
|
||||||
|
name = "AirPods 3",
|
||||||
|
displayName = "AirPods",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.LEFT,
|
||||||
|
iconName = "LeftCircleFill",
|
||||||
|
label = "Left"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.RIGHT,
|
||||||
|
iconName = "RightCircleFill",
|
||||||
|
label = "Right"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.CASE,
|
||||||
|
iconName = "AirPods3Case",
|
||||||
|
label = "Charging Case"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPods3",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.HEAD_GESTURES
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_4 to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A3053", "A3050", "A3054"),
|
||||||
|
name = "AirPods 4",
|
||||||
|
displayName = "AirPods",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.LEFT,
|
||||||
|
iconName = "AirPods4Left",
|
||||||
|
label = "Left"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.RIGHT,
|
||||||
|
iconName = "AirPods4Right",
|
||||||
|
label = "Right"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.CASE,
|
||||||
|
iconName = "AirPods4Case",
|
||||||
|
label = "Charging Case"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPods4",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.HEAD_GESTURES,
|
||||||
|
BaseCapability.SLEEP_DETECTION,
|
||||||
|
BaseCapability.ADAPTIVE_VOLUME
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_4_ANC to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A3056", "A3055", "A3057"),
|
||||||
|
name = "AirPods 4 (ANC)",
|
||||||
|
displayName = "AirPods",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.LEFT,
|
||||||
|
iconName = "AirPods4Left",
|
||||||
|
label = "Left"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.RIGHT,
|
||||||
|
iconName = "AirPods4Right",
|
||||||
|
label = "Right"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.CASE,
|
||||||
|
iconName = "AirPods4Case",
|
||||||
|
label = "Charging Case"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPods4",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.LISTENING_MODE,
|
||||||
|
BaseCapability.CONVERSATION_AWARENESS,
|
||||||
|
BaseCapability.HEAD_GESTURES,
|
||||||
|
BaseCapability.ADAPTIVE_AUDIO,
|
||||||
|
BaseCapability.SLEEP_DETECTION,
|
||||||
|
BaseCapability.ADAPTIVE_VOLUME,
|
||||||
|
BaseCapability.STEM_CONFIG
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_PRO_1 to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A2084", "A2083"),
|
||||||
|
name = "AirPods Pro 1",
|
||||||
|
displayName = "AirPods Pro",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.LEFT,
|
||||||
|
iconName = "AirPodsPro1Left",
|
||||||
|
label = "Left"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.RIGHT,
|
||||||
|
iconName = "AirPodsPro1Right",
|
||||||
|
label = "Right"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.CASE,
|
||||||
|
iconName = "AirPodsPro1Case",
|
||||||
|
label = "Charging Case"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPodsPro2",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.LISTENING_MODE
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_PRO_2_LIGHTNING to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A2931", "A2699", "A2698"),
|
||||||
|
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
|
||||||
|
displayName = "AirPods Pro",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.LEFT,
|
||||||
|
iconName = "AirPodsPro2Left",
|
||||||
|
label = "Left"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.RIGHT,
|
||||||
|
iconName = "AirPodsPro2Right",
|
||||||
|
label = "Right"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.CASE,
|
||||||
|
iconName = "AirPodsPro2Case",
|
||||||
|
label = "Charging Case"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPodsPro2",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.LISTENING_MODE,
|
||||||
|
BaseCapability.CONVERSATION_AWARENESS,
|
||||||
|
BaseCapability.STEM_CONFIG,
|
||||||
|
BaseCapability.LOUD_SOUND_REDUCTION,
|
||||||
|
BaseCapability.SLEEP_DETECTION,
|
||||||
|
BaseCapability.HEARING_AID,
|
||||||
|
BaseCapability.ADAPTIVE_AUDIO,
|
||||||
|
BaseCapability.ADAPTIVE_VOLUME,
|
||||||
|
BaseCapability.SWIPE_FOR_VOLUME,
|
||||||
|
BaseCapability.HEAD_GESTURES
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_PRO_2_USBC to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A3047", "A3048", "A3049"),
|
||||||
|
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
|
||||||
|
displayName = "AirPods Pro",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.LEFT,
|
||||||
|
iconName = "AirPodsPro2Left",
|
||||||
|
label = "Left"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.RIGHT,
|
||||||
|
iconName = "AirPodsPro2Right",
|
||||||
|
label = "Right"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.CASE,
|
||||||
|
iconName = "AirPodsPro2Case",
|
||||||
|
label = "Charging Case"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPodsPro2",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.LISTENING_MODE,
|
||||||
|
BaseCapability.CONVERSATION_AWARENESS,
|
||||||
|
BaseCapability.STEM_CONFIG,
|
||||||
|
BaseCapability.LOUD_SOUND_REDUCTION,
|
||||||
|
BaseCapability.SLEEP_DETECTION,
|
||||||
|
BaseCapability.HEARING_AID,
|
||||||
|
BaseCapability.ADAPTIVE_AUDIO,
|
||||||
|
BaseCapability.ADAPTIVE_VOLUME,
|
||||||
|
BaseCapability.SWIPE_FOR_VOLUME,
|
||||||
|
BaseCapability.HEAD_GESTURES
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_PRO_3 to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A3063", "A3064", "A3065"),
|
||||||
|
name = "AirPods Pro 3",
|
||||||
|
displayName = "AirPods Pro",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.LEFT,
|
||||||
|
iconName = "AirPodsPro3Left",
|
||||||
|
label = "Left"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.RIGHT,
|
||||||
|
iconName = "AirPodsPro3Right",
|
||||||
|
label = "Right"
|
||||||
|
),
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.CASE,
|
||||||
|
iconName = "AirPodsPro3Case",
|
||||||
|
label = "Charging Case"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPodsPro3",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.LISTENING_MODE,
|
||||||
|
BaseCapability.CONVERSATION_AWARENESS,
|
||||||
|
BaseCapability.HEAD_GESTURES,
|
||||||
|
BaseCapability.STEM_CONFIG,
|
||||||
|
BaseCapability.LOUD_SOUND_REDUCTION,
|
||||||
|
BaseCapability.PPE,
|
||||||
|
BaseCapability.SLEEP_DETECTION,
|
||||||
|
BaseCapability.HEARING_AID,
|
||||||
|
BaseCapability.ADAPTIVE_AUDIO,
|
||||||
|
BaseCapability.ADAPTIVE_VOLUME,
|
||||||
|
BaseCapability.SWIPE_FOR_VOLUME,
|
||||||
|
BaseCapability.HRM
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_MAX_LIGHTNING to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A2096"),
|
||||||
|
name = "AirPods Max (Lightning)",
|
||||||
|
displayName = "AirPods Max",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.HEADSET,
|
||||||
|
iconName = "AirPodsMax",
|
||||||
|
label = "Headset"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
genericIconName = "AirPodsMax",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.LISTENING_MODE,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_MAX_USBC to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A3184"),
|
||||||
|
name = "AirPods Max (USB-C)",
|
||||||
|
displayName = "AirPods Max",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.HEADSET,
|
||||||
|
iconName = "AirPodsMax",
|
||||||
|
label = "Headset"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
genericIconName = "AirPodsMax",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.LISTENING_MODE,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.AIRPODS_MAX_2 to AirPodsSpec(
|
||||||
|
modelNumbers = setOf("A3454"),
|
||||||
|
name = "AirPods Max 2",
|
||||||
|
displayName = "AirPods Max 2",
|
||||||
|
components = setOf(
|
||||||
|
ComponentSpec(
|
||||||
|
type = DeviceComponent.HEADSET,
|
||||||
|
iconName = "AirPodsMax",
|
||||||
|
label = "Headset"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
genericIconName = "AirPodsMax2",
|
||||||
|
baseCapabilities = setOf(
|
||||||
|
BaseCapability.LISTENING_MODE,
|
||||||
|
BaseCapability.CONVERSATION_AWARENESS,
|
||||||
|
BaseCapability.LOUD_SOUND_REDUCTION,
|
||||||
|
BaseCapability.ADAPTIVE_AUDIO,
|
||||||
|
BaseCapability.ADAPTIVE_VOLUME,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
AirPodsModel.UNKNOWN to AirPodsSpec(
|
||||||
|
modelNumbers = emptySet(),
|
||||||
|
name = "Unknown AirPods",
|
||||||
|
displayName = "Unknown AirPods",
|
||||||
|
components = emptySet(),
|
||||||
|
genericIconName = "AirPods1",
|
||||||
|
baseCapabilities = emptySet()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fun getSpec(model: AirPodsModel): AirPodsSpec = specs[model] ?: specs[AirPodsModel.UNKNOWN]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class BaseCapability {
|
||||||
|
LISTENING_MODE,
|
||||||
|
CONVERSATION_AWARENESS,
|
||||||
|
STEM_CONFIG,
|
||||||
|
HEAD_GESTURES,
|
||||||
|
LOUD_SOUND_REDUCTION,
|
||||||
|
PPE,
|
||||||
|
SLEEP_DETECTION,
|
||||||
|
HEARING_AID,
|
||||||
|
ADAPTIVE_AUDIO,
|
||||||
|
ADAPTIVE_VOLUME,
|
||||||
|
SWIPE_FOR_VOLUME,
|
||||||
|
HRM
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package me.kavishdevar.librepods.devices
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
|
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||||
|
import me.kavishdevar.librepods.bluetooth.att.ATTManager
|
||||||
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
private const val TAG = "AppleDevice"
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
class AppleDevice(
|
||||||
|
override val bluetoothAdapter: BluetoothAdapter,
|
||||||
|
override val bluetoothDevice: BluetoothDevice,
|
||||||
|
currentState: ConnectionState
|
||||||
|
) : Device<AppleState, AppleSettings, AppleMetadata> {
|
||||||
|
override val macAddress = MacAddress(bluetoothDevice.address)
|
||||||
|
private val _state = MutableStateFlow(AppleState())
|
||||||
|
override val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _settings = MutableStateFlow(AppleSettings())
|
||||||
|
override val settings = _settings.asStateFlow()
|
||||||
|
|
||||||
|
private val _metadata = MutableStateFlow(AppleMetadata())
|
||||||
|
override val metadata = _metadata.asStateFlow()
|
||||||
|
|
||||||
|
private val _connectionState = MutableStateFlow(currentState)
|
||||||
|
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<AppleEvent>()
|
||||||
|
val events = _events.asSharedFlow()
|
||||||
|
|
||||||
|
private val _connectionNumber = MutableStateFlow(0)
|
||||||
|
override val connectionNumber = _connectionNumber.asStateFlow()
|
||||||
|
|
||||||
|
fun loadInitialState(state: AppleState, settings: AppleSettings, metadata: AppleMetadata) {
|
||||||
|
_state.value = state
|
||||||
|
_settings.value = settings
|
||||||
|
_metadata.value = metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
internal inline fun updateState(
|
||||||
|
transform: (AppleState) -> AppleState
|
||||||
|
) {
|
||||||
|
_state.update(transform)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal inline fun updateMetadata(
|
||||||
|
transform: (AppleMetadata) -> AppleMetadata
|
||||||
|
) {
|
||||||
|
_metadata.update(transform)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun emitEvent(event: AppleEvent) {
|
||||||
|
_events.emit(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
val aacp = AACPManager(this)
|
||||||
|
val att = ATTManager(this)
|
||||||
|
|
||||||
|
init {
|
||||||
|
updateMetadata {
|
||||||
|
it.copy(
|
||||||
|
name = bluetoothDevice.alias ?: bluetoothDevice.name ?: "Unknown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentState == ConnectionState.AVAILABLE) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun connect(): Boolean {
|
||||||
|
_connectionState.update {
|
||||||
|
ConnectionState.CONNECTING
|
||||||
|
}
|
||||||
|
|
||||||
|
val success = aacp.connect() // && att.connect()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
loudSoundReductionEnabled = readATTCharacteristic(ATTHandle.LOUD_SOUND_REDUCTION)?.getOrNull(0)?.toInt() == 1,
|
||||||
|
transparencyData = readATTCharacteristic(ATTHandle.TRANSPARENCY)?: byteArrayOf(),
|
||||||
|
hearingAidData = readATTCharacteristic(ATTHandle.HEARING_AID)?: byteArrayOf()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectionState.update {
|
||||||
|
if (success) ConnectionState.CONNECTED else ConnectionState.DISCONNECTED
|
||||||
|
}
|
||||||
|
_connectionNumber.update {
|
||||||
|
it + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disconnect() {
|
||||||
|
_connectionState.update {
|
||||||
|
ConnectionState.DISCONNECTING
|
||||||
|
}
|
||||||
|
|
||||||
|
aacp.disconnect()
|
||||||
|
// att.disconnect()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||||
|
try {
|
||||||
|
bluetoothDevice.disconnect()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "couldn't disconnect bluetooth device", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectionState.update {
|
||||||
|
ConnectionState.DISCONNECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setControlCommand(identifier: ControlCommandIdentifier, value: ByteArray): Boolean = aacp.sendControlCommand(identifier.value, value)
|
||||||
|
fun setControlCommand(identifier: ControlCommandIdentifier, value: Byte): Boolean = aacp.sendControlCommand(identifier.value, value)
|
||||||
|
fun setControlCommand(identifier: ControlCommandIdentifier, value: Int): Boolean = aacp.sendControlCommand(identifier.value, value)
|
||||||
|
fun setControlCommand(identifier: ControlCommandIdentifier, value: Boolean): Boolean = aacp.sendControlCommand(identifier.value, value)
|
||||||
|
|
||||||
|
suspend fun writeATTCharacteristic(handle: ATTHandle, value: ByteArray): Boolean {
|
||||||
|
val success = att.writeCharacteristic(handle, value)
|
||||||
|
if (success) {
|
||||||
|
when (handle) {
|
||||||
|
ATTHandle.LOUD_SOUND_REDUCTION -> _state.update { it.copy(loudSoundReductionEnabled = value.getOrNull(0)?.toInt() == 1) }
|
||||||
|
ATTHandle.TRANSPARENCY -> _state.update { it.copy(transparencyData = value) }
|
||||||
|
ATTHandle.HEARING_AID -> _state.update { it.copy(hearingAidData = value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
// can't use att notifications; keeping ATT connected causes airpods to disconnect every few seconds for some reason
|
||||||
|
// we will poll the characteristic every 1 seconds instead.
|
||||||
|
fun observeATTCharacteristic(handle: ATTHandle): Job = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
while (true) {
|
||||||
|
val value = readATTCharacteristic(handle)
|
||||||
|
if (value != null) {
|
||||||
|
when (handle) {
|
||||||
|
ATTHandle.LOUD_SOUND_REDUCTION -> _state.update { it.copy(loudSoundReductionEnabled = value.getOrNull(0)?.toInt() == 1) }
|
||||||
|
ATTHandle.TRANSPARENCY -> _state.update { it.copy(transparencyData = value) }
|
||||||
|
ATTHandle.HEARING_AID -> _state.update { it.copy(hearingAidData = value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(1000.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun readATTCharacteristic(handle: ATTHandle): ByteArray? = att.readCharacteristic(handle)
|
||||||
|
|
||||||
|
// TODO: handle recording session
|
||||||
|
fun startRecording() = aacp.requestMicrophoneStream()
|
||||||
|
fun stopRecording() = aacp.endMicrophoneStream()
|
||||||
|
|
||||||
|
fun toggleListeningMode(modeBit: Int) {
|
||||||
|
val currentByte = state.value.controlStates[ControlCommandIdentifier.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
|
||||||
|
}
|
||||||
|
setControlCommand(ControlCommandIdentifier.LISTENING_MODE_CONFIGS, newValue)
|
||||||
|
// sharedPreferences.edit { putInt("long_press_byte", newValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
_settings.update {
|
||||||
|
if (side.lowercase() == "left") it.copy(leftLongPressAction = action) else it.copy(rightLongPressAction = action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renameDevice(newName: String) {
|
||||||
|
aacp.sendRename(newName)
|
||||||
|
_metadata.update {
|
||||||
|
it.copy(name = newName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startHeadTracking() {
|
||||||
|
if (settings.value.alternateHeadTrackingPackets) {
|
||||||
|
aacp.sendStartAlternateHeadTracking()
|
||||||
|
} else {
|
||||||
|
aacp.sendStartHeadTracking()
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(headTrackingActive = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopHeadTracking() {
|
||||||
|
if (settings.value.alternateHeadTrackingPackets) {
|
||||||
|
aacp.sendStopAlternateHeadTracking()
|
||||||
|
} else {
|
||||||
|
aacp.sendStopHeadTracking()
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(headTrackingActive = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHeadGesturesEnabled(enabled: Boolean) {
|
||||||
|
_settings.update {
|
||||||
|
it.copy(headGesturesEnabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCustomEqEnabled(enabled: Boolean) {
|
||||||
|
aacp.setCustomEq(_state.value.customEq.copy(state = if(enabled) 2 else 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCustomEq(low: Int, mid: Int, high: Int) {
|
||||||
|
require(low in 0..100)
|
||||||
|
require(mid in 0..100)
|
||||||
|
require(high in 0..100)
|
||||||
|
aacp.setCustomEq(_state.value.customEq.copy(low = low, mid = mid, high = high))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun testHeadGestures() {
|
||||||
|
if (settings.value.alternateHeadTrackingPackets) {
|
||||||
|
aacp.sendStartAlternateHeadTracking()
|
||||||
|
} else {
|
||||||
|
aacp.sendStartHeadTracking()
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
detectHeadGestures = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendRawPacket(data: ByteArray): Boolean = aacp.sendRawPacket(data)
|
||||||
|
|
||||||
|
//// private val _cameraAction = MutableStateFlow(
|
||||||
|
//// sharedPreferences.getString("camera_action", null)
|
||||||
|
//// ?.let { value -> StemPressType.entries.find { it.name == value } })
|
||||||
|
////
|
||||||
|
//// val cameraAction: StateFlow<StemPressType?> = _cameraAction
|
||||||
|
////
|
||||||
|
//// fun setCameraAction(action: StemPressType?) {
|
||||||
|
//// sharedPreferences.edit {
|
||||||
|
//// if (action == null) remove("camera_action")
|
||||||
|
//// else putString("camera_action", action.name)
|
||||||
|
//// }
|
||||||
|
//// _cameraAction.value = 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package me.kavishdevar.librepods.devices
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppleMetadata(
|
||||||
|
override val name: String = "",
|
||||||
|
|
||||||
|
val model: AirPodsModel = AirPodsModel.UNKNOWN,
|
||||||
|
val modelName: String = "",
|
||||||
|
val modelNumber: String = "",
|
||||||
|
val manufacturer: String = "",
|
||||||
|
|
||||||
|
val serialNumber: String = "",
|
||||||
|
val leftSerialNumber: String = "",
|
||||||
|
val rightSerialNumber: String = "",
|
||||||
|
|
||||||
|
val version1: String = "",
|
||||||
|
val version2: String = "",
|
||||||
|
val version3: String = "",
|
||||||
|
|
||||||
|
val hardwareRevision: String = "",
|
||||||
|
val updaterIdentifier: String = "",
|
||||||
|
): DeviceMetadata {
|
||||||
|
override val iconName: String
|
||||||
|
get() = AirPodsSpecs.getSpec(model).genericIconName
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package me.kavishdevar.librepods.devices
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppleSettings(
|
||||||
|
val disconnectWhenNotWearing: Boolean = true, // disconnect_when_not_wearing
|
||||||
|
|
||||||
|
val cacheDisconnectedComponentBattery: Boolean = true,
|
||||||
|
|
||||||
|
val headGesturesEnabled: Boolean = true, // head_gestures_enabled
|
||||||
|
|
||||||
|
val leftLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // left_long_press_action
|
||||||
|
val rightLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // right_long_press_action
|
||||||
|
|
||||||
|
val showIslandPopup: Boolean = true, // show_island_popup
|
||||||
|
val showBottomSheetPopup: Boolean = true, // show_bottom_sheet_popup
|
||||||
|
|
||||||
|
val alternateHeadTrackingPackets: Boolean = true, // use_alternate_head_tracking_packets
|
||||||
|
|
||||||
|
val takeoverWhenDisconnected: Boolean = true, // takeover_when_disconnected
|
||||||
|
val takeoverWhenIdle: Boolean = true, // takeover_when_idle
|
||||||
|
val takeoverWhenMusic: Boolean = true, // takeover_when_music
|
||||||
|
val takeoverWhenCall: Boolean = true, // takeover_when_call
|
||||||
|
|
||||||
|
val takeoverWhenRingingCall: Boolean = true, // takeover_when_ringing_call
|
||||||
|
val takeoverWhenMediaStart: Boolean = true, // takeover_when_media_start
|
||||||
|
|
||||||
|
val conversationalAwarenessPauseMusicEnabled: Boolean = false, // conversational_awareness_pause_music
|
||||||
|
val relativeConversationalAwarenessVolumeEnabled: Boolean = true, // relative_conversational_awareness_volume
|
||||||
|
|
||||||
|
val conversationalAwarenessVolume: Float = 43f, // conversational_awareness_volume
|
||||||
|
|
||||||
|
): DeviceSettings
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package me.kavishdevar.librepods.devices
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacket
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSource
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSourceType
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ConnectedDevice
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
||||||
|
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
|
||||||
|
import me.kavishdevar.librepods.data.audio.MicrophoneState
|
||||||
|
import me.kavishdevar.librepods.data.recording.RecordingState
|
||||||
|
|
||||||
|
data class AppleState(
|
||||||
|
val isLocallyConnected: Boolean = false,
|
||||||
|
|
||||||
|
val owns: Boolean = true,
|
||||||
|
|
||||||
|
val componentState: Set<DeviceComponentState> = emptySet(),
|
||||||
|
|
||||||
|
val conversationalAwarenessState: Int = 0,
|
||||||
|
|
||||||
|
val capabilities: Set<CapabilityEntry> = emptySet(),
|
||||||
|
|
||||||
|
val battery: Set<Battery> = emptySet(),
|
||||||
|
val controlStates: Map<ControlCommandIdentifier, ByteArray> = emptyMap(),
|
||||||
|
|
||||||
|
val magicKeys: Map<MagicKeyType, ByteArray> = emptyMap(),
|
||||||
|
|
||||||
|
val headTrackingActive: Boolean = false,
|
||||||
|
val detectHeadGestures: Boolean = false,
|
||||||
|
|
||||||
|
val loudSoundReductionEnabled: Boolean = false,
|
||||||
|
val transparencyData: ByteArray = byteArrayOf(),
|
||||||
|
val hearingAidData: ByteArray = byteArrayOf(),
|
||||||
|
|
||||||
|
val customEq: CustomEq = CustomEq(1, 50, 50, 50),
|
||||||
|
|
||||||
|
val microphoneFrames: List<MicrophoneFrame> = emptyList(),
|
||||||
|
val microphoneState: MicrophoneState = MicrophoneState(),
|
||||||
|
val recordingState: RecordingState = RecordingState(),
|
||||||
|
|
||||||
|
val audioSource: AudioSource = AudioSource(MacAddress("00:00:00:00:00:00"), AudioSourceType.NONE),
|
||||||
|
var connectedDevices: List<ConnectedDevice> = emptyList(),
|
||||||
|
|
||||||
|
val leftIsPrimary: Boolean = true,
|
||||||
|
|
||||||
|
val headphoneAccomodation: FloatArray = FloatArray(8),
|
||||||
|
val headphoneAccomodationEnabledForMedia: Boolean = false,
|
||||||
|
val headphoneAccomodationEnabledForPhone: Boolean = false,
|
||||||
|
|
||||||
|
val aacpPackets: List<AACPPacket> = emptyList(),
|
||||||
|
): DeviceState
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package me.kavishdevar.librepods.devices
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothA2dp
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothProfile
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Context.RECEIVER_EXPORTED
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.ParcelUuid
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
|
import me.kavishdevar.librepods.bluetooth.createBluetoothSocket
|
||||||
|
|
||||||
|
sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
|
||||||
|
private val TAG: String
|
||||||
|
get() = "LibrePodsDevice<${macAddress.toRedactedString()}>"
|
||||||
|
|
||||||
|
val macAddress: MacAddress
|
||||||
|
|
||||||
|
val bluetoothAdapter: BluetoothAdapter
|
||||||
|
val bluetoothDevice: BluetoothDevice
|
||||||
|
|
||||||
|
val connectionState: StateFlow<ConnectionState>
|
||||||
|
|
||||||
|
val state: StateFlow<S>
|
||||||
|
val settings: StateFlow<T>
|
||||||
|
val metadata: StateFlow<M>
|
||||||
|
|
||||||
|
val connectionNumber: StateFlow<Int>
|
||||||
|
|
||||||
|
fun connect(): Boolean
|
||||||
|
|
||||||
|
fun disconnect()
|
||||||
|
|
||||||
|
fun createSocket(uuid: ParcelUuid, psm: Int) = createBluetoothSocket(
|
||||||
|
adapter = bluetoothAdapter,
|
||||||
|
device = bluetoothDevice,
|
||||||
|
uuid = uuid,
|
||||||
|
psm = psm
|
||||||
|
)
|
||||||
|
|
||||||
|
fun disableAudio(context: Context) {
|
||||||
|
disableA2dp(context)
|
||||||
|
disableHeadset(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableA2dp(context: Context) {
|
||||||
|
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
|
||||||
|
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
|
if (profile == BluetoothProfile.A2DP) {
|
||||||
|
try {
|
||||||
|
if (proxy.getConnectionState(bluetoothDevice) == BluetoothProfile.STATE_DISCONNECTED) {
|
||||||
|
Log.d(TAG, "Already disconnected from A2DP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val method = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
|
||||||
|
Log.d(TAG, "calling A2DP.setConnectionPolicy(0)")
|
||||||
|
method.invoke(proxy, bluetoothDevice, 0)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
|
}, BluetoothProfile.A2DP)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableHeadset(context: Context) {
|
||||||
|
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
|
||||||
|
if (context.checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
|
if (profile == BluetoothProfile.HEADSET) {
|
||||||
|
try {
|
||||||
|
val method = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
|
||||||
|
Log.d(TAG, "calling HEADSET.setConnectionPolicy(0)")
|
||||||
|
method.invoke(proxy, bluetoothDevice, 0)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
|
}, BluetoothProfile.HEADSET)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "not disconnecting HEADSET, no MODIFIY_PHONE_STATE permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableAudio(context: Context) {
|
||||||
|
enableA2dp(context)
|
||||||
|
enableHeadset(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableA2dp(context: Context) {
|
||||||
|
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
|
||||||
|
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
|
if (profile == BluetoothProfile.A2DP) {
|
||||||
|
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
try {
|
||||||
|
val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
|
||||||
|
Log.d(TAG, "calling A2DP.setConnectionPolicy(100)")
|
||||||
|
policyMethod.invoke(proxy, bluetoothDevice, 100)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Log.i(TAG, "not setting connection policy for A2DP, no BLUETOOTH_PRIVILEGED permission. just called connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
|
}, BluetoothProfile.A2DP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableHeadset(context: Context) {
|
||||||
|
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
|
||||||
|
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
|
if (profile == BluetoothProfile.HEADSET) {
|
||||||
|
if (context.checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
try {
|
||||||
|
val policyMethod = proxy.javaClass.getMethod(
|
||||||
|
"setConnectionPolicy",
|
||||||
|
BluetoothDevice::class.java,
|
||||||
|
Int::class.java
|
||||||
|
)
|
||||||
|
Log.d(TAG, "calling HEADSET.setConnectionPolicy(100)")
|
||||||
|
policyMethod.invoke(proxy, bluetoothDevice, 100)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "not setting connection policy for HEADSET, no MODIFIY_PHONE_STATE permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
|
}, BluetoothProfile.HEADSET)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectAudio(context: Context) {
|
||||||
|
connectA2dp(context)
|
||||||
|
connectHeadset(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectA2dp(context: Context) {
|
||||||
|
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
|
||||||
|
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
|
if (profile == BluetoothProfile.A2DP) {
|
||||||
|
try {
|
||||||
|
val connectMethod = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
|
||||||
|
connectMethod.invoke(proxy, bluetoothDevice)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
|
}, BluetoothProfile.A2DP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectHeadset(context: Context) {
|
||||||
|
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
|
||||||
|
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
|
if (profile == BluetoothProfile.HEADSET) {
|
||||||
|
try {
|
||||||
|
val connectMethod = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
|
||||||
|
connectMethod.invoke(proxy, bluetoothDevice)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
|
}, BluetoothProfile.HEADSET)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnectAudio(context: Context) {
|
||||||
|
disconnectA2dp(context)
|
||||||
|
disconnectHeadset(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnectA2dp(context: Context) {
|
||||||
|
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
|
||||||
|
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
|
if (profile == BluetoothProfile.A2DP) {
|
||||||
|
try {
|
||||||
|
val disconnectMethod = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
|
||||||
|
disconnectMethod.invoke(proxy, bluetoothDevice)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
|
}, BluetoothProfile.A2DP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnectHeadset(context: Context) {
|
||||||
|
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||||
|
|
||||||
|
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
|
if (profile == BluetoothProfile.HEADSET) {
|
||||||
|
try {
|
||||||
|
val disconnectMethod = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
|
||||||
|
disconnectMethod.invoke(proxy, bluetoothDevice)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
|
}, BluetoothProfile.HEADSET)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun waitForA2dpConnection(
|
||||||
|
context: Context,
|
||||||
|
onConnected: () -> Unit
|
||||||
|
): BroadcastReceiver {
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val state = intent.getIntExtra(
|
||||||
|
BluetoothProfile.EXTRA_STATE,
|
||||||
|
BluetoothProfile.STATE_DISCONNECTED
|
||||||
|
)
|
||||||
|
val previousState = intent.getIntExtra(
|
||||||
|
BluetoothProfile.EXTRA_PREVIOUS_STATE,
|
||||||
|
BluetoothProfile.STATE_DISCONNECTED
|
||||||
|
)
|
||||||
|
val device = intent.getParcelableExtra(
|
||||||
|
BluetoothDevice.EXTRA_DEVICE,
|
||||||
|
BluetoothDevice::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
state == BluetoothProfile.STATE_CONNECTED &&
|
||||||
|
previousState != BluetoothProfile.STATE_CONNECTED &&
|
||||||
|
device?.address == macAddress.value
|
||||||
|
) {
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
onConnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.registerReceiver(
|
||||||
|
receiver,
|
||||||
|
IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED),
|
||||||
|
RECEIVER_EXPORTED
|
||||||
|
)
|
||||||
|
|
||||||
|
return receiver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceState
|
||||||
|
interface DeviceSettings
|
||||||
|
interface DeviceMetadata {
|
||||||
|
val name: String
|
||||||
|
val iconName: String
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package me.kavishdevar.librepods.devices
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import me.kavishdevar.librepods.utils.redactMac
|
||||||
|
|
||||||
|
// TODO: differentiate between bluetooth connected and oem-specific stuff connected. availiable is reserved for BLE based things, hence not used bluetooth connected
|
||||||
|
enum class ConnectionState {
|
||||||
|
DISCONNECTED,
|
||||||
|
DISCONNECTING,
|
||||||
|
AVAILABLE,
|
||||||
|
CONNECTING,
|
||||||
|
CONNECTED,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BatteryComponent {
|
||||||
|
LEFT,
|
||||||
|
RIGHT,
|
||||||
|
CASE,
|
||||||
|
HEADSET;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromAirPodsByte(value: Byte): BatteryComponent {
|
||||||
|
return when (value.toInt()) {
|
||||||
|
1 -> HEADSET
|
||||||
|
4 -> LEFT
|
||||||
|
2 -> RIGHT
|
||||||
|
8 -> CASE
|
||||||
|
else -> throw IllegalArgumentException("Unknown battery component value: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BatteryStatus {
|
||||||
|
UNKNOWN, // got this from pro 3 when one of the airpods had crashed and the other was still connected
|
||||||
|
CHARGING,
|
||||||
|
NOT_CHARGING,
|
||||||
|
DISCONNECTED,
|
||||||
|
OPTIMIZED_CHARGING;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromAirPodsByte(value: Byte): BatteryStatus {
|
||||||
|
return when (value.toInt()) {
|
||||||
|
0 -> UNKNOWN
|
||||||
|
1 -> CHARGING
|
||||||
|
2 -> NOT_CHARGING
|
||||||
|
4 -> DISCONNECTED
|
||||||
|
5 -> OPTIMIZED_CHARGING
|
||||||
|
else -> throw IllegalArgumentException("Unknown battery status value: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Battery(
|
||||||
|
val component: BatteryComponent,
|
||||||
|
val level: Int,
|
||||||
|
val status: BatteryStatus
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
// TODO: use this for battery too. BatteryComponent is very redundant
|
||||||
|
enum class DeviceComponent {
|
||||||
|
LEFT,
|
||||||
|
RIGHT,
|
||||||
|
CASE,
|
||||||
|
HEADSET
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ComponentStatus {
|
||||||
|
IN_EAR,
|
||||||
|
OUT_OF_EAR,
|
||||||
|
IN_CASE,
|
||||||
|
DISCONNECTED;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromAirPodsByte(value: Byte): ComponentStatus {
|
||||||
|
return when (value.toInt()) {
|
||||||
|
0 -> IN_EAR
|
||||||
|
1 -> OUT_OF_EAR
|
||||||
|
2 -> IN_CASE
|
||||||
|
3 -> DISCONNECTED
|
||||||
|
else -> throw IllegalArgumentException("Unknown device status value: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toAirPodsByte(): Byte {
|
||||||
|
return when (this) {
|
||||||
|
IN_EAR -> 0
|
||||||
|
OUT_OF_EAR -> 1
|
||||||
|
IN_CASE -> 2
|
||||||
|
DISCONNECTED -> 3
|
||||||
|
}.toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DeviceComponentState(
|
||||||
|
val component: DeviceComponent,
|
||||||
|
val status: ComponentStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class PacketDestination {
|
||||||
|
DEVICE,
|
||||||
|
HOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class NoiseControlMode {
|
||||||
|
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
|
||||||
|
}
|
||||||
+73
-51
@@ -18,12 +18,9 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods.presentation.activities
|
||||||
|
|
||||||
// import me.kavishdevar.librepods.screens.Onboarding
|
|
||||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
//import dagger.hilt.android.AndroidEntryPoint
|
//import dagger.hilt.android.AndroidEntryPoint
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
@@ -31,72 +28,76 @@ import android.content.Context
|
|||||||
import android.content.Context.MODE_PRIVATE
|
import android.content.Context.MODE_PRIVATE
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.core.view.WindowCompat
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
|
||||||
import com.google.android.play.core.review.ReviewManagerFactory
|
import com.google.android.play.core.review.ReviewManagerFactory
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.LibrePodsApplication
|
||||||
import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
|
import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
|
||||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
import me.kavishdevar.librepods.presentation.theme.NightTheme
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.LibrePodsService
|
||||||
import me.kavishdevar.librepods.utils.XposedState
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
lateinit var serviceConnection: ServiceConnection
|
lateinit var serviceConnection: ServiceConnection
|
||||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||||
lateinit var testReviewReceiver: BroadcastReceiver
|
//lateinit var testReviewReceiver: BroadcastReceiver
|
||||||
|
|
||||||
//@AndroidEntryPoint
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
init {
|
init {
|
||||||
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||||
System.loadLibrary("l2c_fcr_hook")
|
System.loadLibrary("fluoride_hooks")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val appDataRepository by lazy { (application as LibrePodsApplication).appDataRepository }
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
val settings by appDataRepository.settings.collectAsState()
|
||||||
val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) }
|
|
||||||
|
|
||||||
val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
|
val systemDarkTheme = isSystemInDarkTheme()
|
||||||
when (key) {
|
|
||||||
"m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true)
|
val darkTheme = when (settings.nightMode) {
|
||||||
}
|
NightTheme.Dark -> true
|
||||||
|
NightTheme.Light -> false
|
||||||
|
NightTheme.System -> systemDarkTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
val view = LocalView.current
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
|
val window = (view.context as Activity).window
|
||||||
onDispose {
|
|
||||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
|
LaunchedEffect(darkTheme) {
|
||||||
}
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
LibrePodsTheme(
|
LibrePodsTheme(
|
||||||
m3eEnabled = m3eEnabled.value
|
designSystem = settings.designSystem,
|
||||||
|
darkTheme = darkTheme
|
||||||
) {
|
) {
|
||||||
// For demo screenshots
|
// For demo screenshots
|
||||||
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||||
@@ -121,7 +122,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||||
}
|
}
|
||||||
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
|
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,17 +142,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Main() {
|
fun Main() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
|
||||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
val librepodsService = remember { mutableStateOf<LibrePodsService?>(null) }
|
||||||
|
|
||||||
val airPodsViewModel: AirPodsViewModel = viewModel()
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (BuildConfig.PLAY_BUILD) {
|
if (BuildConfig.PLAY_BUILD) {
|
||||||
@@ -184,19 +179,50 @@ fun Main() {
|
|||||||
val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}"
|
val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}"
|
||||||
val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false)
|
val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false)
|
||||||
|
|
||||||
|
val devicesState = remember(librepodsService.value) {
|
||||||
|
librepodsService.value?.devices ?: MutableStateFlow(emptyMap())
|
||||||
|
}.collectAsState()
|
||||||
|
|
||||||
|
DisposableEffect(onboardingComplete) {
|
||||||
|
if (!onboardingComplete) {
|
||||||
|
onDispose { }
|
||||||
|
} else {
|
||||||
|
val connection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
val binder = service as LibrePodsService.LocalBinder
|
||||||
|
librepodsService.value = binder.getService()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
librepodsService.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.startForegroundService(Intent(context, LibrePodsService::class.java))
|
||||||
|
|
||||||
|
context.bindService(
|
||||||
|
Intent(context, LibrePodsService::class.java),
|
||||||
|
connection,
|
||||||
|
Context.BIND_AUTO_CREATE
|
||||||
|
)
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
try {
|
||||||
|
context.unbindService(connection)
|
||||||
|
} catch(e: Exception) {
|
||||||
|
Log.w("Main", "Error while unbinding service: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun bindService() {
|
fun bindService() {
|
||||||
context.startForegroundService(Intent(context, AirPodsService::class.java))
|
context.startForegroundService(Intent(context, LibrePodsService::class.java))
|
||||||
serviceConnection = object: ServiceConnection {
|
serviceConnection = object: ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
val binder = service as AirPodsService.LocalBinder
|
val binder = service as LibrePodsService.LocalBinder
|
||||||
val service = binder.getService()
|
val service = binder.getService()
|
||||||
airPodsService.value = service
|
librepodsService.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")) {
|
if (!sharedPreferences.contains("first_connection_successful_time")) {
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -206,21 +232,17 @@ fun Main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
airPodsService.value = null
|
librepodsService.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.bindService(
|
context.bindService(
|
||||||
Intent(context, AirPodsService::class.java),
|
Intent(context, LibrePodsService::class.java),
|
||||||
serviceConnection,
|
serviceConnection,
|
||||||
Context.BIND_AUTO_CREATE
|
Context.BIND_AUTO_CREATE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onboardingComplete) {
|
|
||||||
bindService()
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationRoot(
|
NavigationRoot(
|
||||||
showReleaseNotes = !releaseNotesShown,
|
showReleaseNotes = !releaseNotesShown,
|
||||||
updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } },
|
updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } },
|
||||||
@@ -229,7 +251,7 @@ fun Main() {
|
|||||||
sharedPreferences.edit { putBoolean("onboarding_complete", true) }
|
sharedPreferences.edit { putBoolean("onboarding_complete", true) }
|
||||||
bindService()
|
bindService()
|
||||||
},
|
},
|
||||||
airPodsViewModel = airPodsViewModel
|
devicesState = devicesState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.activities
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import me.kavishdevar.librepods.LibrePodsApplication
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
||||||
|
import me.kavishdevar.librepods.devices.Device
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
|
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.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.NightTheme
|
||||||
|
import me.kavishdevar.librepods.services.LibrePodsService
|
||||||
|
|
||||||
|
class NoiseControlWidgetConfigurationActivity: ComponentActivity() {
|
||||||
|
private var service by mutableStateOf<LibrePodsService?>(null)
|
||||||
|
private var bound = false
|
||||||
|
|
||||||
|
private val serviceConnection = object: ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||||
|
service = (binder as LibrePodsService.LocalBinder).getService()
|
||||||
|
bound = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
service = null
|
||||||
|
bound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
|
val appDataRepository by lazy { (application as LibrePodsApplication).appDataRepository }
|
||||||
|
val widgetConfigRepository by lazy { (application as LibrePodsApplication).widgetConfigRepository }
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
Intent(this, LibrePodsService::class.java).also { intent ->
|
||||||
|
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
super.onStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
if (bound) {
|
||||||
|
unbindService(serviceConnection)
|
||||||
|
bound = false
|
||||||
|
}
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
|
||||||
|
appWidgetId = intent.getIntExtra(
|
||||||
|
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||||
|
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
)
|
||||||
|
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val settings by appDataRepository.settings.collectAsState()
|
||||||
|
|
||||||
|
val darkTheme = when (settings.nightMode) {
|
||||||
|
NightTheme.Dark -> true
|
||||||
|
NightTheme.Light -> false
|
||||||
|
NightTheme.System -> isSystemInDarkTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
LibrePodsTheme(
|
||||||
|
designSystem = settings.designSystem,
|
||||||
|
darkTheme = darkTheme
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
service?.let {
|
||||||
|
val devices by it.devices.collectAsState()
|
||||||
|
|
||||||
|
val widgetConfigs by widgetConfigRepository.widgetConfigs.collectAsState()
|
||||||
|
|
||||||
|
val widgetConfig = widgetConfigs.find { config -> config.appWidgetId == appWidgetId }
|
||||||
|
|
||||||
|
WidgetDevicePickerContent(
|
||||||
|
devices = devices,
|
||||||
|
pickedDevice = widgetConfig?.macAddress,
|
||||||
|
onDevicePicked = { macAddress ->
|
||||||
|
val config = widgetConfig?.copy(macAddress = macAddress) ?: WidgetConfigEntity(
|
||||||
|
appWidgetId = appWidgetId,
|
||||||
|
macAddress = macAddress
|
||||||
|
)
|
||||||
|
widgetConfigRepository.setWidgetConfig(config)
|
||||||
|
},
|
||||||
|
onDoneClicked = {
|
||||||
|
val resultValue = Intent().apply {
|
||||||
|
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
}
|
||||||
|
setResult(RESULT_OK, resultValue)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
onCancelClicked = {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} ?: run {
|
||||||
|
Text(
|
||||||
|
text = "Service not available. Please ensure LibrePods is running and try again.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WidgetDevicePickerContent(
|
||||||
|
devices: Map<MacAddress, Device<*, *, *>>,
|
||||||
|
pickedDevice: MacAddress?,
|
||||||
|
onDevicePicked: (MacAddress) -> Unit,
|
||||||
|
onDoneClicked: () -> Unit,
|
||||||
|
onCancelClicked: () -> Unit
|
||||||
|
) {
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.configure_widget),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
if (devices.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No devices found. Please ensure a compatible device is paired with your phone and try again.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
StyledButton(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = onCancelClicked
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Cancel",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StyledList(title = stringResource(R.string.devices)) {
|
||||||
|
devices.forEach { device ->
|
||||||
|
val metadata by device.value.metadata.collectAsState()
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = metadata.name,
|
||||||
|
supportingText = device.key.value,
|
||||||
|
leadingContent = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.background(
|
||||||
|
if (pickedDevice == device.key) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
MaterialShapes.Circle.normalized().toShape()
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = LocalIcons.current.fromName(metadata.iconName) ?: LocalIcons.current.Headphones,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = if (pickedDevice == device.key) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onDevicePicked(device.key)
|
||||||
|
},
|
||||||
|
selected = pickedDevice == device.key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = onDoneClicked,
|
||||||
|
enabled = pickedDevice != null
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Done",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
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.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.icons.richText
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
|
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 = listOf(
|
||||||
|
richText(serialNumbers[0]),
|
||||||
|
richText("\\icon{LeftCircleFill} " + serialNumbers[1]),
|
||||||
|
richText("\\icon{RightCircleFill} " + serialNumbers[2]),
|
||||||
|
)
|
||||||
|
|
||||||
|
val serialNumber = remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
StyledList (title = stringResource(R.string.about)) {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.model_name),
|
||||||
|
supportingText = modelName
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.model_number),
|
||||||
|
supportingText = actualModel
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem (
|
||||||
|
contentText = stringResource(R.string.serial_number),
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = serialNumbers[serialNumber.intValue].text,
|
||||||
|
inlineContent = serialNumbers[serialNumber.intValue].inlineContent,
|
||||||
|
style = if (LocalDesignSystem.current == DesignSystem.Apple) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(0.7f),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (version != null) {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.version),
|
||||||
|
supportingText = version,
|
||||||
|
onClick = navigateToVersion,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.version),
|
||||||
|
onClick = navigateToVersion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AboutCardPreview() {
|
||||||
|
LibrePodsTheme(
|
||||||
|
designSystem = DesignSystem.Apple
|
||||||
|
) {
|
||||||
|
AboutCard(
|
||||||
|
modelName = "AirPods Pro",
|
||||||
|
actualModel = "A2084",
|
||||||
|
serialNumbers = listOf("123456789", "987654321", "567890123"),
|
||||||
|
version = "9141234",
|
||||||
|
navigateToVersion = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -29,24 +29,24 @@ fun AppInfoCard(
|
|||||||
) {
|
) {
|
||||||
StyledList(title = stringResource(R.string.about)) {
|
StyledList(title = stringResource(R.string.about)) {
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
name = stringResource(R.string.version),
|
contentText = stringResource(R.string.version),
|
||||||
description = BuildConfig.VERSION_NAME,
|
supportingText = BuildConfig.VERSION_NAME,
|
||||||
onClick = navigateToReleaseNotesScreen
|
onClick = navigateToReleaseNotesScreen
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
name = stringResource(R.string.version_code),
|
contentText = stringResource(R.string.version_code),
|
||||||
description = BuildConfig.VERSION_CODE.toString(),
|
supportingText = BuildConfig.VERSION_CODE.toString(),
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
name = stringResource(R.string.flavor),
|
contentText = stringResource(R.string.flavor),
|
||||||
description = BuildConfig.FLAVOR,
|
supportingText = BuildConfig.FLAVOR,
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
name = stringResource(R.string.build_type),
|
contentText = stringResource(R.string.build_type),
|
||||||
description = BuildConfig.BUILD_TYPE,
|
supportingText = BuildConfig.BUILD_TYPE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -82,14 +82,14 @@ fun AudioSettings(
|
|||||||
|
|
||||||
if (adaptiveAudioCapability) {
|
if (adaptiveAudioCapability) {
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
name = stringResource(R.string.adaptive_audio),
|
contentText = stringResource(R.string.adaptive_audio),
|
||||||
onClick = navigateToAdaptiveStrength,
|
onClick = navigateToAdaptiveStrength,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (customEqCapability) {
|
if (customEqCapability) {
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
name = stringResource(R.string.equalizer),
|
contentText = stringResource(R.string.equalizer),
|
||||||
onClick = navigateToEqualizer,
|
onClick = navigateToEqualizer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user