Compare commits

..

9 Commits

Author SHA1 Message Date
Kavish Devar 53679cc902 android: bump version
just doing this because play looks "version code up, let's update!" even if it's not installed from Play, and I can't make another rc1.
2026-09-01 00:13:05 +05:30
Kavish Devar b4cacb89f1 android: adjust pre-first connect text style in m3e 2026-08-27 15:50:48 +05:30
Kavish Devar cf6d226fca android: add demo to demo airpods name 2026-08-27 15:44:10 +05:30
Kavish Devar 4c7a3cb203 android: don't force connectionSuccessful to true in demo state 2026-08-27 15:41:02 +05:30
Kavish Devar 1783bb742e android: remove bypass when going back to 'not supported' page in onboarding 2026-08-27 15:29:43 +05:30
Kavish Devar 3cc4d92d62 android: bump version 2026-08-27 15:26:19 +05:30
Kavish Devar 95ecc0eb3d android: show bypassed message on settings page before first connect 2026-08-27 15:26:10 +05:30
Kavish Devar a7537f2361 android: make bypass compatibility check button scroll to next onboarding page 2026-08-27 15:24:52 +05:30
Kavish Devar 21babd819f android: hide reconnect button in m3e when no successful aacp connection 2026-08-27 15:16:28 +05:30
319 changed files with 18958 additions and 28095 deletions
+6 -43
View File
@@ -6,10 +6,6 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.room3)
alias(libs.plugins.protobuf)
// alias(libs.plugins.hilt)
id("kotlin-parcelize")
}
@@ -28,35 +24,10 @@ val releaseSigningAvailable = listOf(
"RELEASE_KEY_PASSWORD"
).all { props[it]?.toString()?.isNotBlank() == true }
room3 {
schemaDirectory("$projectDir/schemas")
}
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java")
create("kotlin")
}
}
}
}
kotlin {
compilerOptions {
optIn.addAll(
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"kotlin.uuid.ExperimentalUuidApi",
"kotlinx.coroutines.FlowPreview",
"kotlinx.serialization.ExperimentalSerializationApi",
"kotlinx.coroutines.ExperimentalCoroutinesApi",
"dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi",
"com.google.accompanist.permissions.ExperimentalPermissionsApi"
optIn.add(
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi"
)
}
}
@@ -78,7 +49,7 @@ android {
defaultConfig {
applicationId = "me.kavishdevar.librepods"
targetSdk = 37
versionCode = 85
versionCode = 63
versionName = appVersionName
}
buildTypes {
@@ -154,8 +125,8 @@ android {
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions)
implementation(libs.androidx.compose.ui.text.google.fonts)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.foundation.layout)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -169,11 +140,12 @@ dependencies {
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.billing)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries.compose)
implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.backdrop)
// implementation(libs.hilt)
@@ -186,12 +158,6 @@ dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.navigationevent)
implementation(libs.androidx.room3.runtime)
ksp(libs.androidx.room3.compiler)
implementation(libs.kotlinx.serialization.cbor)
implementation(libs.protobuf.kotlin)
// compileOnly(files("../../../framework-classes.jar"))
implementation(libs.androidx.healthconnect.client)
}
aboutLibraries {
@@ -212,7 +178,6 @@ fun registerRootModuleZipTask(
flavor: String,
buildType: String
) = tasks.register<Zip>(name) {
description = "Zips the root module for the $flavor-$buildType variant."
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
dependsOn(variantTask)
@@ -248,7 +213,6 @@ val zipDebug = registerRootModuleZipTask(
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
description = "Collects release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
dependsOn(
zipRelease,
zipDebug,
@@ -277,6 +241,5 @@ val collect = tasks.register<Copy>("collectReleaseArtifacts") {
}
tasks.register("packageReleaseArtifacts") {
description = "Packages release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
dependsOn(collect)
}
@@ -1,221 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "c547604d916583c3b94c7bf1ac787fdf",
"entities": [
{
"tableName": "AppleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
"fields": [
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "settings",
"columnName": "settings",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "cache",
"columnName": "cache",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"macAddress"
]
}
},
{
"tableName": "AppSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nightMode",
"columnName": "nightMode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "designSystem",
"columnName": "designSystem",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useHighestRefreshRate",
"columnName": "useHighestRefreshRate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "debugMode",
"columnName": "debugMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleScanMode",
"columnName": "bleScanMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleReportDelay",
"columnName": "bleReportDelay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "swipeAnywhereForBack",
"columnName": "swipeAnywhereForBack",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "AppStateEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "isPremium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCompletedOnboarding",
"columnName": "hasCompletedOnboarding",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastVersionShown",
"columnName": "lastVersionShown",
"affinity": "TEXT"
},
{
"fieldPath": "hasConnectedToAACP",
"columnName": "hasConnectedToAACP",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstSuccessfulConnectionTime",
"columnName": "firstSuccessfulConnectionTime",
"affinity": "INTEGER"
},
{
"fieldPath": "reviewPrompted",
"columnName": "reviewPrompted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeUntilFOSSPremiumExpiry",
"columnName": "timeUntilFOSSPremiumExpiry",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "WidgetConfigEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
"fields": [
{
"fieldPath": "appWidgetId",
"columnName": "appWidgetId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"appWidgetId"
]
}
},
{
"tableName": "HeartRateSampleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bpm",
"columnName": "bpm",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c547604d916583c3b94c7bf1ac787fdf')"
]
}
}
@@ -1,221 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "721149fa5e2a13e82b73a83de312968a",
"entities": [
{
"tableName": "AppleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
"fields": [
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "settings",
"columnName": "settings",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "cache",
"columnName": "cache",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"macAddress"
]
}
},
{
"tableName": "AppSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nightMode",
"columnName": "nightMode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "designSystem",
"columnName": "designSystem",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useHighestRefreshRate",
"columnName": "useHighestRefreshRate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "debugMode",
"columnName": "debugMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleScanMode",
"columnName": "bleScanMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleReportDelay",
"columnName": "bleReportDelay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "swipeAnywhereForBack",
"columnName": "swipeAnywhereForBack",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "AppStateEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "isPremium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCompletedOnboarding",
"columnName": "hasCompletedOnboarding",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastVersionShown",
"columnName": "lastVersionShown",
"affinity": "TEXT"
},
{
"fieldPath": "hasConnectedToAACP",
"columnName": "hasConnectedToAACP",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstSuccessfulConnectionTime",
"columnName": "firstSuccessfulConnectionTime",
"affinity": "INTEGER"
},
{
"fieldPath": "reviewPrompted",
"columnName": "reviewPrompted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeUntilFOSSPremiumExpiry",
"columnName": "timeUntilFOSSPremiumExpiry",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "WidgetConfigEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
"fields": [
{
"fieldPath": "appWidgetId",
"columnName": "appWidgetId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"appWidgetId"
]
}
},
{
"tableName": "HeartRateSampleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bpm",
"columnName": "bpm",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
]
}
}
@@ -1,226 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "721149fa5e2a13e82b73a83de312968a",
"entities": [
{
"tableName": "AppleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
"fields": [
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "settings",
"columnName": "settings",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "cache",
"columnName": "cache",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"macAddress"
]
}
},
{
"tableName": "AppSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `overrideMaterialColor` INTEGER, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nightMode",
"columnName": "nightMode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "designSystem",
"columnName": "designSystem",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "overrideMaterialColor",
"columnName": "overrideMaterialColor",
"affinity": "INTEGER"
},
{
"fieldPath": "useHighestRefreshRate",
"columnName": "useHighestRefreshRate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "debugMode",
"columnName": "debugMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleScanMode",
"columnName": "bleScanMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleReportDelay",
"columnName": "bleReportDelay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "swipeAnywhereForBack",
"columnName": "swipeAnywhereForBack",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "AppStateEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "isPremium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCompletedOnboarding",
"columnName": "hasCompletedOnboarding",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastVersionShown",
"columnName": "lastVersionShown",
"affinity": "TEXT"
},
{
"fieldPath": "hasConnectedToAACP",
"columnName": "hasConnectedToAACP",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstSuccessfulConnectionTime",
"columnName": "firstSuccessfulConnectionTime",
"affinity": "INTEGER"
},
{
"fieldPath": "reviewPrompted",
"columnName": "reviewPrompted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeUntilFOSSPremiumExpiry",
"columnName": "timeUntilFOSSPremiumExpiry",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "WidgetConfigEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
"fields": [
{
"fieldPath": "appWidgetId",
"columnName": "appWidgetId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"appWidgetId"
]
}
},
{
"tableName": "HeartRateSampleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bpm",
"columnName": "bpm",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
]
}
}
+11 -37
View File
@@ -26,22 +26,15 @@
<uses-permission
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.MODIFY_AUDIO_ROUTING"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
<!-- android:maxSdkVersion="30" />-->
@@ -49,8 +42,6 @@
<!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
<application
android:name=".LibrePodsApplication"
android:allowBackup="true"
@@ -89,7 +80,7 @@
</receiver>
<activity
android:name=".presentation.activities.MainActivity"
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.LibrePods">
<intent-filter>
@@ -107,11 +98,16 @@
</activity>
<activity
android:name=".presentation.activities.NoiseControlWidgetConfigurationActivity"
android:exported="false" />
android:name=".QuickSettingsDialogActivity"
android:exported="false"
android:theme="@style/Theme.TransparentDialog"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:taskAffinity=""
/>
<service
android:name=".services.LibrePodsService"
android:name=".services.AirPodsService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="connectedDevice"
@@ -119,7 +115,7 @@
<service
android:name=".services.AirPodsQSService"
android:exported="true"
android:icon="@drawable/ic_airpods"
android:icon="@drawable/airpods"
android:label="ANC Mode"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
@@ -157,28 +153,6 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".presentation.activities.PrivacyPolicyActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".presentation.activities.PrivacyPolicyActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
</application>
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest>
+10 -10
View File
@@ -3,21 +3,21 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23)
add_library(hiddenapi SHARED
hiddenapi.cpp
add_library(bluetooth_socket SHARED
bluetooth_socket.cpp
)
target_compile_options(hiddenapi PRIVATE
target_compile_options(bluetooth_socket PRIVATE
-O2
-fvisibility=hidden
)
target_link_options(hiddenapi PRIVATE
target_link_options(bluetooth_socket PRIVATE
-Wl,--strip-all
-Wl,--gc-sections
)
target_link_libraries(hiddenapi
target_link_libraries(bluetooth_socket
android
log
)
@@ -25,8 +25,8 @@ target_link_libraries(hiddenapi
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
add_library(fluoride_hooks SHARED
fluoride_hooks.cpp
add_library(l2c_fcr_hook SHARED
l2c_fcr_hook.cpp
xz/xz_crc32.c
xz/xz_crc64.c
@@ -36,11 +36,11 @@ add_library(fluoride_hooks SHARED
xz/xz_dec_bcj.c
)
target_include_directories(fluoride_hooks PRIVATE
target_include_directories(l2c_fcr_hook PRIVATE
xz
)
target_compile_definitions(fluoride_hooks PRIVATE
target_compile_definitions(l2c_fcr_hook PRIVATE
XZ_DEC_X86
XZ_DEC_ARM
XZ_DEC_ARMTHUMB
@@ -51,7 +51,7 @@ target_compile_definitions(fluoride_hooks PRIVATE
XZ_DEC_CONCATENATED
)
target_link_libraries(fluoride_hooks
target_link_libraries(l2c_fcr_hook
android
log
)
@@ -41,7 +41,7 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
constexpr auto c6 = ENC("java/lang/String");
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
constexpr auto c8 = ENC("L");
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
JNIEnv* env;
getVm()->AttachCurrentThread(&env, nullptr);
-203
View File
@@ -1,203 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#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,3 +1,21 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include <android/log.h>
#include <cstring>
#include <string>
@@ -6,17 +24,60 @@
#include <unistd.h>
#include <sys/stat.h>
#include <elf.h>
#include <atomic>
#include <jni.h>
#include "l2c_fcr_hook.h"
extern "C" {
#include "xz.h"
#include "xz.h"
}
#define LOG_TAG "LibrePodsHook"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
static HookFunType hook_func = nullptr;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
static std::atomic<bool> enableSdpHook(false);
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
LOGI("fake_l2c_fcr_chk_chan_modes called");
uint8_t orig = 0;
if (original_l2c_fcr_chk_chan_modes)
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig);
return 1;
}
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
LOGI("fake_BTA_DmSetLocalDiRecord called");
if (original_BTA_DmSetLocalDiRecord &&
enableSdpHook.load(std::memory_order_relaxed))
original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
if (p_device_info) {
p_device_info->vendor = 0x004C;
p_device_info->vendor_id_source = 0x0001;
}
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle)
: BTA_FAILURE);
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info,
p_handle)
: BTA_FAILURE;
}
static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<uint8_t> &output) {
LOGI("decompressXZ called with input_size: %zu", input_size);
@@ -123,7 +184,7 @@ static uintptr_t getModuleBase(const char *name) {
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, name)) {
base = strtoull(line, nullptr, 16);
LOGI("getModuleBase: found base at 0x%x", base);
LOGI("getModuleBase: found base at 0x%lx", base);
break;
}
}
@@ -241,3 +302,134 @@ static uint64_t findSymbolOffset(const std::vector<uint8_t> &elf, const char *sy
LOGI("findSymbolOffset: no match found for %s", symbol_substring);
return 0;
}
static bool hookLibrary(const char *libname) {
LOGI("hookLibrary called with libname: %s", libname);
if (!hook_func) {
LOGE("hook_func not initialized");
return false;
}
std::string path;
if (!getLibraryPath(libname, path)) {
LOGE("Failed to locate %s", libname);
return false;
}
LOGI("hookLibrary: located path: %s", path.c_str());
int fd = open(path.c_str(), O_RDONLY);
if (fd < 0) {
LOGE("hookLibrary: open failed");
return false;
}
struct stat st{};
if (fstat(fd, &st) != 0) {
LOGE("hookLibrary: fstat failed");
close(fd);
return false;
}
LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
std::vector<uint8_t> file(st.st_size);
read(fd, file.data(), st.st_size);
close(fd);
auto *eh = reinterpret_cast<Elf64_Ehdr *>(file.data());
auto *shdr = reinterpret_cast<Elf64_Shdr *>(
file.data() + eh->e_shoff);
const char *shstr = reinterpret_cast<const char *>(
file.data() + shdr[eh->e_shstrndx].sh_offset);
uint64_t chk_offset = 0;
uint64_t sdp_offset = 0;
for (int i = 0; i < eh->e_shnum; ++i) {
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
LOGI("hookLibrary: found .gnu_debugdata section");
std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
std::vector<uint8_t> decompressed;
if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
} else {
LOGE("debugdata decompress failed");
}
break;
}
}
if (!chk_offset) {
LOGI("fallback dynsym chk");
chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes");
}
if (!sdp_offset) {
LOGI("fallback dynsym sdp");
sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord");
}
uintptr_t base = getModuleBase(libname);
if (!base) {
LOGE("hookLibrary: getModuleBase failed");
return false;
}
if (chk_offset) {
void *target = reinterpret_cast<void *>(base + chk_offset);
hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
(void **) &original_l2c_fcr_chk_chan_modes);
LOGI("hooked chk");
}
if (sdp_offset) {
void *target = reinterpret_cast<void *>(base + sdp_offset);
hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
(void **) &original_BTA_DmSetLocalDiRecord);
LOGI("hooked sdp");
}
return chk_offset || sdp_offset;
}
static void on_library_loaded(const char *name, void *) {
LOGI("on_library_loaded called with name: %s", name);
if (strstr(name, "libbluetooth_jni.so")) {
LOGI("Bluetooth JNI loaded");
hookLibrary("libbluetooth_jni.so");
}
if (strstr(name, "libbluetooth_qti.so")) {
LOGI("Bluetooth QTI loaded");
hookLibrary("libbluetooth_qti.so");
}
}
extern "C" [[gnu::visibility("default")]]
[[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
LOGI("native_init called with entries: %p", entries);
hook_func = (HookFunType) entries->hook_func;
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
enableSdpHook.load(std::memory_order_relaxed));
return on_library_loaded;
}
extern "C" JNIEXPORT void JNICALL
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz,
jboolean enable) {
LOGI("setSdpHook called with enable: %d", enable);
enableSdpHook.store(enable, std::memory_order_relaxed);
LOGI("sdp hook enabled: %d", enable);
}
@@ -1,67 +1,23 @@
package me.kavishdevar.librepods
import android.app.Application
import androidx.health.connect.client.HealthConnectClient
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.room3.Room
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.database.LibrePodsDatabase
import me.kavishdevar.librepods.repository.AppDataRepository
import me.kavishdevar.librepods.repository.AppleRepository
import me.kavishdevar.librepods.repository.HeartRateRepository
import me.kavishdevar.librepods.repository.RecordingRepository
import me.kavishdevar.librepods.repository.WidgetConfigRepository
import me.kavishdevar.librepods.utils.GestureFeedback
import me.kavishdevar.librepods.utils.XposedServiceHolder
import me.kavishdevar.librepods.utils.XposedState
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
lateinit var database: LibrePodsDatabase
private set
val appleRepository by lazy { AppleRepository(database.appleDao()) }
val appDataRepository by lazy { AppDataRepository(database.appSettingsDao(), database.appStateDao()) }
val widgetConfigRepository by lazy { WidgetConfigRepository(database.widgetConfigDao()) }
val recordingRepository by lazy { RecordingRepository(applicationContext) }
val heartRateRepository by lazy { HeartRateRepository(database.heartRateDao()) }
val healthConnectClient: HealthConnectClient? by lazy {
val status = HealthConnectClient.getSdkStatus(this)
if (status == HealthConnectClient.SDK_AVAILABLE) {
HealthConnectClient.getOrCreate(this)
} else {
null
}
}
override fun onCreate() {
System.loadLibrary("hiddenapi")
database = Room.databaseBuilder(
applicationContext,
LibrePodsDatabase::class.java,
"librepods.db"
).build()
XposedServiceHelper.registerListener(this)
runBlocking(Dispatchers.IO) {
appDataRepository.awaitInitialized()
}
BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
GestureFeedback.init(this)
super<Application>.onCreate()
}
@@ -0,0 +1,245 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods
// import me.kavishdevar.librepods.screens.Onboarding
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
//import dagger.hilt.android.AndroidEntryPoint
import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.edit
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.android.play.core.review.ReviewManagerFactory
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.XposedState
import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver
lateinit var testReviewReceiver: BroadcastReceiver
//@AndroidEntryPoint
@ExperimentalMaterial3Api
class MainActivity : ComponentActivity() {
companion object {
init {
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
System.loadLibrary("l2c_fcr_hook")
}
}
}
@ExperimentalHazeMaterialsApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) }
val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
when (key) {
"m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true)
}
}
DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
}
}
LibrePodsTheme(
m3eEnabled = m3eEnabled.value
) {
// For demo screenshots
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
Main()
}
}
}
override fun onDestroy() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
super.onDestroy()
}
override fun onStop() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
super.onStop()
}
}
@ExperimentalHazeMaterialsApi
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun Main() {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val airPodsViewModel: AirPodsViewModel = viewModel()
LaunchedEffect(Unit) {
if (BuildConfig.PLAY_BUILD) {
val now = System.currentTimeMillis()
val firstConn =
sharedPreferences.getLong("first_connection_successful_time", 0L)
val alreadyPrompted =
sharedPreferences.getBoolean("review_prompted", false)
val oneDay = 24 * 60 * 60 * 1000L
if (
firstConn != 0L &&
!alreadyPrompted &&
(now - firstConn) > oneDay
) {
triggerReviewFlow(context as? Activity ?: return@LaunchedEffect)
sharedPreferences.edit {
putBoolean("review_prompted", true)
}
}
}
}
val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false)
val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}"
val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false)
fun bindService() {
context.startForegroundService(Intent(context, AirPodsService::class.java))
serviceConnection = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder
val service = binder.getService()
airPodsService.value = service
airPodsViewModel.init(
service = service,
controlRepo = ControlCommandRepository(service.aacpManager),
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
appContext = context.applicationContext
)
if (!sharedPreferences.contains("first_connection_successful_time")) {
sharedPreferences.edit {
putLong("first_connection_successful_time", System.currentTimeMillis())
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
airPodsService.value = null
}
}
context.bindService(
Intent(context, AirPodsService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
if (onboardingComplete) {
bindService()
}
NavigationRoot(
showReleaseNotes = !releaseNotesShown,
updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } },
showOnboarding = !onboardingComplete,
onboardingComplete = {
sharedPreferences.edit { putBoolean("onboarding_complete", true) }
bindService()
},
airPodsViewModel = airPodsViewModel
)
}
private fun triggerReviewFlow(activity: Activity) {
val manager = ReviewManagerFactory.create(activity)
val request = manager.requestReviewFlow()
request.addOnCompleteListener { task ->
if (task.isSuccessful) {
val reviewInfo = task.result
manager.launchReviewFlow(activity, reviewInfo)
}
}
}
@@ -0,0 +1,639 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.view.Gravity
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.presentation.components.IconAreaSize
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
class QuickSettingsDialogActivity : ComponentActivity() {
private var airPodsService: AirPodsService? = null
private var isBound = false
private var isNoiseControlExpandedState by mutableStateOf(false)
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as AirPodsService.LocalBinder
airPodsService = binder.getService()
isBound = true
Log.d("QSActivity", "Service bound")
setContent {
LibrePodsTheme {
DraggableDismissBox(
onDismiss = { finish() },
onlyCollapseWhenClicked = {
if (isNoiseControlExpandedState) {
isNoiseControlExpandedState = false
true
} else {
false
}
}
) {
if (isBound && airPodsService != null) {
NewControlCenterDialogContent(
service = airPodsService,
isNoiseControlExpanded = isNoiseControlExpandedState,
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
)
}
}
}
}
}
override fun onServiceDisconnected(arg0: ComponentName) {
isBound = false
airPodsService = null
Log.d("QSActivity", "Service unbound")
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH)
window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
window.setGravity(Gravity.BOTTOM)
Intent(this, AirPodsService::class.java).also { intent ->
bindService(intent, connection, BIND_AUTO_CREATE)
}
setContent {
LibrePodsTheme {
DraggableDismissBox(
onDismiss = { finish() },
onlyCollapseWhenClicked = {
if (isNoiseControlExpandedState) {
isNoiseControlExpandedState = false
true
} else {
false
}
}
) {
if (isBound && airPodsService != null) {
NewControlCenterDialogContent(
service = airPodsService,
isNoiseControlExpanded = isNoiseControlExpandedState,
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
)
}
}
}
}
}
override fun onDestroy() {
super.onDestroy()
if (isBound) {
unbindService(connection)
isBound = false
}
}
}
@Composable
fun DraggableDismissBox(
onDismiss: () -> Unit,
onlyCollapseWhenClicked: () -> Boolean,
content: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
var dragOffset by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) }
val dismissThreshold = 400f
val animatedOffset = remember { Animatable(0f) }
val animatedScale = remember { Animatable(1f) }
val animatedAlpha = remember { Animatable(1f) }
val backgroundAlpha by animateFloatAsState(
targetValue = if (isDragging) {
val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f)
1f - dragProgress
} else 1f,
label = "BackgroundFade"
)
LaunchedEffect(isDragging) {
if (!isDragging) {
if (abs(dragOffset) < dismissThreshold) {
val springSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessHigh,
visibilityThreshold = 0.1f
)
launch { animatedOffset.animateTo(0f, springSpec) }
launch { animatedScale.animateTo(1f, springSpec) }
launch { animatedAlpha.animateTo(1f, tween(100)) }
dragOffset = 0f
}
}
}
LaunchedEffect(dragOffset, isDragging) {
if (isDragging) {
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
animatedOffset.snapTo(dragOffset)
animatedScale.snapTo(1f - dragProgress * 0.3f)
animatedAlpha.snapTo(1f - dragProgress * 0.7f)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f * backgroundAlpha))
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragStart = { isDragging = true },
onDragEnd = {
isDragging = false
if (abs(dragOffset) > dismissThreshold) {
coroutineScope.launch {
val direction = if (dragOffset > 0) 1f else -1f
launch {
animatedOffset.animateTo(
direction * 1500f,
tween(350, easing = FastOutSlowInEasing)
)
}
launch { animatedScale.animateTo(0.7f, tween(350)) }
launch { animatedAlpha.animateTo(0f, tween(250)) }
kotlinx.coroutines.delay(350)
onDismiss()
}
}
},
onDragCancel = { isDragging = false },
onVerticalDrag = { change, dragAmount ->
change.consume()
dragOffset += dragAmount
}
)
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onlyCollapseWhenClicked()
},
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer(
translationY = animatedOffset.value,
scaleX = animatedScale.value,
scaleY = animatedScale.value,
alpha = animatedAlpha.value
),
contentAlignment = Alignment.BottomCenter
) {
content()
}
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@Composable
fun NewControlCenterDialogContent(
service: AirPodsService?,
isNoiseControlExpanded: Boolean,
onNoiseControlExpandedChange: (Boolean) -> Unit
) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val textColor = Color.White
var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) }
var isConvAwarenessEnabled by remember { mutableStateOf(false) }
val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) }
val availableModes = remember(isOffModeEnabled) {
mutableListOf(
NoiseControlMode.TRANSPARENCY,
NoiseControlMode.ADAPTIVE,
NoiseControlMode.NOISE_CANCELLATION
).apply {
if (isOffModeEnabled) {
add(0, NoiseControlMode.OFF)
}
}
}
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) }
val animatedVolumeFraction by animateFloatAsState(
targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(),
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "VolumeAnimation"
)
var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) }
var isDraggingVolume by remember { mutableStateOf(false) }
LaunchedEffect(animatedVolumeFraction, isDraggingVolume) {
if (!isDraggingVolume) {
liveDragFraction = animatedVolumeFraction
}
}
DisposableEffect(service, availableModes) {
val ancReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.ANC_DATA && service != null) {
val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1
val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY }
if (availableModes.contains(newMode)) {
currentAncMode = newMode
} else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) {
currentAncMode = NoiseControlMode.TRANSPARENCY
}
Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})")
}
}
}
val filter = IntentFilter(AirPodsNotifications.ANC_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(ancReceiver, filter)
}
service?.let {
val initialModeOrdinal = it.getANC().minus(1)
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
if (!availableModes.contains(initialMode)) {
initialMode = NoiseControlMode.TRANSPARENCY
}
currentAncMode = initialMode
isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true)
Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled")
}
onDispose {
context.unregisterReceiver(ancReceiver)
}
}
DisposableEffect(Unit) {
val volumeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == "android.media.VOLUME_CHANGED_ACTION") {
val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
if (newVolume != currentVolumeInt) {
currentVolumeInt = newVolume
Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt")
}
}
}
}
val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION")
context.registerReceiver(volumeReceiver, filter)
onDispose {
context.unregisterReceiver(volumeReceiver)
}
}
val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" }
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
.padding(horizontal = 24.dp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent()
}
}
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
if (service != null) {
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier
.weight(2f)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(id = R.drawable.airpods),
contentDescription = "Device Icon",
tint = textColor.copy(alpha = 0.8f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = deviceName,
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(32.dp))
VerticalVolumeSlider(
displayFraction = animatedVolumeFraction,
maxVolume = maxVolume,
onVolumeChange = { newVolume ->
currentVolumeInt = newVolume
try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
} catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) }
},
initialFraction = animatedVolumeFraction,
onDragStateChange = { dragging -> isDraggingVolume = dragging },
baseSliderHeight = 400.dp,
baseSliderWidth = 145.dp,
baseCornerRadius = 48.dp,
maxStretchFactor = 1.15f,
minCompressionFactor = 0.875f,
stretchSensitivity = 0.3f,
compressionSensitivity = 0.3f,
cornerRadiusChangeFactor = -0.5f,
directionalStretchRatio = 0.75f,
modifier = Modifier
.width(145.dp)
.padding(vertical = 8.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 72.dp)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
),
contentAlignment = Alignment.Center
) {
Crossfade(
targetState = isNoiseControlExpanded,
animationSpec = tween(durationMillis = 300),
label = "NoiseControlCrossfade"
) { expanded ->
if (expanded) {
ControlCenterNoiseControlSegmentedButton(
availableModes = availableModes,
selectedMode = currentAncMode,
onModeSelected = { newMode ->
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
value = newMode.ordinal + 1
)
currentAncMode = newMode
},
modifier = Modifier.fillMaxWidth(0.8f)
)
} else {
Row(
modifier = Modifier.fillMaxWidth(0.85f),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) {
AdaptiveRainbowBrush
} else {
null
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(IconAreaSize)
.clip(CircleShape)
.background(
brush = noiseControlButtonBrush ?:
Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF)))
)
.clickable(
onClick = { onNoiseControlExpandedChange(true) },
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = getModeIconRes(currentAncMode)),
contentDescription = getModeLabel(currentAncMode),
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = getModeLabel(currentAncMode),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
Spacer(modifier = Modifier.width(24.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(IconAreaSize)
.clip(CircleShape)
.background(
Brush.linearGradient(
colors = listOf(
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E),
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E)
)
)
)
.clickable(
onClick = {
val newState = !isConvAwarenessEnabled
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
value = newState
)
isConvAwarenessEnabled = newState
},
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.airpods),
contentDescription = "Conversational Awareness",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Conversational\nAwareness",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
lineHeight = 14.sp
)
}
}
}
}
}
} else {
Spacer(modifier = Modifier.weight(1f))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Loading...", color = textColor)
}
Spacer(modifier = Modifier.weight(1f))
}
}
}
private fun getModeIconRes(mode: NoiseControlMode): Int {
return when (mode) {
NoiseControlMode.OFF -> R.drawable.noise_cancellation
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
}
}
private fun getModeLabel(mode: NoiseControlMode): String {
return when (mode) {
NoiseControlMode.OFF -> "Off"
NoiseControlMode.TRANSPARENCY -> "Transparency"
NoiseControlMode.ADAPTIVE -> "Adaptive"
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel"
}
}
@@ -21,6 +21,7 @@ package me.kavishdevar.librepods.billing
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.content.edit
import androidx.core.net.toUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -30,19 +31,17 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.LibrePodsApplication
import me.kavishdevar.librepods.R
import kotlin.time.Duration.Companion.seconds
class FOSSBillingProvider(context: Context): BillingProvider {
private val appDataRepository = (context.applicationContext as LibrePodsApplication).appDataRepository
private val _isPremium = MutableStateFlow(appDataRepository.state.value.isPremium)
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
override val price: StateFlow<String> = _price
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var purchaseJob: Job? = null
@@ -58,18 +57,21 @@ class FOSSBillingProvider(context: Context): BillingProvider {
purchaseJob?.cancel()
purchaseJob = scope.launch {
delay(5.seconds)
delay(5_000)
_isPremium.value = true
appDataRepository.updateState { it.copy(isPremium = true) }
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
}
override fun queryPurchases() {
_isPremium.value = appDataRepository.state.value.isPremium
val stored = sharedPreferences.getBoolean("foss_upgraded", false)
if (stored != _isPremium.value) {
_isPremium.value = stored
}
}
override fun restorePurchases() {
_isPremium.value = true
appDataRepository.updateState { it.copy(isPremium = true) }
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
}
@@ -41,7 +41,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.LibrePodsApplication
const val TAG = "PlayBillingProvider"
@@ -50,11 +49,10 @@ private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
class PlayBillingProvider(
context: Context
) : BillingProvider, PurchasesUpdatedListener {
private val appDataRepository = (context.applicationContext as LibrePodsApplication).appDataRepository
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _isPremium = MutableStateFlow(appDataRepository.state.value.isPremium)
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("unknown")
@@ -179,8 +177,6 @@ class PlayBillingProvider(
_isPremium.value = hasPremium
appDataRepository.updateState { it.copy(isPremium = hasPremium) }
scope.launch {
purchases
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "ATTManager"
enum class ATTHandles(val value: Int) {
TRANSPARENCY(0x18),
LOUD_SOUND_REDUCTION(0x1B),
HEARING_AID(0x2A)
}
enum class ATTCCCDHandles(val value: Int) {
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
}
class ATTManagerv2 {
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
private val readerRunning = AtomicBoolean(false)
private var readerThread: Thread? = null
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
fun startReader() {
if (readerRunning.getAndSet(true)) return
readerThread = Thread {
try {
runReaderLoop()
} catch (t: Throwable) {
Log.e(TAG, "reader thread crashed: ${t.message}", t)
} finally {
readerRunning.set(false)
Log.d(TAG, "reader thread stopped")
}
}.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() }
Log.d(TAG, "reader started")
}
fun stopReader() {
readerRunning.set(false)
readerThread?.interrupt()
readerThread = null
}
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
onNotificationReceived = listener
}
fun enableNotification(handle: ATTCCCDHandles) {
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
}
fun getCharacteristic(handle: ATTHandles): ByteArray? {
val storedValue = characteristicList[handle]
return if (storedValue?.isNotEmpty() != true) {
readCharacteristic(handle)
} else storedValue
}
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
val socket = BluetoothConnectionManager.attSocket ?: return null
try {
val output = socket.outputStream
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
synchronized(output) {
output.write(pdu)
output.flush()
}
Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val resp = waitForResponse(0x0B, timeoutMillis) ?: run {
Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}")
return null
}
Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
val value = resp.copyOfRange(1, resp.size)
characteristicList[handle] = value
return value
} catch (e: Exception) {
Log.e(TAG, "error reading characteristic: ${e.message}")
return null
}
}
fun writeCharacteristic(handle: ATTHandles, data: ByteArray, timeoutMillis: Long = 2000) {
characteristicList[handle] = data
writeCharacteristic(handle.value.toByte(), data, timeoutMillis)
}
fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) {
val socket = BluetoothConnectionManager.attSocket ?: return
try {
val output = socket.outputStream
val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE
synchronized(output) {
output.write(pdu)
output.flush()
}
Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val resp = waitForResponse(0x13, timeoutMillis) ?: run {
Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}")
return
}
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.e(TAG, "error writing characteristic: ${e.message}")
}
}
fun disconnected() {
characteristicList.clear()
stopReader()
val socket = BluetoothConnectionManager.attSocket?: return
try {
socket.close()
} catch (e: Exception) {
Log.w(TAG, "error closing socket: ${e.message}")
}
Log.d(TAG, "ATT disconnected")
}
private fun runReaderLoop() {
val socket = BluetoothConnectionManager.attSocket ?: run {
Log.w(TAG, "ATT socket not available. stopping reader")
readerRunning.set(false)
return
}
val input = socket.inputStream
val buffer = ByteArray(512)
while (readerRunning.get()) {
try {
val len = input.read(buffer)
if (len == -1) {
Log.w(TAG, "ATT input stream ended")
break
}
val data = buffer.copyOfRange(0, len)
if (data.isEmpty()) continue
val opcode = data[0]
Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}")
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
queue.offer(data)
if (opcode == 0x1B.toByte()) {
if (data.size >= 3) {
val handle = data[1]
val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0)
Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}")
try {
onNotificationReceived?.invoke(handle, value)
} catch (t: Throwable) {
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
}
} else {
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
}
}
} catch (e: Exception) {
Log.e(TAG, "error in reader loop: ${e.message}", e)
break
}
}
readerRunning.set(false)
}
private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? {
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
return try {
queue.poll(timeoutMillis, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
@@ -1,4 +1,22 @@
package me.kavishdevar.librepods.bluetooth.ble
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
@@ -12,9 +30,10 @@ import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.util.Log
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.utils.BluetoothCryptography
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import kotlin.collections.iterator
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -22,7 +41,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
*/
@OptIn(ExperimentalEncodingApi::class)
class BLEManagerold(private val context: Context) {
class BLEManager(private val context: Context) {
data class AirPodsStatus(
val address: String,
@@ -193,7 +212,7 @@ class BLEManagerold(private val context: Context) {
@OptIn(ExperimentalEncodingApi::class)
private fun getEncryptionKeyFromPreferences(): ByteArray? {
val keyBase64 = sharedPreferences.getString(MagicKeyType.ENC_KEY.name, null)
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
return if (keyBase64 != null) {
try {
Base64.decode(keyBase64)
@@ -231,86 +250,86 @@ class BLEManagerold(private val context: Context) {
}
private fun processScanResult(result: ScanResult) {
// try {
// val scanRecord = result.scanRecord ?: return
// val address = result.device.address
//
// if (processedAddresses.contains(address)) {
// return
// }
//
// val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
// if (manufacturerData.size <= 20) return
//
// if (!verifiedAddresses.contains(address)) {
// val irk = getIrkFromPreferences()
// if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
// return
// }
// verifiedAddresses.add(address)
// Log.d(TAG, "RPA verified and added to trusted list: $address")
// }
//
// processedAddresses.add(address)
// lastBroadcastTime = System.currentTimeMillis()
//
// val encryptionKey = getEncryptionKeyFromPreferences()
// val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
// val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
// parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
// } else {
// parseProximityMessage(address, manufacturerData)
// }
//
// val previousStatus = deviceStatusMap[address]
// deviceStatusMap[address] = parsedStatus
//
// airPodsStatusListener?.let { listener ->
// if (previousStatus == null) {
// listener.onBroadcastFromNewAddress(parsedStatus)
// Log.d(TAG, "New AirPods device detected: $address")
//
// if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
// currentGlobalLidState = parsedStatus.lidOpen
// listener.onLidStateChanged(parsedStatus.lidOpen)
// Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
// }
// } else {
// if (parsedStatus != previousStatus) {
// listener.onDeviceStatusChanged(parsedStatus, previousStatus)
// }
//
// if (parsedStatus.lidOpen != previousStatus.lidOpen) {
// val previousGlobalState = currentGlobalLidState
// currentGlobalLidState = parsedStatus.lidOpen
//
// if (previousGlobalState != parsedStatus.lidOpen) {
// listener.onLidStateChanged(parsedStatus.lidOpen)
// Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}")
// }
// }
//
// if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
// parsedStatus.isRightInEar != previousStatus.isRightInEar) {
// listener.onEarStateChanged(
// parsedStatus,
// parsedStatus.isLeftInEar,
// parsedStatus.isRightInEar
// )
// Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
// }
//
// if (parsedStatus.leftBattery != previousStatus.leftBattery ||
// parsedStatus.rightBattery != previousStatus.rightBattery ||
// parsedStatus.caseBattery != previousStatus.caseBattery) {
// listener.onBatteryChanged(parsedStatus)
// Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
// }
// }
// }
// } catch (t: Throwable) {
// Log.e(TAG, "Error processing scan result", t)
// }
try {
val scanRecord = result.scanRecord ?: return
val address = result.device.address
if (processedAddresses.contains(address)) {
return
}
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
if (manufacturerData.size <= 20) return
if (!verifiedAddresses.contains(address)) {
val irk = getIrkFromPreferences()
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
return
}
verifiedAddresses.add(address)
Log.d(TAG, "RPA verified and added to trusted list: $address")
}
processedAddresses.add(address)
lastBroadcastTime = System.currentTimeMillis()
val encryptionKey = getEncryptionKeyFromPreferences()
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
} else {
parseProximityMessage(address, manufacturerData)
}
val previousStatus = deviceStatusMap[address]
deviceStatusMap[address] = parsedStatus
airPodsStatusListener?.let { listener ->
if (previousStatus == null) {
listener.onBroadcastFromNewAddress(parsedStatus)
Log.d(TAG, "New AirPods device detected: $address")
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
currentGlobalLidState = parsedStatus.lidOpen
listener.onLidStateChanged(parsedStatus.lidOpen)
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
}
} else {
if (parsedStatus != previousStatus) {
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
}
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
val previousGlobalState = currentGlobalLidState
currentGlobalLidState = parsedStatus.lidOpen
if (previousGlobalState != parsedStatus.lidOpen) {
listener.onLidStateChanged(parsedStatus.lidOpen)
Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}")
}
}
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
listener.onEarStateChanged(
parsedStatus,
parsedStatus.isLeftInEar,
parsedStatus.isRightInEar
)
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
}
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
parsedStatus.rightBattery != previousStatus.rightBattery ||
parsedStatus.caseBattery != previousStatus.caseBattery) {
listener.onBatteryChanged(parsedStatus)
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
}
}
}
} catch (t: Throwable) {
Log.e(TAG, "Error processing scan result", t)
}
}
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
@@ -398,7 +417,7 @@ class BLEManagerold(private val context: Context) {
@OptIn(ExperimentalEncodingApi::class)
private fun getIrkFromPreferences(): ByteArray? {
val irkBase64 = sharedPreferences.getString(MagicKeyType.IRK.name, null)
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
return if (irkBase64 != null) {
try {
Base64.decode(irkBase64)
@@ -0,0 +1,76 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
object BluetoothConnectionManager {
var aacpSocket: BluetoothSocket? = null
var attSocket: BluetoothSocket? = null
}
fun createBluetoothSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
arrayOf(device, type, true, true, psm, uuid),
arrayOf(device, type, 1, true, true, psm, uuid),
arrayOf(type, 1, true, true, device, psm, uuid),
arrayOf(type, true, true, device, psm, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("createSocket<psm>", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("createSocket<psm>", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("createSocket<psm>", "Trying constructor signature #${index + 1}")
attemptedConstructors++
val paramTypes =
params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) {
Log.e("createSocket<psm>", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
}
}
val errorMessage =
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("createSocket<psm>", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
}
@@ -0,0 +1,277 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.R
open class AirPodsBase(
val modelNumber: List<String>,
val name: String,
val displayName: String = "AirPods",
val manufacturer: String = "Apple Inc.",
val budCaseRes: Int,
val budsRes: Int,
val leftBudsRes: Int,
val rightBudsRes: Int,
val caseRes: Int,
val capabilities: Set<Capability>
)
enum class Capability {
LISTENING_MODE,
CONVERSATION_AWARENESS,
STEM_CONFIG,
HEAD_GESTURES,
LOUD_SOUND_REDUCTION,
PPE,
SLEEP_DETECTION,
HEARING_AID,
ADAPTIVE_AUDIO,
ADAPTIVE_VOLUME,
SWIPE_FOR_VOLUME,
HRM
}
class AirPods: AirPodsBase(
modelNumber = listOf("A1523", "A1722"),
name = "AirPods 1",
// budCaseRes = R.drawable.airpods_1
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_1_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_1_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_1_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_1_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = emptySet()
)
class AirPods2: AirPodsBase(
modelNumber = listOf("A2032", "A2031"),
name = "AirPods 2",
// budCaseRes = R.drawable.airpods_2
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_2_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_2_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = emptySet()
)
class AirPods3: AirPodsBase(
modelNumber = listOf("A2565", "A2564"),
name = "AirPods 3",
// budCaseRes = R.drawable.airpods_3
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_3_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_3_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_3_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_3_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.HEAD_GESTURES
)
)
class AirPods4: AirPodsBase(
modelNumber = listOf("A3053", "A3050", "A3054"),
name = "AirPods 4",
// budCaseRes = R.drawable.airpods_4
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_4_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_4_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_4_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_4_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.HEAD_GESTURES,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPods4ANC: AirPodsBase(
modelNumber = listOf("A3056", "A3055", "A3057"),
name = "AirPods 4 (ANC)",
// budCaseRes = R.drawable.airpods_4
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_4_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_4_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_4_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_4_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.ADAPTIVE_AUDIO,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME,
Capability.STEM_CONFIG
)
)
class AirPodsPro1: AirPodsBase(
modelNumber = listOf("A2084", "A2083"),
name = "AirPods Pro 1",
displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_1
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_1_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_1_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_1_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_1_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE
)
)
class AirPodsPro2Lightning: AirPodsBase(
modelNumber = listOf("A2931", "A2699", "A2698"),
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_2
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_2_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_2_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HEAD_GESTURES
)
)
class AirPodsPro2USBC: AirPodsBase(
modelNumber = listOf("A3047", "A3048", "A3049"),
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_2
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_2_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_2_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HEAD_GESTURES
)
)
class AirPodsPro3: AirPodsBase(
modelNumber = listOf("A3063", "A3064", "A3065"),
name = "AirPods Pro 3",
displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_3
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_3_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_3_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_3_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_3_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.PPE,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HRM
)
)
data class AirPodsInstance(
val name: String,
val model: AirPodsBase,
val actualModelNumber: String,
val serialNumber: String?,
val leftSerialNumber: String?,
val rightSerialNumber: String?,
val version1: String?,
val version2: String?,
val version3: String?,
)
object AirPodsModels {
val models: List<AirPodsBase> = listOf(
AirPods(),
AirPods2(),
AirPods3(),
AirPods4(),
AirPods4ANC(),
AirPodsPro1(),
AirPodsPro2Lightning(),
AirPodsPro2USBC(),
AirPodsPro3()
)
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
return models.find { modelNumber in it.modelNumber }
}
}
@@ -0,0 +1,70 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
class ControlCommandRepository(
private val aacpManager: AACPManager
) {
fun getValue(
identifier: ControlCommandIdentifiers
): ByteArray? {
return aacpManager.controlCommandStatusList
.find { it.identifier == identifier }
?.value
}
fun setValue(
id: ControlCommandIdentifiers,
value: ByteArray
) {
aacpManager.sendControlCommand(id.value, value)
}
fun observe(
identifier: ControlCommandIdentifiers,
onChange: (ByteArray) -> Unit
): AACPManager.ControlCommandListener {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
onChange(controlCommand.value)
}
}
aacpManager.registerControlCommandListener(identifier, listener)
return listener
}
fun remove(
identifier: ControlCommandIdentifiers,
listener: AACPManager.ControlCommandListener
) {
aacpManager.unregisterControlCommandListener(identifier, listener)
}
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
return aacpManager.controlCommandStatusList.associate {
it.identifier to it.value
}
}
}
@@ -1,21 +1,18 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
package me.kavishdevar.librepods.data
import kotlinx.serialization.Serializable
import me.kavishdevar.librepods.bluetooth.AACPManager
@Serializable
data class CustomEq(
val state: Int,
val low: Int,
val mid: Int,
val high: Int
) {
enum class CustomEqBand { LOW, MID, HIGH }
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
fun isEnabled(): Boolean {
return state == 2
}
fun toAACPPayload(): ByteArray {
fun toPacket(): ByteArray {
return byteArrayOf(
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
0x05, 0x00, // length (LE)
0x01, state.toByte(),
low.toByte(), mid.toByte(), high.toByte()
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth.att.types
package me.kavishdevar.librepods.data
import android.util.Log
import androidx.compose.runtime.MutableState
@@ -25,7 +25,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.bluetooth.ATTHandles
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -141,7 +141,7 @@ fun sendHearingAidSettings(
currentData: ByteArray,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>,
sender: (ATTHandle, ByteArray) -> Unit
sender: (ATTHandles, ByteArray) -> Unit
) {
debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
@@ -184,7 +184,7 @@ fun sendHearingAidSettings(
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
sender(ATTHandle.HEARING_AID, currentData)
sender(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}
@@ -0,0 +1,265 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
import android.os.Parcelable
import android.util.Log
import kotlinx.parcelize.Parcelize
// TODO: Remove everything but Battery-related stuff
enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(byteArrayOf(0x0d)),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 0x00)),
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
}
object BatteryComponent {
const val LEFT = 4
const val RIGHT = 2
const val CASE = 8
}
object BatteryStatus {
const val CHARGING = 1
const val NOT_CHARGING = 2
const val DISCONNECTED = 4
const val OPTIMIZED_CHARGING = 5
}
@Parcelize
data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable {
fun getComponentName(): String? {
return when (component) {
BatteryComponent.LEFT -> "LEFT"
BatteryComponent.RIGHT -> "RIGHT"
BatteryComponent.CASE -> "CASE"
else -> null
}
}
fun getStatusName(): String? {
return when (status) {
BatteryStatus.CHARGING -> "CHARGING"
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
else -> null
}
}
}
enum class NoiseControlMode {
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
}
class AirPodsNotifications {
companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
const val BATTERY_DATA = "me.kavishdevar.librepods.BATTERY_DATA"
const val CA_DATA = "me.kavishdevar.librepods.CA_DATA"
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
}
class EarDetection {
private val notificationBit = 6.toByte()
private val notificationPrefix = Enums.PREFIX.value + notificationBit
var status: List<Byte> = listOf(0x01, 0x01)
fun setStatus(data: ByteArray) {
status = listOf(data[6], data[7])
}
fun isEarDetectionData(data: ByteArray): Boolean {
if (data.size != 8) {
return false
}
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
}
class ANC {
private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value
var status: Int = 1
private set
fun isANCData(data: ByteArray): Boolean {
if (data.size != 11) {
return false
}
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
fun setStatus(data: ByteArray) {
when (data.size) {
// if the whole packet is given
11 -> {
status = data[7].toInt()
}
// if only the data is given
1 -> {
status = data[0].toInt()
}
// if the value of control command is given
4 -> {
status = data[0].toInt()
}
else -> {
Log.d("ANC", "Invalid ANC data size: ${data.size}")
}
}
}
val name: String =
when (status) {
1 -> "OFF"
2 -> "ON"
3 -> "TRANSPARENCY"
4 -> "ADAPTIVE"
else -> "UNKNOWN"
}
}
class BatteryNotification {
private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED)
private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED)
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
fun isBatteryData(data: ByteArray): Boolean {
if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) {
Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.")
} else {
return false
}
if (data.size != 22) {
Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.")
return false
}
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
}
fun setBatteryDirect(
leftLevel: Int,
leftCharging: Boolean,
rightLevel: Int,
rightCharging: Boolean,
caseLevel: Int,
caseCharging: Boolean
) {
first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
}
fun setBattery(data: ByteArray) {
if (data.size != 22) {
return
}
// first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
// Battery(first.component, first.level, data[10].toInt())
// } else {
// Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
// }
// second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
// Battery(second.component, second.level, data[15].toInt())
// } else {
// Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
// }
// case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
// Battery(case.component, case.level, data[20].toInt())
// } else {
// Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
// }
// sometimes it shows battery as -1%, just skip all that and set it normally
first = Battery(
data[7].toInt(), data[9].toInt(), data[10].toInt()
)
second = Battery(
data[12].toInt(), data[14].toInt(), data[15].toInt()
)
case = Battery(
data[17].toInt(), data[19].toInt(), data[20].toInt()
)
}
fun getBattery(): List<Battery> {
val left = if (first.component == BatteryComponent.LEFT) first else second
val right = if (first.component == BatteryComponent.LEFT) second else first
return listOf(left, right, case)
}
}
class ConversationalAwarenessNotification {
@Suppress("PrivatePropertyName")
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
var status: Byte = 0
private set
fun isConversationalAwarenessData(data: ByteArray): Boolean {
if (data.size != 10) {
return false
}
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
fun setData(data: ByteArray) {
status = data[9]
}
}
}
fun isHeadTrackingData(data: ByteArray): Boolean {
if (data.size <= 60) return false
val prefixPattern = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00,
0x10, 0x00
)
for (i in prefixPattern.indices) {
if (data[i] != prefixPattern[i]) return false
}
if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false
if (data[11] != 0x00.toByte()) return false
return true
}
@@ -18,10 +18,8 @@
package me.kavishdevar.librepods.data
import kotlinx.serialization.Serializable
import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressType
import me.kavishdevar.librepods.bluetooth.AACPManager
@Serializable
enum class StemAction {
PLAY_PAUSE,
PREVIOUS_TRACK,
@@ -32,11 +30,11 @@ enum class StemAction {
fun fromString(action: String): StemAction? {
return entries.find { it.name == action }
}
val defaultActions: Map<StemPressType, StemAction> = mapOf(
StemPressType.SINGLE_PRESS to PLAY_PAUSE,
StemPressType.DOUBLE_PRESS to NEXT_TRACK,
StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
)
}
}
@@ -16,18 +16,17 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.bluetooth.att.types
package me.kavishdevar.librepods.data
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.bluetooth.ATTHandles
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.time.Duration.Companion.milliseconds
data class TransparencySettings(
val enabled: Boolean,
@@ -142,10 +141,10 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
private var debounceJob: Job? = null
fun sendTransparencySettings(writer: (ATTHandle, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100.milliseconds)
delay(100)
try {
val buffer = ByteBuffer.allocate(
if (transparencySettings.ownVoiceAmplification != null) 104 else 100
@@ -174,7 +173,7 @@ fun sendTransparencySettings(writer: (ATTHandle, ByteArray) -> Unit, transparenc
}
val data = buffer.array()
writer(ATTHandle.TRANSPARENCY, data)
writer(ATTHandles.TRANSPARENCY, data)
} catch (e: IOException) {
e.printStackTrace()
}
@@ -1,4 +1,4 @@
package me.kavishdevar.librepods.data.xposed
package me.kavishdevar.librepods.data
interface XposedRemotePref {
fun isAvailable(): Boolean
@@ -1,4 +1,4 @@
package me.kavishdevar.librepods.data.xposed
package me.kavishdevar.librepods.data
import androidx.core.content.edit
import me.kavishdevar.librepods.utils.XposedServiceHolder
@@ -1,4 +1,4 @@
package me.kavishdevar.librepods.data.xposed
package me.kavishdevar.librepods.data
object XposedRemotePrefProvider {
fun create(): XposedRemotePref = XposedRemotePrefImpl()
@@ -2,9 +2,9 @@ package me.kavishdevar.librepods.data.updates
import androidx.compose.runtime.Composable
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.screens.apple.AppleSettingsScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewApple
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewApple
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -13,7 +13,7 @@ val update1_0_0 = listOf(
titleRes = R.string.material3e,
descriptionRes = R.string.update_m3e_description,
demoComposeable = @Composable {
AppleSettingsScreenPreviewMaterial()
AirPodsSettingsScreenPreviewMaterial()
}
),
UpdateItem(
@@ -0,0 +1,528 @@
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
}
@@ -0,0 +1,79 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AboutCard(
modelName: String,
actualModel: String,
serialNumbers: List<String>,
version: String?,
navigateToVersion: () -> Unit
) {
val serialNumbers = when (LocalDesignSystem.current) {
DesignSystem.Apple -> listOf(
serialNumbers[0],
"􀀛 ${serialNumbers[1]}",
"􀀧 ${serialNumbers[2]}"
)
DesignSystem.Material -> listOf(
serialNumbers[0],
stringResource(R.string.left) + " " + serialNumbers[1],
stringResource(R.string.right) + " " + serialNumbers[2],
)
}
val serialNumber = remember { mutableIntStateOf(0) }
StyledList (title = stringResource(R.string.about)) {
StyledListItem(
name = stringResource(R.string.model_name),
description = modelName
)
StyledListItem(
name = stringResource(R.string.model_number),
description = actualModel
)
StyledListItem (
name = stringResource(R.string.serial_number),
description = serialNumbers[serialNumber.intValue],
onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size }
)
StyledListItem(
name = stringResource(R.string.version),
description = version,
onClick = navigateToVersion,
)
}
}
@@ -16,14 +16,12 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.common
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
@Composable
fun AppInfoCard(
@@ -31,24 +29,24 @@ fun AppInfoCard(
) {
StyledList(title = stringResource(R.string.about)) {
StyledListItem(
contentText = stringResource(R.string.version),
supportingText = BuildConfig.VERSION_NAME,
name = stringResource(R.string.version),
description = BuildConfig.VERSION_NAME,
onClick = navigateToReleaseNotesScreen
)
StyledListItem(
contentText = stringResource(R.string.version_code),
supportingText = BuildConfig.VERSION_CODE.toString(),
name = stringResource(R.string.version_code),
description = BuildConfig.VERSION_CODE.toString(),
)
StyledListItem(
contentText = stringResource(R.string.flavor),
supportingText = BuildConfig.FLAVOR,
name = stringResource(R.string.flavor),
description = BuildConfig.FLAVOR,
)
StyledListItem(
contentText = stringResource(R.string.build_type),
supportingText = BuildConfig.BUILD_TYPE,
name = stringResource(R.string.build_type),
description = BuildConfig.BUILD_TYPE,
)
}
}
@@ -18,14 +18,11 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components.apple
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
import me.kavishdevar.librepods.presentation.components.primitives.StyledToggle
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
@@ -33,6 +30,7 @@ fun AudioSettings(
adaptiveVolumeCapability: Boolean,
conversationalAwarenessCapability: Boolean,
loudSoundReductionCapability: Boolean,
adaptiveAudioCapability: Boolean,
customEqCapability: Boolean,
adaptiveVolumeChecked: Boolean,
@@ -44,12 +42,13 @@ fun AudioSettings(
loudSoundReductionChecked: Boolean,
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
navigateToAdaptiveStrength: () -> Unit,
navigateToEqualizer: () -> Unit,
vendorIdHook: Boolean,
isPremium: Boolean
) {
if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability) {
if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability || adaptiveAudioCapability) {
StyledList(title = stringResource(R.string.audio)) {
if (adaptiveVolumeCapability) {
StyledToggle(
@@ -81,9 +80,16 @@ fun AudioSettings(
)
}
if (adaptiveAudioCapability) {
StyledListItem(
name = stringResource(R.string.adaptive_audio),
onClick = navigateToAdaptiveStrength,
)
}
if (customEqCapability) {
StyledListItem(
contentText = stringResource(R.string.equalizer),
name = stringResource(R.string.equalizer),
onClick = navigateToEqualizer,
)
}
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.apple
package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration
@@ -24,14 +24,13 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -45,15 +44,16 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalDensity
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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.devices.BatteryStatus
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.icons.richText
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import kotlin.math.cos
import kotlin.math.min
@@ -63,11 +63,12 @@ import kotlin.math.sqrt
@Composable
fun BatteryIndicator(
batteryPercentage: Int,
status: BatteryStatus,
imageVectorName: String? = null,
status: Int,
prefix: String = "",
previousCharging: Boolean = false,
) {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val isDarkTheme = isSystemInDarkTheme()
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor =
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
@@ -91,7 +92,7 @@ fun BatteryIndicator(
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
val trackColor = MaterialTheme.colorScheme.surfaceContainerHigh
val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
val optimizedLimit = 0.8f
val progress = batteryPercentage / 100f
@@ -173,45 +174,39 @@ fun BatteryIndicator(
}
}
Icon(
imageVector = LocalIcons.current.Bolt,
contentDescription = null,
tint = batteryFillColor,
modifier = Modifier
.size(14.dp)
.scale(scaleAnim.value)
Text(
text = "\uDBC0\uDEE6", style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = batteryFillColor,
textAlign = TextAlign.Center
), modifier = Modifier.scale(scaleAnim.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
Row {
val richText = if (imageVectorName != null) {
richText("\\icon{$imageVectorName,onSurface} $batteryPercentage%")
} else {
richText("$batteryPercentage%")
}
Text(
text = richText.text,
inlineContent = richText.inlineContent,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
Text(
text = "$prefix $batteryPercentage%",
color = batteryTextColor,
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryIndicatorPreview() {
LibrePodsTheme(designSystem = DesignSystem.Material) {
LibrePodsTheme(m3eEnabled = false) {
BatteryIndicator(
batteryPercentage = 50,
status = BatteryStatus.OPTIMIZED_CHARGING,
previousCharging = false,
imageVectorName = "LeftCircleFill"
prefix = "\uDBC6\uDCE5",
previousCharging = false
)
}
}
@@ -0,0 +1,174 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(
batteryList: List<Battery>,
budsRes: Int,
caseRes: Int
) {
val left = batteryList.find { it.component == BatteryComponent.LEFT }
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
val case = batteryList.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val singleDisplayed = remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.widthIn(max = 500.dp),
horizontalArrangement = Arrangement.Center
) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (
left?.status == right?.status &&
(leftLevel - rightLevel) in -3..3
) {
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
left?.status ?: BatteryStatus.NOT_CHARGING
)
singleDisplayed.value = true
} else {
singleDisplayed.value = false
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
left?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE5"
)
}
if (leftLevel > 0 && rightLevel > 0) {
Spacer(modifier = Modifier.width(16.dp))
}
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
rightLevel,
right?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE8"
)
}
}
}
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(caseRes),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
caseLevel,
case?.status ?: BatteryStatus.NOT_CHARGING,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
)
}
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
val fakeBattery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
)
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier
.background(bg)
.padding(16.dp)
) {
BatteryView(
batteryList = fakeBattery,
budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_pro_2_case
)
}
}
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components.apple
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -28,8 +28,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@@ -49,25 +47,25 @@ fun CallControlSettings(
StyledList(title = stringResource(R.string.call_controls)) {
StyledListItem(
contentText = stringResource(R.string.answer_call),
supportingText = stringResource(R.string.press_once),
name = stringResource(R.string.answer_call),
description = stringResource(R.string.press_once),
enabled = false
)
StyledListItem(
contentText = muteUnmuteText,
supportingText = singlePressAction,
onClick = { navigateToCallControlScreen(muteUnmuteText) },
name = muteUnmuteText,
description = singlePressAction,
onClick = { navigateToCallControlScreen(muteUnmuteText) } ,
)
StyledListItem(
contentText = hangUpText,
supportingText = doublePressAction,
name = hangUpText,
description = doublePressAction,
onClick = { navigateToCallControlScreen(hangUpText) }
)
// StyledListItem(
// contentText = pressOnceText,
// name = pressOnceText,
// selected = doublePressAction == pressOnceText,
// onClick = {
// doublePressAction = pressOnceText
@@ -78,7 +76,7 @@ fun CallControlSettings(
// )
//
// StyledListItem(
// contentText = pressTwiceText,
// name = pressTwiceText,
// selected = doublePressAction == pressTwiceText,
// onClick = {
// doublePressAction = pressTwiceText
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -25,6 +25,7 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
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
@@ -47,9 +48,13 @@ 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.luminance
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
@@ -57,12 +62,15 @@ import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@OptIn(ExperimentalMaterial3Api::class)
@ExperimentalHazeMaterialsApi
@Composable
fun StyledConfirmationDialog(
fun ConfirmationDialog(
showDialog: MutableState<Boolean>,
title: String,
message: String,
@@ -84,9 +92,7 @@ fun StyledConfirmationDialog(
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false
),
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainerHighest, RoundedCornerShape(28.dp))
)
) {
Column(
modifier = Modifier.padding(16.dp),
@@ -134,7 +140,7 @@ fun StyledConfirmationDialog(
.clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val isDarkTheme = isSystemInDarkTheme()
Box(
modifier = Modifier
.requiredWidthIn(min = 200.dp, max = 360.dp)
@@ -0,0 +1,49 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConnectionSettings(
automaticEarDetectionEnabled: Boolean,
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
automaticConnectionEnabled: Boolean,
onAutomaticConnectionChanged: (Boolean) -> Unit,
) {
StyledList {
StyledToggle(
label = stringResource(R.string.ear_detection),
checked = automaticEarDetectionEnabled,
onCheckedChange = onAutomaticEarDetectionChanged
)
StyledToggle(
label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description),
checked = automaticConnectionEnabled,
onCheckedChange = onAutomaticConnectionChanged
)
}
}
@@ -0,0 +1,106 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("unused")
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val SelectedColorBlue = Color(0xFF0A84FF)
private val UnselectedColor = Color(0x593C3C3E)
private val TextColor = Color.White
private val IconTint = Color.White
@Composable
fun ControlCenterButton(
label: String,
icon: Painter,
onClick: () -> Unit,
modifier: Modifier = Modifier,
iconAreaSize: Dp,
isSelected: Boolean,
backgroundBrush: Brush? = null
) {
val targetBackgroundColor = if (isSelected) SelectedColorBlue else UnselectedColor
val backgroundColor by animateColorAsState(
targetValue = targetBackgroundColor,
label = "ButtonBackground"
)
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(iconAreaSize)
.clip(CircleShape)
.background(backgroundBrush ?: Brush.linearGradient(colors=listOf(backgroundColor, backgroundColor)))
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = icon,
contentDescription = null,
tint = IconTint,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = label,
color = TextColor,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 2
)
}
}
@@ -0,0 +1,242 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
private val SelectedIndicatorColorBlue = Color(0xFF0A84FF)
private val TextColor = Color.White
private val IconTintUnselected = Color.White
private val IconTintSelected = Color.White
internal val AdaptiveRainbowBrush = Brush.sweepGradient(
colors = listOf(
Color(0xFFB03A2F), Color(0xFFB07A2F), Color(0xFFB0A22F), Color(0xFF6AB02F),
Color(0xFF2FAAB0), Color(0xFF2F5EB0), Color(0xFF7D2FB0), Color(0xFFB02F7D),
Color(0xFFB03A2F)
)
)
internal val IconAreaSize = 72.dp
private val IconSize = 42.dp
private val IconRowHeight = IconAreaSize + 12.dp
private val TextRowHeight = 24.dp
private val TextSize = 12.sp
@Composable
fun ControlCenterNoiseControlSegmentedButton(
modifier: Modifier = Modifier,
availableModes: List<NoiseControlMode>,
selectedMode: NoiseControlMode,
onModeSelected: (NoiseControlMode) -> Unit
) {
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
val density = LocalDensity.current
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
val itemCount = availableModes.size
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
if (itemCount > 0 && iconRowWidthPx > 0) {
iconRowWidthPx / itemCount
} else {
0f
}
}
val itemSlotWidthDp = remember(itemSlotWidthPx) { with(density) { itemSlotWidthPx.toDp() } }
val iconAreaSizePx = remember { with(density) { IconAreaSize.toPx() } }
val targetIndicatorStartPx = remember(selectedIndex, itemSlotWidthPx, iconAreaSizePx) {
if (itemSlotWidthPx > 0) {
val slotCenterPx = (selectedIndex + 0.5f) * itemSlotWidthPx
slotCenterPx - (iconAreaSizePx / 2f)
} else {
0f
}
}
val indicatorOffset: Dp by animateDpAsState(
targetValue = with(density) { targetIndicatorStartPx.toDp() },
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium
),
label = "IndicatorOffset"
)
val indicatorBackground = remember(selectedMode) {
when (selectedMode) {
NoiseControlMode.ADAPTIVE -> AdaptiveRainbowBrush
NoiseControlMode.OFF -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorGray, SelectedIndicatorColorGray))
NoiseControlMode.TRANSPARENCY,
NoiseControlMode.NOISE_CANCELLATION -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorBlue, SelectedIndicatorColorBlue))
}
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(IconRowHeight)
.clip(CircleShape)
.background(ContainerColor)
.onSizeChanged { iconRowWidthPx = it.width.toFloat() },
contentAlignment = Alignment.Center
) {
Box(
Modifier
.align(Alignment.CenterStart)
.offset(x = indicatorOffset)
.size(IconAreaSize)
.clip(CircleShape)
.background(indicatorBackground)
)
Row(
modifier = Modifier.fillMaxWidth().align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
availableModes.forEach { mode ->
val isSelected = selectedMode == mode
NoiseControlIconItem(
modifier = Modifier.size(IconAreaSize),
mode = mode,
isSelected = isSelected,
onClick = { onModeSelected(mode) }
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(TextRowHeight),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
availableModes.forEach { mode ->
val isSelected = selectedMode == mode
Text(
text = getModeLabel(mode),
color = TextColor,
fontSize = TextSize,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
textAlign = TextAlign.Center,
modifier = Modifier.width(itemSlotWidthDp.coerceAtLeast(1.dp))
)
}
}
}
}
@Composable
private fun NoiseControlIconItem(
modifier: Modifier = Modifier,
mode: NoiseControlMode,
isSelected: Boolean,
onClick: () -> Unit
) {
val iconRes = remember(mode) { getModeIconRes(mode) }
val tint = IconTintUnselected
Box(
modifier = modifier
.clip(CircleShape)
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = getModeLabel(mode),
tint = if (isSelected && mode == NoiseControlMode.ADAPTIVE) IconTintSelected else tint,
modifier = Modifier.size(IconSize)
)
}
}
private fun getModeIconRes(mode: NoiseControlMode): Int {
return when (mode) {
NoiseControlMode.OFF -> R.drawable.noise_cancellation
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
}
}
private fun getModeLabel(mode: NoiseControlMode): String {
return when (mode) {
NoiseControlMode.OFF -> "Off"
NoiseControlMode.TRANSPARENCY -> "Transparency"
NoiseControlMode.ADAPTIVE -> "Adaptive"
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancellation"
}
}
@@ -0,0 +1,56 @@
package me.kavishdevar.librepods.presentation.components
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun DeviceInfoCard() {
StyledList(title = stringResource(R.string.device_info)) {
StyledListItem(
name = stringResource(R.string.manufacturer),
description = Build.MANUFACTURER,
enabled = false
)
StyledListItem(
name = stringResource(R.string.model_number),
description = Build.MODEL,
enabled = false
)
StyledListItem(
name = stringResource(R.string.build_id),
description = Build.DISPLAY,
enabled = false
)
StyledListItem(
name = stringResource(R.string.version),
description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})",
enabled = false
)
StyledListItem(
name = stringResource(R.string.xposed_available),
description = if (XposedState.isAvailable) {
stringResource(R.string.yes)
} else {
stringResource(R.string.no)
},
enabled = false
)
StyledListItem(
name = stringResource(R.string.app_enabled_in_xposed),
description = if (XposedState.bluetoothScopeEnabled) {
stringResource(R.string.yes)
} else {
stringResource(R.string.no)
},
enabled = false
)
}
}
@@ -18,13 +18,11 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components.apple
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
@@ -40,24 +38,24 @@ fun HearingHealthSettings(
if (hasPPECapability && shouldShowHearingAid) {
StyledList(title = stringResource(R.string.hearing_health)) {
StyledListItem(
contentText = stringResource(R.string.hearing_protection),
name = stringResource(R.string.hearing_protection),
onClick = navigateToHearingProtection
)
StyledListItem(
contentText = stringResource(R.string.hearing_aid),
name = stringResource(R.string.hearing_aid),
onClick = navigateToHearingAid
)
}
} else if (shouldShowHearingAid) {
StyledListItem(
contentText = stringResource(R.string.hearing_aid),
name = stringResource(R.string.hearing_aid),
onClick = navigateToHearingAid
)
} else if (hasPPECapability) {
StyledListItem(
title = stringResource(R.string.hearing_health),
contentText = stringResource(R.string.hearing_protection),
name = stringResource(R.string.hearing_protection),
onClick = navigateToHearingProtection
)
}
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.apple
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -70,7 +70,7 @@ fun NoiseControlButton(
@Composable
fun NoiseControlButtonPreview() {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = {},
textColor = Color.White,
)
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components.apple
package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint
import androidx.compose.animation.core.AnimationSpec
@@ -29,12 +29,12 @@ import androidx.compose.foundation.background
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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -63,7 +63,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
@@ -75,7 +74,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.devices.NoiseControlMode
import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -89,8 +88,7 @@ import kotlin.math.roundToInt
fun NoiseControlSettings(
showOffListeningMode: Boolean,
noiseControlModeValue: Int,
onNoiseControlModeChanged: (Int) -> Unit,
showLabels: Boolean = true
onNoiseControlModeChanged: (Int) -> Unit
) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
@@ -100,7 +98,7 @@ fun NoiseControlSettings(
Triple(
NoiseControlMode.OFF,
R.string.off,
R.drawable.ic_noise_cancellation
R.drawable.noise_cancellation
)
)
}
@@ -109,21 +107,21 @@ fun NoiseControlSettings(
Triple(
NoiseControlMode.TRANSPARENCY,
R.string.transparency,
R.drawable.ic_transparency
R.drawable.transparency
)
)
add(
Triple(
NoiseControlMode.ADAPTIVE,
R.string.adaptive,
R.drawable.ic_adaptive
R.drawable.adaptive
)
)
add(
Triple(
NoiseControlMode.NOISE_CANCELLATION,
R.string.noise_cancellation,
R.drawable.ic_noise_cancellation
R.drawable.noise_cancellation
)
)
}
@@ -131,14 +129,15 @@ fun NoiseControlSettings(
val selectedMode = NoiseControlMode.entries[(noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex)]
Column {
if (showLabels) {
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 12.dp)
) {
Text(
text = stringResource(R.string.noise_control),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelSmallEmphasized,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 12.dp)
style = MaterialTheme.typography.labelSmallEmphasized
)
}
Row(
@@ -163,7 +162,8 @@ fun NoiseControlSettings(
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
colors = ToggleButtonDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
colors = ToggleButtonDefaults.toggleButtonColors()
.copy(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier.fillMaxWidth()
) {
Icon(
@@ -173,27 +173,22 @@ fun NoiseControlSettings(
)
}
if (showLabels) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
maxLines = 2,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
}
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
maxLines = 2,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
// TODO: make it draggable (?)
DesignSystem.Apple -> {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val backgroundColor = if (isDarkTheme) (if (showLabels) Color(0xFF1C1C1E) else MaterialTheme.colorScheme.surfaceContainerLow) else Color(0xFFE3E3E8)
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
@@ -249,14 +244,16 @@ fun NoiseControlSettings(
onModeSelected(noiseControlMode.value, received = true)
if (showLabels) {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 4.dp)
) {
Text(
text = stringResource(R.string.noise_control),
color = MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 12.dp)
style = MaterialTheme.typography.labelSmallEmphasized
)
}
BoxWithConstraints(
@@ -317,7 +314,7 @@ fun NoiseControlSettings(
) {
if (showOffListeningMode) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
modifier = Modifier.weight(1f),
@@ -332,7 +329,7 @@ fun NoiseControlSettings(
)
}
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.ic_transparency),
icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
modifier = Modifier.weight(1f),
@@ -346,7 +343,7 @@ fun NoiseControlSettings(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.ic_adaptive),
icon = ImageBitmap.imageResource(R.drawable.adaptive),
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
modifier = Modifier.weight(1f),
@@ -360,7 +357,7 @@ fun NoiseControlSettings(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
modifier = Modifier.weight(1f),
@@ -414,7 +411,7 @@ fun NoiseControlSettings(
) {
if (showOffListeningMode) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
modifier = Modifier.weight(1f),
@@ -429,7 +426,7 @@ fun NoiseControlSettings(
)
}
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.ic_transparency),
icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
modifier = Modifier.weight(1f),
@@ -443,7 +440,7 @@ fun NoiseControlSettings(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.ic_adaptive),
icon = ImageBitmap.imageResource(R.drawable.adaptive),
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
modifier = Modifier.weight(1f),
@@ -457,7 +454,7 @@ fun NoiseControlSettings(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
modifier = Modifier.weight(1f),
@@ -466,39 +463,37 @@ fun NoiseControlSettings(
}
}
if (showLabels) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
) {
if (showOffListeningMode) {
Text(
text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
) {
if (showOffListeningMode) {
Text(
text = stringResource(R.string.transparency),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.adaptive),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.noise_cancellation),
text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
Text(
text = stringResource(R.string.transparency),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.adaptive),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.noise_cancellation),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
}
}
@@ -511,7 +506,7 @@ fun NoiseControlSettings(
@Composable
fun NoiseControlSettingsPreview() {
LibrePodsTheme(
designSystem = DesignSystem.Material
m3eEnabled = true
) {
Box(
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
@@ -16,14 +16,12 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.apple
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
@Composable
fun PressAndHoldSettings(
@@ -48,13 +46,13 @@ fun PressAndHoldSettings(
title = stringResource(R.string.press_and_hold_airpods)
) {
StyledListItem(
contentText = stringResource(R.string.left),
supportingText = leftActionText,
name = stringResource(R.string.left),
description = leftActionText,
onClick = navigateToLeftLongPress
)
StyledListItem(
contentText = stringResource(R.string.right),
supportingText = rightActionText,
name = stringResource(R.string.right),
description = rightActionText,
onClick = navigateToRightLongPress,
)
}
@@ -1,12 +1,12 @@
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
@@ -15,7 +15,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import com.kyant.backdrop.backdrops.LayerBackdrop
@@ -36,7 +35,7 @@ fun StyledBottomSheet(
) {
if (!visible) return
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val isDarkTheme = isSystemInDarkTheme()
val sheetState = rememberModalBottomSheetState(false) // move this to parent composable
val isExpanded = sheetState.targetValue == SheetValue.Expanded
@@ -73,7 +72,9 @@ fun StyledBottomSheet(
},
onDrawSurface = {
drawRect(
if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(0xFFE0E0E0).copy(alpha = 0.45f)
if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(
0xFFE0E0E0
).copy(alpha = 0.45f)
)
}
)
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import android.graphics.RuntimeShader
import android.os.Build
@@ -35,7 +35,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -61,7 +60,6 @@ import androidx.compose.ui.util.fastCoerceAtMost
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
@@ -86,9 +84,9 @@ enum class MaterialButtonStyle {
@Composable
fun StyledButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
backdrop: Backdrop = rememberLayerBackdrop(),
backdrop: Backdrop,
modifier: Modifier = Modifier,
isInteractive: Boolean = true,
tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
@@ -104,8 +102,7 @@ fun StyledButton(
Button(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content,
enabled = enabled,
content = content
)
}
MaterialButtonStyle.Tonal -> {
@@ -113,7 +110,6 @@ fun StyledButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content,
enabled = enabled,
colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor)
)
}
@@ -122,8 +118,7 @@ fun StyledButton(
OutlinedButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content,
enabled = enabled,
content = content
)
}
@@ -131,14 +126,12 @@ fun StyledButton(
TextButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content,
enabled = enabled,
content = content
)
}
}
}
DesignSystem.Apple -> {
val defaultButtonColor = MaterialTheme.colorScheme.surfaceContainerHigh
val isInteractive = enabled && isInteractive
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
@@ -202,9 +195,6 @@ half4 main(float2 coord) {
if (isPressed && enabled) {
drawRect(Color.Black.copy(alpha = 0.4f))
drawRect(Color.White.copy(alpha = 0.2f))
} else {
drawRect(defaultButtonColor)
}
}
},
@@ -16,16 +16,18 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -34,10 +36,9 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -59,13 +60,16 @@ import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
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.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
@@ -80,7 +84,7 @@ import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.utils.inspectDragGestures
@@ -93,12 +97,13 @@ import kotlin.math.tanh
@Composable
fun StyledIconButton(
modifier: Modifier = Modifier,
icon: String,
iconTint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit,
enabled: Boolean = true,
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal,
content: @Composable () -> Unit,
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal
) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
@@ -109,7 +114,13 @@ fun StyledIconButton(
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
content()
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
MaterialButtonStyle.Filled -> {
@@ -118,7 +129,13 @@ fun StyledIconButton(
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
content()
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
MaterialButtonStyle.Outlined -> {
@@ -127,7 +144,13 @@ fun StyledIconButton(
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
content()
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
MaterialButtonStyle.Normal -> {
@@ -136,13 +159,20 @@ fun StyledIconButton(
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
content()
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
DesignSystem.Apple -> {
val haptics = LocalHapticFeedback.current
val darkMode = isSystemInDarkTheme()
val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
@@ -155,7 +185,8 @@ fun StyledIconButton(
val density = LocalDensity.current
val interactiveHighlightShader = remember {
RuntimeShader(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader(
"""
uniform float2 size;
layout(color) uniform half4 color;
@@ -169,8 +200,11 @@ half4 main(float2 coord) {
return color * intensity;
}"""
)
} else {
null
}
}
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val isDarkTheme = isSystemInDarkTheme()
TextButton(
onClick = {
if (enabled) {
@@ -279,28 +313,35 @@ half4 main(float2 coord) {
if (!enabled) return@drawBackdrop
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) {
drawRect(
Color.White.copy(0.1f * progress),
blendMode = BlendMode.Plus
)
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
drawRect(
Color.White.copy(0.1f * progress),
blendMode = BlendMode.Plus
)
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
)
}
drawRect(
ShaderBrush(interactiveHighlightShader),
blendMode = BlendMode.Plus
)
} else {
drawRect(
Color.White.copy(0.25f * progress),
blendMode = BlendMode.Plus
)
}
drawRect(
ShaderBrush(interactiveHighlightShader),
blendMode = BlendMode.Plus
)
}
},
effects = {
@@ -363,7 +404,15 @@ half4 main(float2 coord) {
}
.size(with(density) { 48.sp.toDp() }),
) {
content()
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Normal,
color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
@@ -377,16 +426,12 @@ fun StyledIconButtonPreview() {
.height(120.dp)
.width(200.dp)
.background(
MaterialTheme.colorScheme.surfaceContainer,
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
RoundedCornerShape(28.dp)
), contentAlignment = Alignment.Center) {
StyledIconButton(
icon = "􀍟",
onClick = { }
) {
Icon(
imageVector = LocalIcons.current.Settings,
contentDescription = "Settings",
)
}
)
}
}
@@ -1,10 +1,11 @@
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -13,16 +14,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
@@ -32,12 +29,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -48,12 +50,7 @@ fun StyledInputField(
focusRequester: FocusRequester,
placeholder: String = "",
singleLine: Boolean = true,
forceApple: Boolean = false,
colors: ListItemColors = ListItemDefaults.segmentedColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
forceApple: Boolean = false
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple
@@ -74,6 +71,9 @@ fun StyledInputField(
)
}
else {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val minHeight = if (singleLine) 58.dp else 120.dp
val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
val hasText = inputState.text.isNotEmpty()
@@ -99,7 +99,7 @@ fun StyledInputField(
.fillMaxWidth()
.heightIn(min = minHeight)
.background(
colors.containerColor,
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
@@ -112,8 +112,12 @@ fun StyledInputField(
BasicTextField(
state = inputState,
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
textStyle = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
cursorBrush = SolidColor(textColor),
decorator = { innerTextField ->
Row(
modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
@@ -130,8 +134,12 @@ fun StyledInputField(
) {
Text(
text = placeholder,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Light,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.8f)
),
modifier = Modifier
.offset(y = yOffset)
)
@@ -145,11 +153,15 @@ fun StyledInputField(
inputState.clearText()
}
) {
Icon(
imageVector = LocalIcons.current.CloseCircle,
contentDescription = "Clear text",
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
modifier = Modifier.size(16.dp)
Text(
text = "􀁡",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.6f
)
),
)
}
}
@@ -1,4 +1,4 @@
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -7,15 +7,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -33,23 +28,12 @@ import me.kavishdevar.librepods.presentation.theme.sectionHeader
@Composable
fun StyledList(
modifier: Modifier = Modifier,
scrollEnabled: Boolean = false,
title: String? = null,
description: String? = null,
colors: ListItemColors = ListItemDefaults.segmentedColors().run {
copy(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
key: Any? = null,
content: @Composable StyledListScope.() -> Unit
) {
val scope = StyledListScope()
key(key) {
scope.content()
}
scope.content()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
@@ -68,13 +52,11 @@ fun StyledList(
)
}
}
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.background(if (m3eEnabled) Color.Transparent else colors.containerColor, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
.clip(RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
.then(if (scrollEnabled) Modifier.verticalScroll(scrollState) else Modifier)
) {
if (m3eEnabled && description != null) {
Text(
@@ -92,10 +74,9 @@ fun StyledList(
}
}
if (!m3eEnabled && description != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmallEmphasized,
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
modifier = Modifier.padding(horizontal = 16.dp)
)
@@ -118,23 +99,22 @@ class StyledListScope {
@Composable
fun StyledListDemo() {
LibrePodsTheme(
designSystem = DesignSystem.Apple,
darkTheme = false
m3eEnabled = true
) {
val backgroundC = MaterialTheme.colorScheme.background
StyledScaffold(
title = "StyledListTest",
navigateBack = null
) { topPadding, bottomPadding ->
title = "${backgroundC.red}, ${backgroundC.green}, ${backgroundC.blue}"
) {
Column (
modifier = Modifier.padding(horizontal = 12.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
Spacer(modifier = Modifier.height(56.dp))
StyledList(
title = "hello"
) {
for (i in 0..2) {
StyledListItem(
contentText = i.toString(),
name = i.toString(),
onClick = {}
)
}
@@ -146,7 +126,6 @@ fun StyledListDemo() {
onCheckedChange = { checked.value = it },
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
@@ -0,0 +1,409 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
@Composable
fun StyledListItem(
modifier: Modifier = Modifier,
title: String? = null,
name: String,
onClick: (() -> Unit)?,
description: String? = null,
height: Dp = 58.dp,
enabled: Boolean = true,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column {
title?.let {
Box(
modifier = Modifier
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp)
) {
Text(
text = it,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
}
Column(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(
if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface,
RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)
)
.clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp))
) {
StyledListItemContent(
name = name,
onClick = onClick,
description = description,
height = height,
enabled = enabled,
index = 0,
count = 1,
orientation = orientation,
leadingContent = leadingContent,
trailingContent = trailingContent
)
}
}
}
@Composable
fun StyledListScope.StyledListItem(
modifier: Modifier = Modifier,
name: String,
onClick: (() -> Unit)? = null,
description: String? = null,
enabled: Boolean = onClick != null,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
selected: Boolean? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
item { index, count ->
StyledListItemContent(
name = name,
onClick = onClick,
description = description,
enabled = enabled,
index = index,
count = count,
orientation = orientation,
modifier = modifier,
selected = selected,
leadingContent = leadingContent,
trailingContent = trailingContent
)
}
}
enum class ListItemOrientation{
Horizontal,
Vertical
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun StyledListItemContent(
modifier: Modifier = Modifier,
name: String,
onClick: (() -> Unit)?,
description: String? = null,
height: Dp = 58.dp,
enabled: Boolean = true,
index: Int,
count: Int,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
selected: Boolean? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val isDarkTheme = isSystemInDarkTheme()
val surfaceColor = MaterialTheme.colorScheme.surface
val surfaceDimColor = MaterialTheme.colorScheme.surfaceDim
var backgroundColor by remember { mutableStateOf(surfaceColor) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
when (LocalDesignSystem.current) {
DesignSystem.Apple -> {
val trailingContentDefault: @Composable () -> Unit = {
if (trailingContent == null) {
if (onClick != null) {
if (selected != null) {
val floatAnimateState by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)
Text(
text = "􀆅",
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState),
),
modifier = Modifier.padding(end = 4.dp)
)
} else {
Text(
text = "􀯻",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(0.6f),
modifier = Modifier
.padding(start = if (description != null) 6.dp else 0.dp)
)
}
}
} else {
trailingContent()
}
}
Column (
modifier = Modifier
.background(
animatedBackgroundColor,
when {
(index == 0 && count == 1) -> {
RoundedCornerShape(28.dp)
}
(index == 0) -> {
RoundedCornerShape(
topStart = 28.dp,
topEnd = 28.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
}
(index + 1 == count) -> {
RoundedCornerShape(
topStart = 0.dp,
topEnd = 0.dp,
bottomStart = 28.dp,
bottomEnd = 28.dp
)
}
else -> {
RectangleShape
}
}
)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (enabled) {
backgroundColor = surfaceDimColor
tryAwaitRelease()
backgroundColor = surfaceColor
}
},
onTap = {
if (enabled) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.ContextClick
)
}
onClick?.invoke()
}
}
)
}
.heightIn(min = height)
.padding(horizontal = 16.dp)
) {
Row(
modifier = Modifier
.heightIn(min = height)
.padding(vertical = if (orientation == ListItemOrientation.Vertical) 12.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leadingContent != null) {
leadingContent()
Spacer(modifier = Modifier.width(12.dp))
}
Column (verticalArrangement = Arrangement.Center) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (description != null && orientation == ListItemOrientation.Vertical) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f), // TODO: move to color scheme
)
}
}
Spacer(modifier = Modifier.weight(1f))
if (orientation == ListItemOrientation.Horizontal && description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f) // TODO: move to color scheme
)
}
trailingContentDefault()
}
if (index+1 != count) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = if (leadingContent != null) 12.dp else 0.dp)
)
}
}
}
DesignSystem.Material -> {
val defaultShape = when {
count == 1 -> RoundedCornerShape(24.dp)
index == 0 -> RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
)
index == count - 1 -> RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
else -> RoundedCornerShape(8.dp)
}
Column {
SegmentedListItem(
modifier = modifier.heightIn(min = 64.dp),
shapes = ListItemDefaults.shapes().copy(
shape = defaultShape,
pressedShape = RoundedCornerShape(24.dp),
selectedShape = RoundedCornerShape(24.dp),
hoveredShape = RoundedCornerShape(24.dp),
),
onClick = onClick ?: {},
leadingContent = leadingContent,
trailingContent = {
if (trailingContent == null) {
if (onClick != null) {
if (selected == true) {
Icon(Icons.Default.Check, contentDescription = null)
} else if (selected == null) {
Icon(
Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = null
)
}
}
} else {
trailingContent()
}
},
supportingContent = {
if (description != null) Text(
description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 4.dp)
)
},
content = {
Text(
text = name,
style = MaterialTheme.typography.labelMediumEmphasized,
modifier = Modifier.padding(
top = 4.dp,
bottom = if (description != null) 0.dp else 4.dp
)
)
},
verticalAlignment = Alignment.CenterVertically,
colors = if (onClick == null) {
ListItemDefaults.segmentedColors().run {
copy(
disabledContentColor = contentColor,
disabledSupportingContentColor = supportingContentColor,
disabledTrailingContentColor = trailingContentColor
)
}
} else ListItemDefaults.segmentedColors(),
enabled = onClick != null && enabled,
selected = selected ?: false
)
if (index+1 != count) {
Spacer(modifier = Modifier.height(2.dp))
}
}
}
}
}
@@ -16,10 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import android.graphics.RenderEffect
import android.graphics.Shader
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
@@ -29,13 +27,11 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.isSystemInDarkTheme
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.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -64,15 +60,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalLayoutDirection
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.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop
@@ -81,10 +77,7 @@ import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.navigation.LocalIsCurrentEntry
import me.kavishdevar.librepods.presentation.navigation.LocalSharedTransitionScope
import me.kavishdevar.librepods.presentation.navigation.LocalTransitionProgress
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -94,11 +87,13 @@ fun StyledScaffold(
modifier: Modifier = Modifier,
visible: Boolean = true,
title: String,
navigateBack: (() -> Unit)?,
showBackButton: Boolean = false,
onNavigateBack: () -> Unit = {},
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (topPadding: Dp, bottomPadding: Dp) -> Unit
content: @Composable () -> Unit
) {
val isDarkTheme = isSystemInDarkTheme()
val hazeState = rememberHazeState(blurEnabled = true)
when (LocalDesignSystem.current) {
@@ -114,18 +109,14 @@ fun StyledScaffold(
) {
TopAppBar(
navigationIcon = {
if (navigateBack != null) {
if (showBackButton) {
Row {
Spacer(modifier = Modifier.width(12.dp))
FilledTonalIconButton(
onClick = navigateBack,
onClick = onNavigateBack,
modifier = Modifier
.minimumInteractiveComponentSize()
.size(
IconButtonDefaults.mediumContainerSize(
IconButtonDefaults.IconButtonWidthOption.Narrow
)
),
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
@@ -143,7 +134,7 @@ fun StyledScaffold(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = if (navigateBack != null) 8.dp else 12.dp, end = 12.dp),
modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp),
style = MaterialTheme.typography.titleSmall
)
}
@@ -161,18 +152,11 @@ fun StyledScaffold(
) { paddingValues ->
Box(
modifier = modifier
.then(
if (visible) Modifier.padding(
start = paddingValues.calculateStartPadding(
LocalLayoutDirection.current
),
end = paddingValues.calculateEndPadding(LocalLayoutDirection.current)
) else Modifier
)
.then(if (visible) Modifier.padding(paddingValues) else Modifier)
.fillMaxSize()
.hazeSource(hazeState)
) {
content(paddingValues.calculateTopPadding(), paddingValues.calculateBottomPadding())
content()
}
}
}
@@ -182,102 +166,46 @@ fun StyledScaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier
.then(
if (MaterialTheme.colorScheme.surface.luminance() > 0.5) Modifier.shadow(
if (!isDarkTheme) Modifier.shadow(
elevation = 36.dp,
shape = RoundedCornerShape(52.dp),
ambientColor = Color.Black,
spotColor = Color.Black
) else Modifier
)
.clip(RoundedCornerShape(52.dp))
) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding()
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(52.dp))
.padding(start = startPadding, end = endPadding)
) {
val backdrop = rememberLayerBackdrop()
val bgColor = MaterialTheme.colorScheme.surfaceContainer
// val density = LocalDensity.current
// val screenWidthPx = with(density) {
// LocalWindowInfo.current.containerDpSize.width.toPx()
// }
val isCurrentEntry = LocalIsCurrentEntry.current
val transitionProgress = LocalTransitionProgress.current
val sharedTransitionScope = LocalSharedTransitionScope.current
val showBackButton = if (transitionProgress == 0f) navigateBack != null else !isCurrentEntry
if (showBackButton) {
with(sharedTransitionScope) {
Box(
modifier = Modifier
.zIndex(3f)
.padding(top = topPadding, start = 8.dp)
.align(Alignment.TopStart)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
event.changes.forEach { change ->
change.consume()
}
} while (event.changes.any { it.pressed })
}
}
}
.renderInSharedTransitionScopeOverlay(
zIndexInOverlay = 3f,
renderInOverlay = {
!isCurrentEntry && transitionProgress != 0f
}
)
.graphicsLayer { // AI generated
if (!isCurrentEntry && navigateBack == null && transitionProgress < 0f) {
val progress = (-transitionProgress).coerceIn(0f, 1f)
val eased = progress * progress * (3f - 2f * progress)
val scale = 1f - 0.18f * eased
scaleX = scale
scaleY = scale
alpha = 1f - 0.28f * eased
val blur = 8f * progress
renderEffect = RenderEffect.createBlurEffect(
blur,
blur,
Shader.TileMode.DECAL
).asComposeRenderEffect()
}
}
) {
StyledIconButton(
onClick = { navigateBack?.invoke() },
backdrop = backdrop // i know, this doesn't capture what's actually beneath it. but it's going to matter just in the transition.
) {
Icon(
imageVector = LocalIcons.current.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
AnimatedVisibility(
visible = showBackButton,
enter = fadeIn() + scaleIn(
initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut() + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier
.zIndex(3f)
.padding(top = topPadding, start = 8.dp)
.align(Alignment.TopStart)
) {
StyledIconButton(
onClick = onNavigateBack,
icon = "􀯶",
backdrop = backdrop
)
}
AnimatedVisibility(
@@ -296,15 +224,17 @@ fun StyledScaffold(
.fillMaxWidth()
.layerBackdrop(backdrop)
){
val scrimColor = MaterialTheme.colorScheme.scrim
Box(
modifier = Modifier.hazeEffect(
state = hazeState,
) {
backgroundColor = bgColor
tints = listOf(
HazeTint(scrimColor)
HazeTint(
if (isDarkTheme) Color.Black.copy(0.55f) else Color(
0xFFF2F2F7
).copy(alpha = 0.85f)
)
)
blurRadius = 6.dp
}
@@ -315,7 +245,12 @@ fun StyledScaffold(
Crossfade(targetState = title) {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = if (isDarkTheme) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
@@ -338,21 +273,6 @@ fun StyledScaffold(
.zIndex(3f)
.padding(top = topPadding, end = 8.dp)
.align(Alignment.TopEnd)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
event.changes.forEach { change ->
change.consume()
}
} while (event.changes.any { it.pressed })
}
}
}
) {
Row{
actionButtons.forEach { actionButton ->
@@ -366,7 +286,7 @@ fun StyledScaffold(
.hazeSource(hazeState)
.fillMaxSize()
) {
content(topPadding + 64.dp, bottomPadding)
content()
}
}
}
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint
import android.content.res.Configuration
@@ -28,6 +28,7 @@ import androidx.compose.foundation.background
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
@@ -41,8 +42,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
@@ -61,8 +60,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
@@ -73,11 +70,15 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt
import androidx.compose.ui.util.lerp
@@ -94,7 +95,6 @@ import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -223,20 +223,15 @@ fun StyledSlider(
backdrop: Backdrop = rememberLayerBackdrop(),
snapPoints: List<Float> = emptyList(),
snapThreshold: Float = 0.05f,
startImageVector: ImageVector? = null,
endImageVector: ImageVector? = null,
startIcon: String? = null,
endIcon: String? = null,
startLabel: String? = null,
endLabel: String? = null,
independent: Boolean = false,
description: String? = null,
enabled: Boolean = true,
index: Int = 0,
count: Int = 1,
colors: ListItemColors = ListItemDefaults.segmentedColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
count: Int = 1
) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
@@ -322,13 +317,8 @@ fun StyledSlider(
verticalAlignment = Alignment.CenterVertically
) {
startImageVector?.let {
Icon(
imageVector = it,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
startIcon?.let {
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
Spacer(Modifier.width(12.dp))
}
@@ -353,19 +343,13 @@ fun StyledSlider(
enabled = enabled
)
endImageVector?.let {
endIcon?.let {
Spacer(Modifier.width(12.dp))
Icon(
imageVector = it,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
}
}
}
},
colors = colors
}
)
if (index + 1 != count) {
@@ -377,16 +361,20 @@ fun StyledSlider(
}
DesignSystem.Apple -> {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val backgroundColor =
if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isDarkTheme = isSystemInDarkTheme()
val trackColor =
if (isDarkTheme) Color(0xFF787880).copy(0.36f)
else Color(0xFF787878).copy(0.2f)
val accentColor =
if (enabled) {
MaterialTheme.colorScheme.primary
if (isDarkTheme) Color(0xFF0091FF)
else Color(0xFF0088FF)
} else {
trackColor
}
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
val fraction by derivedStateOf {
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
@@ -407,7 +395,7 @@ fun StyledSlider(
val content = @Composable {
Box(
Modifier
.fillMaxWidth(if (startImageVector == null && endImageVector == null) 0.95f else 1f)
.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
) {
Box(
Modifier
@@ -430,13 +418,21 @@ fun StyledSlider(
) {
Text(
text = startLabel ?: "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = endLabel ?: "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Spacer(modifier = Modifier.height(12.dp))
@@ -446,7 +442,7 @@ fun StyledSlider(
.fillMaxWidth()
.padding(vertical = 4.dp)
.then(
if (startImageVector == null && endImageVector == null) Modifier.padding(
if (startIcon == null && endIcon == null) Modifier.padding(
horizontal = 8.dp
) else Modifier
),
@@ -455,17 +451,20 @@ fun StyledSlider(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
startImageVector?.let{
Icon(
imageVector = it,
contentDescription = null,
tint = accentColor,
if (startIcon != null) {
Text(
text = startIcon,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 12.dp)
.size(24.dp)
.onGloballyPositioned { coordinates ->
.onGloballyPositioned {
startIconWidthState.floatValue =
coordinates.size.width.toFloat()
it.size.width.toFloat()
}
)
}
@@ -504,14 +503,17 @@ fun StyledSlider(
}
)
}
if (endImageVector != null) {
Icon(
imageVector = endImageVector,
contentDescription = null,
tint = accentColor,
if (endIcon != null) {
Text(
text = endIcon,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 12.dp)
.size(24.dp)
.onGloballyPositioned {
endIconWidthState.floatValue =
it.size.width.toFloat()
@@ -529,13 +531,13 @@ fun StyledSlider(
) {
if (snapPoints.isNotEmpty()) {
val trackWidth =
if (startImageVector != null && endImageVector != null) trackWidthState.floatValue - with(
if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(
density
) { 6.dp.toPx() } * 2 else trackWidthState.floatValue - with(
density
) { 22.dp.toPx() }
val startOffset =
if (startImageVector != null) startIconWidthState.floatValue + with(
if (startIcon != null) startIconWidthState.floatValue + with(
density
) { 34.dp.toPx() } else with(density) { 14.dp.toPx() }
Box(
@@ -570,7 +572,7 @@ fun StyledSlider(
Modifier
.graphicsLayer {
val startOffset =
if (startImageVector != null)
if (startIcon != null)
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
else
with(density) { 8.dp.toPx() }
@@ -702,8 +704,12 @@ fun StyledSlider(
if (label != null) {
Text(
text = label,
style = MaterialTheme.typography.labelSmallEmphasized,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = labelTextColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
)
}
@@ -711,7 +717,7 @@ fun StyledSlider(
Box(
modifier = Modifier
.fillMaxWidth()
.background(colors.containerColor, RoundedCornerShape(28.dp))
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
.heightIn(min = 58.dp),
contentAlignment = Alignment.Center
@@ -722,10 +728,16 @@ fun StyledSlider(
if (description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Light),
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
alpha = 0.6f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 18.dp, vertical = 8.dp)
.padding(horizontal = 18.dp, vertical = 4.dp)
)
}
}
@@ -749,23 +761,21 @@ private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.
return if (abs(nearest - value) <= threshold) nearest else value
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE)
@Composable
fun StyledSliderPreview() {
val a = remember { mutableFloatStateOf(0.5f) }
LibrePodsTheme(
designSystem = DesignSystem.Apple
m3eEnabled = true
) {
StyledScaffold(
title = "test",
navigateBack = null
) { topPadding, bottomPadding ->
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
Spacer(modifier = Modifier.height(72.dp))
StyledSlider(
value = a.floatValue,
onValueChange = {
@@ -775,8 +785,8 @@ fun StyledSliderPreview() {
snapPoints = listOf(1f),
snapThreshold = 0.1f,
independent = true,
startImageVector = LocalIcons.current.LeftCircleFill,
endImageVector = LocalIcons.current.RightCircleFill,
startIcon = "A",
endIcon = "B",
)
StyledSlider(
label = "Small label",
@@ -789,22 +799,9 @@ fun StyledSliderPreview() {
snapPoints = listOf(1f),
snapThreshold = 0.1f,
independent = true,
startImageVector = LocalIcons.current.SpeakerMin,
endImageVector = LocalIcons.current.SpeakerMax,
startIcon = "A",
endIcon = "B",
)
val sliderValue = remember { mutableFloatStateOf(50f) }
StyledSlider(
label = stringResource(R.string.customize_adaptive_audio),
value = sliderValue.floatValue,
onValueChange = { sliderValue.floatValue = it },
valueRange = 0f..100f,
snapPoints = listOf(0f, 50f, 100f),
startImageVector = LocalIcons.current.SpeakerMin,
endImageVector = LocalIcons.current.SpeakerMax,
independent = true,
description = stringResource(R.string.adaptive_audio_description),
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState
@@ -28,12 +28,12 @@ import androidx.compose.foundation.background
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.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -57,7 +57,6 @@ import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged
@@ -83,9 +82,8 @@ fun StyledSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true,
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer,
) {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val isDarkTheme = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
@@ -237,7 +235,7 @@ fun StyledSwitch(
right = size.width,
bottom = size.height,
paint = Paint().apply {
color = backgroundColor
color = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
}
)
scale(0.7f) {
@@ -292,9 +290,11 @@ fun StyledSwitch(
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
@Composable
fun StyledSwitchPreview() {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.background(backgroundColor)
.width(100.dp)
.height(150.dp),
contentAlignment = Alignment.Center
@@ -308,9 +308,9 @@ fun StyledSwitchPreview() {
enabled = true,
)
// LaunchedEffect(Unit) {
// delay(1.seconds)
// delay(1000)
// checked.value = false
// delay(1.seconds)
// delay(1000)
// checked.value = true
// }
}
@@ -18,12 +18,14 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components.primitives
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -34,7 +36,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
@@ -53,10 +54,14 @@ import androidx.compose.ui.graphics.Color
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.tooling.preview.Preview
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.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -71,33 +76,29 @@ fun StyledToggle(
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
header: Boolean = false,
colors: ListItemColors = if (header) ListItemDefaults.segmentedColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) else ListItemDefaults.segmentedColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
header: Boolean = false
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column(modifier = Modifier.padding(vertical = 12.dp)) {
title?.let {
Text(
text = it,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized,
Box(
modifier = Modifier
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = if (m3eEnabled) 12.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()
.background(
if (m3eEnabled) if (header) MaterialTheme.colorScheme.primaryContainer else Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh,
if (m3eEnabled) if (header) MaterialTheme.colorScheme.primaryContainer else Color.Transparent else MaterialTheme.colorScheme.surface,
RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp)
)
.clip(RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp))
@@ -111,8 +112,7 @@ fun StyledToggle(
onCheckedChange = onCheckedChange,
index = 0,
count = 1,
header = header,
colors = colors
header = header
)
} else {
StyledToggleContent(
@@ -121,18 +121,18 @@ fun StyledToggle(
enabled = enabled,
onCheckedChange = onCheckedChange,
index = 0,
count = 1,
colors = colors
count = 1
)
}
}
if (description != null && !m3eEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
modifier = Modifier.padding(horizontal = 16.dp)
text = description, style = TextStyle(
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
), modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
@@ -145,11 +145,6 @@ fun StyledListScope.StyledToggle(
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
colors: ListItemColors = ListItemDefaults.segmentedColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
) {
item { index, count ->
StyledToggleContent(
@@ -159,8 +154,7 @@ fun StyledListScope.StyledToggle(
enabled = enabled,
onCheckedChange = onCheckedChange,
index = index,
count = count,
colors = colors
count = count
)
}
}
@@ -175,18 +169,13 @@ private fun StyledToggleContent(
onCheckedChange: (Boolean) -> Unit,
index: Int,
count: Int,
header: Boolean = false,
colors: ListItemColors = if (header) ListItemDefaults.segmentedColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) else ListItemDefaults.segmentedColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
header: Boolean = false
) {
val currentChecked by rememberUpdatedState(checked)
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
@@ -257,7 +246,7 @@ private fun StyledToggleContent(
enabled = enabled,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = 64.dp),
colors = colors
colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors()
)
if (index+1 != count) {
Spacer(modifier = Modifier.height(2.dp))
@@ -297,12 +286,12 @@ private fun StyledToggleContent(
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp)
.padding(end = 4.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground,
color = textColor,
)
if (description != null) {
@@ -310,7 +299,7 @@ private fun StyledToggleContent(
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(0.8f)
color = textColor.copy(0.8f)
)
}
}
@@ -322,8 +311,7 @@ private fun StyledToggleContent(
if (enabled) {
onCheckedChange(it)
}
},
backgroundColor = colors.containerColor
}
)
}
if (index+1 != count) {
@@ -342,7 +330,7 @@ private fun StyledToggleContent(
@Composable
fun StyledToggleAppleListPreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(designSystem = DesignSystem.Apple) {
LibrePodsTheme(m3eEnabled = false) {
StyledList {
StyledToggle(
label = "Apple Styled List",
@@ -358,7 +346,7 @@ fun StyledToggleAppleListPreview() {
@Composable
fun StyledToggleApplePreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(designSystem = DesignSystem.Apple) {
LibrePodsTheme(m3eEnabled = false) {
StyledToggle(
label = "Apple",
description = "This is an example description for the styled toggle.",
@@ -372,7 +360,7 @@ fun StyledToggleApplePreview() {
@Composable
fun StyledToggleM3EListPreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(designSystem = DesignSystem.Material) {
LibrePodsTheme(m3eEnabled = true) {
StyledList {
StyledToggle(
label = "Apple Styled List",
@@ -388,7 +376,7 @@ fun StyledToggleM3EListPreview() {
@Composable
fun StyledToggleM3EPreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(designSystem = DesignSystem.Material) {
LibrePodsTheme(m3eEnabled = true) {
StyledToggle(
label = "Material",
description = "This is an example description for the styled toggle.",
@@ -0,0 +1,190 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sign
@Composable
fun VerticalVolumeSlider(
displayFraction: Float,
maxVolume: Int,
onVolumeChange: (Int) -> Unit,
initialFraction: Float,
onDragStateChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
baseSliderHeight: Dp = 400.dp,
baseSliderWidth: Dp = 145.dp,
baseCornerRadius: Dp = 45.dp,
maxStretchFactor: Float = 1.15f,
minCompressionFactor: Float = 0.875f,
stretchSensitivity: Float = 1.0f,
compressionSensitivity: Float = 1.0f,
cornerRadiusChangeFactor: Float = 0.2f,
directionalStretchRatio: Float = 0.75f
) {
val trackColor = Color(0x593C3C3E)
val progressColor = Color.White
var dragFraction by remember { mutableFloatStateOf(initialFraction) }
var isDragging by remember { mutableStateOf(false) }
var rawDragPosition by remember { mutableFloatStateOf(initialFraction) }
var overscrollAmount by remember { mutableFloatStateOf(0f) }
val baseHeightPx = with(LocalDensity.current) { baseSliderHeight.toPx() }
val animatedProgress by animateFloatAsState(
targetValue = dragFraction.coerceIn(0f, 1f),
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium
),
label = "ProgressAnimation"
)
val animatedOverscroll by animateFloatAsState(
targetValue = overscrollAmount,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "OverscrollAnimation"
)
val maxOverscrollEffect = (maxStretchFactor - 1f).coerceAtLeast(0f)
val stretchMultiplier = stretchSensitivity
val compressionMultiplier = compressionSensitivity
val overscrollDirection = sign(animatedOverscroll)
val totalStretchAmount = (min(maxOverscrollEffect, abs(animatedOverscroll) * stretchMultiplier) * baseSliderHeight.value).dp
val offsetY = if (abs(animatedOverscroll) > 0.001f) {
val asymmetricOffset = totalStretchAmount * (directionalStretchRatio - 0.5f)
(-overscrollDirection * asymmetricOffset.value).dp
} else {
0.dp
}
val heightStretch = baseSliderHeight + totalStretchAmount
val widthCompression = baseSliderWidth * max(
minCompressionFactor,
1f - min(1f - minCompressionFactor, abs(animatedOverscroll) * compressionMultiplier)
)
val dynamicCornerRadius = baseCornerRadius * (1f - min(cornerRadiusChangeFactor, abs(animatedOverscroll) * cornerRadiusChangeFactor * 2f))
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.height(heightStretch)
.width(widthCompression)
.offset(y = offsetY)
.clip(RoundedCornerShape(dynamicCornerRadius))
.background(trackColor)
.pointerInput(Unit) {
detectTapGestures { offset ->
val newFraction = 1f - (offset.y / size.height).coerceIn(0f, 1f)
dragFraction = newFraction
rawDragPosition = newFraction
overscrollAmount = 0f
val newVolume = (newFraction * maxVolume).roundToInt()
onVolumeChange(newVolume)
}
}
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
rawDragPosition -= (delta / baseHeightPx)
dragFraction = rawDragPosition.coerceIn(0f, 1f)
overscrollAmount = when {
rawDragPosition > 1f -> min(1.0f, (rawDragPosition - 1f) * 2.0f)
rawDragPosition < 0f -> max(-1.0f, rawDragPosition * 2.0f)
else -> 0f
}
val newVolume = (dragFraction * maxVolume).roundToInt()
onVolumeChange(newVolume)
},
onDragStarted = {
isDragging = true
dragFraction = displayFraction
rawDragPosition = displayFraction
overscrollAmount = 0f
onDragStateChange(true)
},
onDragStopped = {
isDragging = false
overscrollAmount = 0f
rawDragPosition = dragFraction
onDragStateChange(false)
}
),
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(animatedProgress)
.background(progressColor)
)
}
}
}
@@ -0,0 +1,324 @@
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 }
}
},
)
}
}
@@ -0,0 +1,158 @@
package me.kavishdevar.librepods.presentation.navigation
import android.util.Log
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.kyant.backdrop.backdrops.LayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.MaterialIcons
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun NavigationRoot(
showReleaseNotes: Boolean = false,
updatesShown: () -> Unit = {},
showOnboarding: Boolean = false,
onboardingComplete: () -> Unit = {},
airPodsViewModel: AirPodsViewModel
) {
val backStack = remember {
mutableStateListOf(
when {
showOnboarding -> Screen.Onboarding
showReleaseNotes -> Screen.ReleaseNotes
else -> Screen.AirPodsSettings
}
)
}
val currentScreen = backStack.last()
val state by airPodsViewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val title = when (currentScreen) {
Screen.Onboarding -> ""
Screen.AirPodsSettings -> if (state.isLocallyConnected) state.deviceName else stringResource(R.string.app_name)
Screen.Accessibility -> stringResource(R.string.accessibility)
Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio)
Screen.AppSettings -> stringResource(R.string.settings)
// Screen.CameraControl -> stringResource(R.string.camera_control)
Screen.Equalizer -> stringResource(R.string.equalizer)
Screen.HeadTracking -> stringResource(R.string.head_tracking)
Screen.HearingAid -> stringResource(R.string.hearing_aid)
Screen.HearingAidAdjustments -> stringResource(R.string.adjustments)
Screen.HearingProtection -> stringResource(R.string.hearing_protection)
is Screen.LongPress -> currentScreen.bud
Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses)
Screen.Purchase -> stringResource(R.string.unlock_advanced_features)
Screen.Rename -> stringResource(R.string.name)
Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode)
Screen.Troubleshooting -> stringResource(R.string.troubleshooting)
Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test)
Screen.VersionInfo -> stringResource(R.string.version)
is Screen.CallControl -> currentScreen.action
Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode)
Screen.ReleaseNotes -> ""
}
// is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold
val actionButtons = when (currentScreen) {
Screen.AirPodsSettings -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
if (m3eEnabled) {
FilledTonalIconButton(
onClick = { backStack.add(Screen.AppSettings) },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "settings",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
)
}
} else {
StyledIconButton(
onClick = { backStack.add(Screen.AppSettings) },
icon = "􀍟",
backdrop = scaffoldBackdrop
)
}
}
)
Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
if (m3eEnabled) {
FilledTonalIconToggleButton(
checked = state.headTrackingActive,
onCheckedChange = { if (it) airPodsViewModel.startHeadTracking() else airPodsViewModel.stopHeadTracking() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
imageVector = if (state.headTrackingActive) MaterialIcons.pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
)
}
} else {
StyledIconButton(
onClick = {
if (!state.headTrackingActive) {
airPodsViewModel.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started")
} else {
airPodsViewModel.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped")
}
},
icon = if (state.headTrackingActive) "􀊅" else "􀊃",
backdrop = scaffoldBackdrop
)
}
}
)
else -> listOf()
}
StyledScaffold(
visible = currentScreen.showTopBar,
title = title,
showBackButton = backStack.size > 1,
onNavigateBack = { backStack.removeAt(backStack.lastIndex) },
actionButtons = actionButtons
) {
AppNavGraph(
showReleaseNotes = showReleaseNotes,
updatesShown = updatesShown,
showOnboarding = showOnboarding,
onboardingComplete = onboardingComplete,
backStack = backStack,
airPodsViewModel = airPodsViewModel,
)
}
}
@@ -0,0 +1,84 @@
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
}
}
@@ -26,11 +26,15 @@ import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Resources
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log.e
@@ -42,6 +46,8 @@ import android.view.View
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator
import android.view.animation.AnticipateOvershootInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
@@ -53,9 +59,11 @@ import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.devices.Battery
import me.kavishdevar.librepods.devices.BatteryComponent
import me.kavishdevar.librepods.devices.BatteryStatus
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.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
@@ -98,12 +106,32 @@ class IslandWindow(private val context: Context) {
private lateinit var springAnimation: SpringAnimation
private val flingAnimator = ValueAnimator()
private val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra("data", Battery::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra("data")
}
updateBatteryDisplay(batteryList)
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
val isVisible: Boolean
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
@SuppressLint("SetTextI18n")
fun updateBattery(batteryList: Set<Battery>) {
if (batteryList.isEmpty()) return
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
if (batteryList == null || batteryList.isEmpty()) return
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
@@ -137,26 +165,39 @@ class IslandWindow(private val context: Context) {
@SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag",
"SetTextI18n"
)
fun show(
name: String,
batteryPercentage: Int,
context: Context,
type: IslandType = IslandType.CONNECTED,
reversed: Boolean = false,
otherDeviceName: String? = null,
onReverseAction: () -> Unit = {}
) {
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) {
if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true
val displayMetrics = Resources.getSystem().displayMetrics
val width = (displayMetrics.widthPixels * 0.95).toInt()
screenHeight = displayMetrics.heightPixels
val batteryList = ServiceManager.getService()?.getBattery()
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
if (batteryPercentage != 0) {
batteryText.text = "$batteryPercentage%"
batteryProgressBar.progress = batteryPercentage
val displayBatteryLevel = if (batteryList != null) {
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
when {
(leftBattery?.level ?: 0) > 0 && (rightBattery?.level ?: 0) > 0 ->
minOf(leftBattery!!.level, rightBattery!!.level)
(leftBattery?.level ?: 0) > 0 -> leftBattery!!.level
(rightBattery?.level ?: 0) > 0 -> rightBattery!!.level
batteryPercentage > 0 -> batteryPercentage
else -> null
}
} else if (batteryPercentage > 0) {
batteryPercentage
} else {
null
}
if (displayBatteryLevel != null) {
batteryText.text = "$displayBatteryLevel%"
batteryProgressBar.progress = displayBatteryLevel
} else {
batteryText.text = "?"
batteryProgressBar.progress = 0
@@ -170,7 +211,9 @@ class IslandWindow(private val context: Context) {
if (type == IslandType.MOVED_TO_OTHER_DEVICE && !reversed) {
actionButton.visibility = View.VISIBLE
actionButton.setOnClickListener {
onReverseAction()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceManager.getService()?.takeOver("reverse")
}
close()
}
batteryText.visibility = View.GONE
@@ -183,6 +226,16 @@ class IslandWindow(private val context: Context) {
batteryBg.visibility = View.VISIBLE
}
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(batteryReceiver, batteryIntentFilter)
}
ServiceManager.getService()?.sendBatteryBroadcast()
containerView.removeAllViews()
val containerParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
@@ -271,30 +324,20 @@ class IslandWindow(private val context: Context) {
if (isBeingDragged) {
val currentTranslationY = containerView.translationY
abs(yVelocity) > 800
val significantDrag = abs(dragDistance) > 80
if (isDraggingDown && (currentTranslationY > 200 || yVelocity > 1000)) {
flingAnimator.cancel()
flingAnimator.setFloatValues(currentTranslationY, screenHeight.toFloat())
flingAnimator.duration = 300
flingAnimator.interpolator = AccelerateInterpolator()
flingAnimator.addUpdateListener { animation ->
val value = animation.animatedValue as Float
containerView.translationY = value
when {
yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> {
animateDismissWithInertia(yVelocity)
}
yVelocity > 1200 || (isDraggingDown && significantDrag) -> {
animateExpandWithStretch(yVelocity)
}
else -> {
springBackWithInertia(yVelocity)
}
flingAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
close()
}
})
flingAnimator.start()
} else {
springAnimation.cancel()
springAnimation.setStartValue(currentTranslationY)
springAnimation.start()
applyCustomStretchEffect(0f)
}
} else if (dragDistance < 10) {
resetAutoCloseTimer()
}
@@ -359,23 +402,10 @@ class IslandWindow(private val context: Context) {
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
Looper.getMainLooper().let { mainLooper ->
if (Looper.myLooper() == mainLooper) {
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
start()
}
} else {
Handler(mainLooper).post {
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
start()
}
}
}
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
start()
}
resetAutoCloseTimer()
@@ -446,6 +476,178 @@ class IslandWindow(private val context: Context) {
autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500)
}
private fun springBackWithInertia(velocity: Float) {
springAnimation.cancel()
flingAnimator.cancel()
springAnimation.setStartVelocity(velocity)
val baseStiffness = SpringForce.STIFFNESS_MEDIUM
val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f))
springAnimation.spring = SpringForce(0f)
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
.setStiffness(dynamicStiffness)
resetStretchEffects()
if (params != null) {
params!!.height = WindowManager.LayoutParams.WRAP_CONTENT
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
}
springAnimation.start()
}
private fun resetStretchEffects() {
try {
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight)
heightAnimator.duration = 300
heightAnimator.interpolator = OvershootInterpolator(1.5f)
heightAnimator.addUpdateListener { animation ->
mainLayout.minimumHeight = animation.animatedValue as Int
}
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0)
textMarginAnimator.duration = 300
textMarginAnimator.interpolator = OvershootInterpolator(1.5f)
textMarginAnimator.addUpdateListener { animation ->
deviceTextParams.topMargin = animation.animatedValue as Int
deviceText.layoutParams = deviceTextParams
}
heightAnimator.start()
textMarginAnimator.start()
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun animateDismissWithInertia(velocity: Float) {
springAnimation.cancel()
flingAnimator.cancel()
val baseDistance = -screenHeight
val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f)
val targetDistance = baseDistance * velocityFactor
val baseDuration = 400L
val velocityDurationFactor = (1500f / (abs(velocity) + 1500f))
val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L)
flingAnimator.setFloatValues(containerView.translationY, targetDistance)
flingAnimator.duration = duration
flingAnimator.addUpdateListener { animation ->
containerView.translationY = animation.animatedValue as Float
val progress = animation.animatedFraction
containerView.scaleX = 1f - (progress * 0.5f)
containerView.scaleY = 1f - (progress * 0.5f)
containerView.alpha = 1f - progress
}
flingAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
forceClose()
}
})
flingAnimator.interpolator = DecelerateInterpolator(1.2f)
flingAnimator.start()
}
private fun animateExpandWithStretch(velocity: Float) {
springAnimation.cancel()
flingAnimator.cancel()
val baseDuration = 600L
val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f)
val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L)
if (params != null) {
params!!.height = screenHeight
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
}
val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f)
containerAnimator.duration = expandDuration
containerAnimator.interpolator = DecelerateInterpolator(0.8f)
containerAnimator.addUpdateListener { animation ->
containerView.translationY = animation.animatedValue as Float
}
val stretchAnimator = ValueAnimator.ofFloat(0f, 1f)
stretchAnimator.duration = expandDuration
stretchAnimator.interpolator = OvershootInterpolator(0.5f)
stretchAnimator.addUpdateListener { animation ->
val progress = animation.animatedValue as Float
animateCustomStretch(progress)
}
val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f)
normalizeAnimator.duration = 300
normalizeAnimator.startDelay = expandDuration - 150
normalizeAnimator.interpolator = AccelerateInterpolator(1.2f)
normalizeAnimator.addUpdateListener { animation ->
val progress = animation.animatedValue as Float
containerView.alpha = progress
if (progress < 0.7f) {
islandView.findViewById<VideoView>(R.id.island_video_view).visibility = View.GONE
}
}
normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
ServiceManager.getService()?.startMainActivity()
forceClose()
}
})
containerAnimator.start()
stretchAnimator.start()
normalizeAnimator.start()
}
private fun animateCustomStretch(progress: Float) {
try {
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val targetHeight = (screenHeight * 0.7f).toInt()
val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress)
mainLayout.minimumHeight = currentHeight.toInt()
val mainLayoutParams = mainLayout.layoutParams
mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT
mainLayout.layoutParams = mainLayoutParams
val targetMargin = (400 * progress).toInt()
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
deviceTextParams.topMargin = targetMargin
deviceText.layoutParams = deviceTextParams
val baseTextSize = 24f
deviceText.textSize = baseTextSize + (progress * 8f)
val baseSubTextSize = 16f
connectedText.textSize = baseSubTextSize + (progress * 4f)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun close() {
if (Looper.myLooper() != Looper.getMainLooper()) {
Handler(Looper.getMainLooper()).post { close() }
@@ -455,8 +657,17 @@ class IslandWindow(private val context: Context) {
if (isClosing) return
isClosing = true
try {
context.unregisterReceiver(batteryReceiver)
} catch (e: Exception) {
// e.printStackTrace()
}
ServiceManager.getService()?.islandOpen = false
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
resetStretchEffects()
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
try {
videoView.stopPlayback()
@@ -502,7 +713,7 @@ class IslandWindow(private val context: Context) {
e("IslandWindow", "Error removing view: $e")
}
isClosing = false
// Make sure all animations are canceled
try {
springAnimation.cancel()
} catch (e: Exception) {
@@ -524,11 +735,20 @@ class IslandWindow(private val context: Context) {
if (isClosing) return
isClosing = true
try {
context.unregisterReceiver(batteryReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
ServiceManager.getService()?.islandOpen = false
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
// Cancel all ongoing animations
springAnimation.cancel()
flingAnimator.cancel()
// Immediately remove the view without animations
cleanupAndRemoveView()
} catch (e: Exception) {
e.printStackTrace()
@@ -24,9 +24,13 @@ import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.PixelFormat
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
@@ -42,9 +46,10 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.VideoView
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.devices.Battery
import me.kavishdevar.librepods.devices.BatteryComponent
import me.kavishdevar.librepods.devices.BatteryStatus
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
@SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow(
@@ -55,6 +60,7 @@ class PopupWindow(
private var isClosing = false
private var autoCloseHandler = Handler(Looper.getMainLooper())
private var autoCloseRunnable: Runnable? = null
private var batteryUpdateReceiver: BroadcastReceiver? = null
@Suppress("DEPRECATION")
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
@@ -124,12 +130,12 @@ class PopupWindow(
}
@SuppressLint("InlinedApi", "SetTextI18s")
fun open(name: String = "AirPods Pro", batteries: Set<Battery>) {
fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) {
try {
if (mView.windowToken == null && mView.parent == null && !isClosing) {
mView.findViewById<TextView>(R.id.name).text = name
updateBatteryStatus(batteries)
updateBatteryStatus(batteryNotification)
val vid = mView.findViewById<VideoView>(R.id.video)
vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
@@ -160,6 +166,8 @@ class PopupWindow(
start()
}
registerBatteryUpdateReceiver()
autoCloseRunnable = Runnable { close() }
autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000)
}
@@ -169,6 +177,43 @@ class PopupWindow(
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
private fun registerBatteryUpdateReceiver() {
batteryUpdateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra("data", Battery::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra("data")
}
if (batteryList != null) {
updateBatteryStatusFromList(batteryList)
}
}
}
}
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(batteryUpdateReceiver, filter)
}
}
private fun unregisterBatteryUpdateReceiver() {
batteryUpdateReceiver?.let {
try {
context.unregisterReceiver(it)
batteryUpdateReceiver = null
} catch (e: Exception) {
Log.e("PopupWindow", "Error unregistering battery receiver: ${e.message}")
}
}
}
private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
@@ -176,7 +221,7 @@ class PopupWindow(
batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"L ${it.level}%"
"\uDBC3\uDC8E ${it.level}%"
} else {
""
}
@@ -184,7 +229,7 @@ class PopupWindow(
batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"R ${it.level}%"
"\uDBC3\uDC8D ${it.level}%"
} else {
""
}
@@ -192,7 +237,7 @@ class PopupWindow(
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"C ${it.level}%"
"\uDBC3\uDE6C ${it.level}%"
} else {
""
}
@@ -200,8 +245,9 @@ class PopupWindow(
}
@SuppressLint("SetTextI18s")
fun updateBatteryStatus(batteries: Set<Battery>) {
updateBatteryStatusFromList(batteries.toList())
fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) {
val batteryStatus = batteryNotification.getBattery()
updateBatteryStatusFromList(batteryStatus)
}
fun close() {
@@ -210,6 +256,7 @@ class PopupWindow(
isClosing = true
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
unregisterBatteryUpdateReceiver()
val vid = mView.findViewById<VideoView>(R.id.video)
vid.stopPlayback()
@@ -16,17 +16,26 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens.apple
package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -42,298 +51,301 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.devices.AirPodsSpecs
import me.kavishdevar.librepods.devices.BaseCapability
import me.kavishdevar.librepods.presentation.components.primitives.StyledButton
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
import me.kavishdevar.librepods.presentation.components.primitives.StyledScaffold
import me.kavishdevar.librepods.presentation.components.primitives.StyledSlider
import me.kavishdevar.librepods.presentation.components.primitives.StyledToggle
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.Capability
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.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
//private var phoneMediaDebounceJob: Job? = null
private var phoneMediaDebounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class)
@Composable
fun AccessibilitySettingsScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?,
navigateToPurchase: () -> Unit,
navigateToTransparencyCustomization: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val state = uiState.state
val metadata = uiState.metadata
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) {
val state by viewModel.uiState.collectAsState()
val hearingAidEnabled =
state.controlStates[ControlCommandIdentifier.HEARING_AID]?.getOrNull(
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
1
)
?.toInt() == 1 && state.controlStates[ControlCommandIdentifier.HEARING_AID]?.getOrNull(
?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
0
)?.toInt() == 1
StyledScaffold(
title = stringResource(R.string.accessibility),
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
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
if (!uiState.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// val phoneEQEnabled = remember { mutableStateOf(false) }
// val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
)
val selectedPressSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
0
)
val selectedPressSpeedValue =
state.controlStates[ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL]?.getOrNull(
0
)
var selectedPressSpeed by remember {
mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
)
}
val pressAndHoldDurationOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
var selectedPressSpeed by remember {
mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
)
}
val selectedPressAndHoldDurationValue =
state.controlStates[ControlCommandIdentifier.CLICK_HOLD_INTERVAL]?.getOrNull(
0
)
var selectedPressAndHoldDuration by remember {
mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
?: pressAndHoldDurationOptions[0]
)
}
val pressAndHoldDurationOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
)
val volumeSwipeSpeedOptions = mapOf(
1.toByte() to stringResource(R.string.default_option),
2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest)
val selectedPressAndHoldDurationValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
0
)
val selectedVolumeSwipeSpeedValue =
state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL]?.getOrNull(
0
)
var selectedVolumeSwipeSpeed by remember {
mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
?: volumeSwipeSpeedOptions[1]
)
}
var selectedPressAndHoldDuration by remember {
mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
?: pressAndHoldDurationOptions[0]
)
}
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// val phoneEQEnabled = remember { mutableStateOf(false) }
// val mediaEQEnabled = remember { mutableStateOf(false) }
//
// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
// phoneMediaDebounceJob?.cancel()
// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
// delay(150.milliseconds)
// try {
// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
// val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
// Log.d(
// "AccessibilitySettingsScreen",
// "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
// )
// viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
// } catch (e: Exception) {
// Log.w(
// "AccessibilitySettingsScreen",
// "Error sending phone/media EQ: ${e.message}"
// )
// }
// }
// }
val volumeSwipeSpeedOptions = mapOf(
1.toByte() to stringResource(R.string.default_option),
2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest)
)
val selectedVolumeSwipeSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
0
)
var selectedVolumeSwipeSpeed by remember {
mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
?: volumeSwipeSpeedOptions[1]
)
}
StyledList(
title = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description)
) {
pressSpeedOptions.forEach { (value, label) ->
StyledListItem(
contentText = label,
selected = selectedPressSpeed == label,
onClick = {
selectedPressSpeed = label
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) }
viewModel.setControlCommand(
identifier = ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL,
value = value
)
}
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150.milliseconds)
try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
Log.d(
"AccessibilitySettingsScreen",
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
)
viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(
"AccessibilitySettingsScreen",
"Error sending phone/media EQ: ${e.message}"
)
}
}
}
StyledList(
title = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description)
) {
pressAndHoldDurationOptions.forEach { (value, label) ->
StyledListItem(
contentText = label,
selected = selectedPressAndHoldDuration == label,
onClick = {
selectedPressAndHoldDuration = label
viewModel.setControlCommand(
identifier = ControlCommandIdentifier.CLICK_HOLD_INTERVAL,
value = value
)
}
)
}
}
StyledToggle(
title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description),
checked = state.controlStates[ControlCommandIdentifier.ONE_BUD_ANC_MODE]?.getOrNull(
0
) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommand(
ControlCommandIdentifier.ONE_BUD_ANC_MODE, it
)
},
enabled = uiState.isPremium
)
if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION) && uiState.vendorIdHook) {
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.writeATTCharacteristic(
ATTHandle.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
)
},
enabled = uiState.isPremium
)
}
if (!hearingAidEnabled && uiState.vendorIdHook) {
StyledList(
title = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description)
) {
pressSpeedOptions.forEach { (value, label) ->
StyledListItem(
contentText = stringResource(R.string.customize_transparency_mode),
onClick = navigateToTransparencyCustomization,
enabled = uiState.isPremium
)
}
name = label,
selected = selectedPressSpeed == label,
onClick = {
selectedPressSpeed = label
val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[ControlCommandIdentifier.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
LaunchedEffect(toneVolumeValue) {
snapshotFlow {
toneVolumeValue.floatValue
}
.debounce(100.milliseconds)
.collect {
viewModel.setControlCommand(
ControlCommandIdentifier.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50)
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = value
)
}
)
}
}
StyledSlider(
label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue.floatValue,
onValueChange = {
toneVolumeValue.floatValue = it
StyledList(
title = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description)
) {
pressAndHoldDurationOptions.forEach { (value, label) ->
StyledListItem(
name = label,
selected = selectedPressAndHoldDuration == label,
onClick = {
selectedPressAndHoldDuration = label
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = value
)
}
)
}
}
StyledToggle(
title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
0
) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
)
},
enabled = state.isPremium
)
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
)
},
valueRange = 0f..100f,
snapPoints = listOf(75f),
startImageVector = LocalIcons.current.SpeakerMin,
endImageVector = LocalIcons.current.SpeakerMax,
independent = true,
enabled = uiState.isPremium
enabled = state.isPremium
)
}
if (!hearingAidEnabled && state.vendorIdHook) {
StyledListItem(
name = stringResource(R.string.customize_transparency_mode),
onClick = navigateToTransparencyCustomization,
enabled = state.isPremium
)
}
val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
LaunchedEffect(toneVolumeValue) {
snapshotFlow {
toneVolumeValue.floatValue
}
.debounce(100.milliseconds)
.collect {
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50)
)
}
}
StyledSlider(
label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue.floatValue,
onValueChange = {
toneVolumeValue.floatValue = it
},
valueRange = 0f..100f,
snapPoints = listOf(75f),
startIcon = "\uDBC0\uDEA1",
endIcon = "\uDBC0\uDEA9",
independent = true,
enabled = state.isPremium
)
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
0
)?.toInt() == 0x01
StyledToggle(
label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
)
},
enabled = state.isPremium
)
if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled =
state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_MODE]?.getOrNull(
0
)?.toInt() == 0x01
StyledToggle(
label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled,
onCheckedChange = {
viewModel.setControlCommand(
ControlCommandIdentifier.VOLUME_SWIPE_MODE, it
)
},
enabled = uiState.isPremium
)
StyledList(
title = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description)
) {
volumeSwipeSpeedOptions.forEach { (value, label) ->
StyledListItem(
name = label,
selected = selectedVolumeSwipeSpeed == label,
onClick = {
selectedVolumeSwipeSpeed = label
StyledList(
title = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description)
) {
volumeSwipeSpeedOptions.forEach { (value, label) ->
StyledListItem(
contentText = label,
selected = selectedVolumeSwipeSpeed == label,
onClick = {
selectedVolumeSwipeSpeed = label
viewModel.setControlCommand(
identifier = ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL,
value = value
)
}
)
}
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = value
)
}
)
}
}
}
// if (!hearingAidEnabled && XposedState.isAvailable) {
// Text(
@@ -568,7 +580,6 @@ fun AccessibilitySettingsScreen(
// }
// }
// }
Spacer(modifier = Modifier.height(bottomPadding))
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -0,0 +1,122 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
val sliderValue = remember {
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
}
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
StyledSlider(
label = stringResource(R.string.customize_adaptive_audio),
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
job?.cancel()
job = scope.launch {
delay(150)
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
byteArrayOf((100 - it).toInt().toByte())
)
}
},
valueRange = 0f..100f,
snapPoints = listOf(0f, 50f, 100f),
startIcon = "􀊥",
endIcon = "􀊩",
independent = true,
description = stringResource(R.string.adaptive_audio_description),
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -0,0 +1,613 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.MaterialTypography
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSettingsScreen(
viewModel: AppSettingsViewModel = viewModel(),
navigateToPurchase: () -> Unit,
navigateToTroubleshooting: () -> Unit,
navigateToOpenSourceLicenses: () -> Unit,
navigateToReleaseNotesScreen: () -> Unit
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val contactBottomSheet = remember { mutableStateOf(false) }
val subjectState = remember { TextFieldState() }
val descriptionState = remember { TextFieldState() }
val subjectFocusRequester = remember { FocusRequester() }
val descriptionFocusRequester = remember { FocusRequester() }
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.layerBackdrop(backdrop)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme()
if (!state.isPremium && state.connectionSuccessful) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (state.timeUntilFOSSPremiumExpiry > 0L) {
Box(
modifier = Modifier
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
.clickable {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
putExtra(
Intent.EXTRA_TEXT,
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
)
}
context.startActivity(emailIntent)
}
) {
Text(
text = stringResource(
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
),
modifier = Modifier
.padding(16.dp),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
StyledToggle(
title = stringResource(R.string.appearance),
label = stringResource(R.string.use_material3e),
checked = state.m3eEnabled,
onCheckedChange = viewModel::setm3eEnabled,
enabled = state.isPremium
)
if (state.connectionSuccessful) {
StyledToggle(
title = stringResource(R.string.widget),
label = stringResource(R.string.show_phone_battery_in_widget),
description = stringResource(R.string.show_phone_battery_in_widget_description),
checked = state.showPhoneBatteryInWidget,
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
enabled = state.isPremium
)
StyledList(title = stringResource(R.string.popup_animations)) {
StyledToggle(
label = stringResource(R.string.show_bottom_sheet_popup),
description = stringResource(R.string.show_bottom_sheet_popup_description),
checked = state.showBottomSheetPopup,
onCheckedChange = viewModel::setShowBottomSheetPopup,
)
StyledToggle(
label = stringResource(R.string.show_island_popup),
description = stringResource(R.string.show_island_popup_description),
checked = state.showIslandPopup,
onCheckedChange = viewModel::setShowIslandPopup,
)
}
Spacer(modifier = Modifier.height(16.dp))
StyledList (title = stringResource(R.string.conversational_awareness)) {
StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description),
checked = state.conversationalAwarenessPauseMusicEnabled,
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description),
checked = state.relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
enabled = state.isPremium,
)
}
Spacer(modifier = Modifier.height(16.dp))
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
LaunchedEffect(conversationalAwarenessVolume) {
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
}
StyledSlider(
label = stringResource(R.string.conversational_awareness_volume),
value = conversationalAwarenessVolume,
valueRange = 10f..85f,
snapPoints = listOf(44f),
startLabel = "10%",
endLabel = "85%",
onValueChange = { newValue ->
viewModel.setConversationalAwarenessVolume(
newValue
)
},
independent = true,
enabled = state.isPremium
)
// if (!BuildConfig.PLAY_BUILD) {
// Spacer(modifier = Modifier.height(16.dp))
//
// StyledListItem(
// to = "",
// titleRes = stringResource(R.string.camera_control),
// name = stringResource(R.string.set_custom_camera_package),
// navController = navController,
// onClick = {
// if (state.isPremium) viewModel.setShowCameraDialog(true)
// },
// independent = true,
// descriptionRes = stringResource(R.string.camera_control_app_description)
// )
// }
Spacer(modifier = Modifier.height(16.dp))
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
StyledToggle(
title = stringResource(R.string.ear_detection),
label = stringResource(R.string.disconnect_when_not_wearing),
description = stringResource(R.string.disconnect_when_not_wearing_description),
checked = state.disconnectWhenNotWearing,
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
enabled = state.isPremium
)
}
StyledList(title = stringResource(R.string.takeover_airpods_state)) {
StyledToggle(
label = stringResource(R.string.takeover_disconnected),
description = stringResource(R.string.takeover_disconnected_desc),
checked = state.takeoverWhenDisconnected,
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc),
checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc),
checked = state.takeoverWhenMusic,
onCheckedChange = viewModel::setTakeoverWhenMusic,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc),
checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall,
enabled = state.isPremium
)
}
Spacer(modifier = Modifier.height(16.dp))
StyledList(title = stringResource(R.string.takeover_phone_state)) {
StyledToggle(
label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc),
checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc),
checked = state.takeoverWhenMediaStart,
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
enabled = state.isPremium
)
}
StyledToggle(
title = stringResource(R.string.advanced_options), // shouldn't be here, but okay
label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(16.dp))
} else {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 2.dp)
) {
Text(
text = stringResource(R.string.customizations_unavailable),
style = MaterialTypography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
)
}
}
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
StyledToggle(
label = stringResource(R.string.act_as_an_apple_device) + " (${
stringResource(
R.string.requires_xposed
)
})",
description = stringResource(R.string.act_as_an_apple_device_description),
checked = state.vendorIdHook,
onCheckedChange = { enabled ->
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
viewModel.setVendorIdHook(enabled)
}
)
}
if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp))
StyledList {
StyledListItem(
name = stringResource(R.string.troubleshooting),
onClick = navigateToTroubleshooting,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
StyledList(title = stringResource(R.string.contact)) {
StyledListItem(
name = stringResource(R.string.email),
onClick = { contactBottomSheet.value = true },
)
StyledListItem(
name = stringResource(R.string.discord),
onClick = {
val intent =
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
context.startActivity(intent)
},
)
StyledListItem(
name = stringResource(R.string.github_issues),
onClick = {
val appVersion =
Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
val appSource = Uri.encode(
when {
BuildConfig.PLAY_BUILD -> "Play"
else -> "GitHub"
}
)
val url = "https://github.com/kavishdevar/librepods/issues/new" +
"?template=01-bug-report-android.yml" +
"&app-source=$appSource" +
"&app-version=$appVersion" +
"&device=$device" +
"&android-version=$androidVersion"
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(intent)
},
)
}
Spacer(modifier = Modifier.height(20.dp))
DeviceInfoCard()
Spacer(modifier = Modifier.height(16.dp))
AppInfoCard(navigateToReleaseNotesScreen)
Spacer(modifier = Modifier.height(16.dp))
StyledListItem(
name = stringResource(R.string.open_source_licenses),
onClick = navigateToOpenSourceLicenses,
)
Spacer(modifier = Modifier.height(bottomPadding))
if (state.showCameraDialog) {
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
Text(
stringResource(R.string.set_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}, text = {
Column {
Text(
stringResource(R.string.enter_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = state.cameraPackageValue,
onValueChange = {
viewModel.setCameraPackageValue(it)
viewModel.setCameraPackageError(null)
},
modifier = Modifier.fillMaxWidth(),
isError = state.cameraPackageError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
capitalization = KeyboardCapitalization.None
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
0xFF3C6DF5
),
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
),
supportingText = {
if (state.cameraPackageError != null) {
Text(
state.cameraPackageError ?: "",
color = MaterialTheme.colorScheme.error
)
}
},
label = { Text(stringResource(R.string.custom_camera_package)) })
}
}, confirmButton = {
val successText = stringResource(R.string.custom_camera_package_set_success)
TextButton(
onClick = {
viewModel.saveCameraPackage()
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
}) {
Text(
"Save",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
}, dismissButton = {
TextButton(
onClick = { viewModel.setShowCameraDialog(false) }) {
Text(
"Cancel",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
})
}
}
StyledBottomSheet(
visible = contactBottomSheet.value,
onDismiss = { contactBottomSheet.value = false },
backdrop = backdrop
) { innerBackdrop, progress ->
val animatedPadding = lerp(16.dp, 2.dp, progress)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = animatedPadding)
.padding(bottom = 16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
StyledIconButton(
icon = "\uDBC0\uDD84",
backdrop = innerBackdrop,
onClick = { contactBottomSheet.value = false }
)
Text (
text = stringResource(R.string.describe_your_issue),
style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
StyledIconButton(
icon = "\uDBC0\uDE1F",
backdrop = innerBackdrop,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray,
enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(),
onClick = {
contactBottomSheet.value = false
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}")
putExtra(
Intent.EXTRA_TEXT,
"${descriptionState.text}" +
"\n\n----------" +
"\nPhone details:" +
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nDISPLAY_VERSION: ${Build.DISPLAY}" +
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
"\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
"\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
)
}
context.startActivity(intent)
subjectState.clearText()
descriptionState.clearText()
}
)
}
Spacer(modifier = Modifier.height(8.dp))
StyledInputField(
inputState = subjectState,
focusRequester = subjectFocusRequester,
placeholder = stringResource(R.string.subject),
forceApple = true
)
Spacer(modifier = Modifier.height(12.dp))
StyledInputField(
inputState = descriptionState,
focusRequester = descriptionFocusRequester,
placeholder = stringResource(R.string.describe_your_issue),
singleLine = false,
forceApple = true
)
}
}
}
@@ -0,0 +1,97 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val scrollState = rememberScrollState()
val bytes =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(
2
)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
val flipped = try {
bytes[1] == 0x02.toByte()
} catch (e: Exception) {
false
}
val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice)
val muteUnmuteText = stringResource(R.string.mute_unmute)
var singlePressAction by remember { mutableStateOf(if ((action == muteUnmuteText) == !flipped) pressOnceText else pressTwiceText) }
val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } }
val flippedValue = action != muteUnmuteText
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = pressOnceText,
selected = pressOnceIsAction,
onClick = {
singlePressAction = pressOnceText
onCallControlValueChanged(flippedValue)
}
)
StyledListItem(
name = pressTwiceText,
selected = !pressOnceIsAction,
onClick = {
singlePressAction = pressTwiceText
onCallControlValueChanged(!flippedValue)
}
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -16,10 +16,10 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens.apple
package me.kavishdevar.librepods.presentation.screens
//@Composable
//fun CameraControlScreen(viewModel: AppleViewModel) {
//fun CameraControlScreen(viewModel: AirPodsViewModel) {
// val context = LocalContext.current
// val currentCameraAction by viewModel.cameraAction.collectAsState()
//
@@ -0,0 +1,762 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.visible
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.demoState
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun EqualizerRoute(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = state,
topPadding = topPadding,
bottomPadding = bottomPadding,
setCustomEqEnabled = viewModel::setCustomEqEnabled,
setCustomEq = viewModel::setCustomEq
)
}
}
@OptIn(FlowPreview::class)
@Composable
fun EqualizerScreen(
state: AirPodsUiState,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
setCustomEqEnabled: (Boolean) -> Unit,
setCustomEq: (Int, Int, Int) -> Unit
) {
val customEq = state.customEq
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val height = 200.dp
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
val offsets = remember(state.customEq) {
listOf(
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
)
}
LaunchedEffect(offsets) {
snapshotFlow {
Triple(
offsets[0].floatValue,
offsets[1].floatValue,
offsets[2].floatValue
)
}
.debounce(100.milliseconds) // cool, should've been using this since the very beginning
.collect { (lowF, midF, highF) ->
val low =
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
val mid =
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
val high =
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
setCustomEq(low, mid, high)
}
}
Spacer(modifier = Modifier.height(topPadding))
val enabled = customEq.isEnabled()
StyledList {
StyledListItem(
name = stringResource(R.string.recommended),
selected = !enabled,
onClick = { setCustomEqEnabled(false) }
)
StyledListItem(
name = stringResource(R.string.custom),
selected = enabled,
onClick = { setCustomEqEnabled(true) }
)
}
Spacer(modifier = Modifier.height(12.dp))
Crossfade (
customEq.isEnabled()
) { visible ->
Column(
modifier = Modifier
.fillMaxWidth()
.visible(visible),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
EqualizerCard(
lowOffset = offsets[0],
midOffset = offsets[1],
highOffset = offsets[2]
)
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
StyledButton(
onClick = {
offsets[0].floatValue = 0f
offsets[1].floatValue = 0f
offsets[2].floatValue = 0f
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
isInteractive = false,
enabled = resetButtonEnabled.value
) {
Text(
text = stringResource(R.string.reset),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@Composable
fun EqualizerCard(
lowOffset: MutableState<Float>,
midOffset: MutableState<Float>,
highOffset: MutableState<Float>
) {
val height = 200.dp
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
) {
val dashColor = if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D)
val backdrop = rememberLayerBackdrop()
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
) {
Spacer(modifier = Modifier.height(42.dp))
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .padding(18.dp),
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(12.dp)
// ) {
// Box(
// modifier = Modifier
// .size(64.dp)
// .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp))
// )
// Column(
// modifier = Modifier
// .weight(1f),
// verticalArrangement = Arrangement.Center
// ) {
// Text(
// text = "Written into Changes",
// style = TextStyle(
// fontSize = 16.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Bold,
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
// )
// )
// Spacer(modifier = Modifier.height(4.dp))
// Text(
// text = "Avalon Emerson",
// style = TextStyle(
// fontSize = 14.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Normal,
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
// )
// )
// }
// val paused = remember { mutableStateOf(false) }
// Box(
// modifier = Modifier
// .size(48.dp)
// .background(Color(0x600091FF), CircleShape)
// .clickable(
// interactionSource = remember { MutableInteractionSource() },
// indication = null,
// ) {
// paused.value = !paused.value
// },
// contentAlignment = Alignment.Center
// ) {
// Crossfade(
// targetState = paused.value,
// label = "media_icon"
// ) { p ->
// Text(
// text = if (p) "􀊄" else "􀊆",
// style = TextStyle(
// fontSize = 24.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Normal,
// color = Color(0xFF0091FF),
// textAlign = TextAlign.Center
// )
// )
// }
// }
// }
//
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888),
// modifier = Modifier
// .padding(horizontal = 20.dp)
// .padding(bottom = 16.dp)
// )
Box(
modifier = Modifier.fillMaxWidth()
) {
fun colorFromY(y: Float): Color {
val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f)
val stops = listOf(
0.0f to Color(0xFFFFA300),
0.25f to Color(0xFFFCE600),
0.5f to Color(0xFF00FAAF),
0.75f to Color(0xFF00FAFF),
1.0f to Color(0xFF00B5FF)
)
val (start, end) = stops.zipWithNext()
.first { f <= it.second.first }
val c = (f - start.first) / (end.first - start.first)
return lerp(start.second, end.second, c)
}
fun pathBrush(
startY: Float,
endY: Float,
): Brush {
val stops = (0..20).map { i ->
val t = i / 20f
val y = lerp(startY, endY, t)
t to colorFromY(y)
}
return Brush.linearGradient(
colorStops = stops.toTypedArray()
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.layerBackdrop(backdrop)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = 20.dp)
) {
Row(
modifier = Modifier
.fillMaxSize()
) {
val dashCount = (height / 10.dp).toInt()
repeat(3) {
Box(
modifier = Modifier
.fillMaxSize()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
for (i in 1..(dashCount)) {
val t = i.toFloat() / dashCount
val centerDistance = abs(0.5f - t)
val alpha = 1f - (centerDistance * 2f)
Box(
modifier = Modifier
.height(9.dp)
.width(0.75.dp)
.background(
dashColor.copy(alpha),
RoundedCornerShape(28.dp)
)
)
}
}
}
}
}
val backgroundColor = MaterialTheme.colorScheme.surface
Canvas(
modifier = Modifier
.fillMaxSize()
) {
val canvasWidth = size.width
drawLine(
color = backgroundColor,
start = Offset(
x = 0f,
y = lowOffset.value + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(lowOffset.value),
start = Offset(
x = 0f,
y = lowOffset.value + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
),
strokeWidth = 8f
)
val lowToMidPath = Path()
lowToMidPath.moveTo(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
)
lowToMidPath.cubicTo(
x1 = canvasWidth * 1 / 6f + 108.dp.value,
y1 = lowOffset.value + maxOffset,
x2 = canvasWidth * 0.5f - 108.dp.value,
y2 = midOffset.value + maxOffset,
x3 = canvasWidth * 0.5f,
y3 = midOffset.value + maxOffset
)
drawPath(
color = backgroundColor,
path = lowToMidPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
lowOffset.value,
midOffset.value
),
path = lowToMidPath,
style = Stroke(width = 8f)
)
val midToHighPath = Path()
midToHighPath.moveTo(
x = 0.5f * canvasWidth,
y = midOffset.value + maxOffset
)
midToHighPath.cubicTo(
x1 = canvasWidth * 0.5f + 108.dp.value,
y1 = midOffset.value + maxOffset,
x2 = canvasWidth * 5 / 6f - 108.dp.value,
y2 = highOffset.value + maxOffset,
x3 = canvasWidth * 5 / 6f,
y3 = highOffset.value + maxOffset
)
drawPath(
color = backgroundColor,
path = midToHighPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
midOffset.value,
highOffset.value
),
path = midToHighPath,
style = Stroke(width = 8f)
)
drawLine(
color = backgroundColor,
start = Offset(
x = 5 / 6f * canvasWidth,
y = highOffset.value + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = highOffset.value + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(highOffset.value),
start = Offset(
x = 5 / 6f * canvasWidth,
y = highOffset.value + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = highOffset.value + maxOffset
),
strokeWidth = 8f
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "Low".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "Mid".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "High".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically
) {
for (i in 0..2) {
Row(
modifier = Modifier
.weight(1f),
horizontalArrangement = Arrangement.Center
) {
val pressed = remember { mutableStateOf(false) }
Box(
modifier = Modifier
.offset {
IntOffset(
x = 0,
y = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.roundToInt()
)
},
contentAlignment = Alignment.Center
) {
Crossfade(
pressed.value
) {
Box(
modifier = Modifier
.size(96.dp)
.then(
if (it) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { CircleShape },
highlight = {
Highlight.Ambient
},
onDrawSurface = {
drawCircle(
color = Color.White.copy(
0.2f
),
radius = size.height
)
drawCircle(
color = colorFromY(
when (i) {
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
else -> 0f
}
),
style = Stroke(2.dp.value),
radius = size.height / 2
)
},
effects = {
lens(
refractionHeight = 32f.dp.value,
refractionAmount = size.height
)
}
)
} else Modifier
)
)
}
Box(
modifier = Modifier
.size(18.dp)
.background(
colorFromY(
when (i) {
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
else -> 0f
}
),
CircleShape
)
.border(
2.5.dp,
MaterialTheme.colorScheme.surfaceContainer,
CircleShape
)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
when (i) {
0 -> {
lowOffset.value =
(lowOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
1 -> {
midOffset.value =
(midOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
2 -> {
highOffset.value =
(highOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
}
},
onDragStarted = {
pressed.value = true
},
onDragStopped = {
pressed.value = false
}
)
)
}
}
}
}
}
}
}
}
@Preview(name = "Apple")
@Composable
fun EqualizerScreenPreviewApple() {
LibrePodsTheme(
m3eEnabled = false
) {
Box (
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = demoState,
setCustomEqEnabled = { },
setCustomEq = {_, _, _ -> }
)
}
}
}
@Preview(name = "Material")
@Composable
fun EqualizerScreenPreviewMaterial() {
LibrePodsTheme(
m3eEnabled = true
) {
val state = remember { mutableStateOf(demoState) }
Box (
modifier = Modifier
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = state.value,
setCustomEqEnabled = { state.value = state.value.copy(customEq = state.value.customEq.copy(state = if (it) 2 else 1)) },
setCustomEq = {low, mid, high -> state.value = state.value.copy(customEq = state.value.customEq.copy(low = low, mid = mid, high = high))}
)
}
}
}
@@ -0,0 +1,405 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens
import android.graphics.Paint
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) {
viewModel.startHeadTracking()
onDispose {
viewModel.stopHeadTracking()
}
}
val isDarkTheme = isSystemInDarkTheme()
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope()
var lastClickTime by remember { mutableLongStateOf(0L) }
var shouldExplode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(topPadding))
Column (
modifier = Modifier
.fillMaxWidth()
.layerBackdrop(backdrop)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
StyledToggle(
label = "Head Gestures",
checked = state.headGesturesEnabled,
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
enabled = state.isPremium || state.headGesturesEnabled,
description = stringResource(R.string.head_gestures_details),
header = true
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
"Velocity",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
)
Plot()
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(gestureText) {
if (gestureText.isNotEmpty()) {
lastClickTime = System.currentTimeMillis()
delay(3000)
if (System.currentTimeMillis() - lastClickTime >= 3000) {
shouldExplode = true
}
}
}
}
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
StyledButton(
onClick = {
gestureText = gestureTextValue
coroutineScope.launch {
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
}
},
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
maxScale = 0.05f
) {
Text(
"Test Head Gestures",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = MaterialTheme.colorScheme.onSecondaryContainer
),
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
) {
AnimatedContent(
targetState = gestureText,
transitionSpec = {
(fadeIn(
animationSpec = tween(300)
) + slideInVertically(
initialOffsetY = { 40 },
animationSpec = tween(300)
)).togetherWith(fadeOut(animationSpec = tween(150)))
}
) { text ->
if (shouldExplode) {
LaunchedEffect(Unit) {
CoroutineScope(coroutineScope.coroutineContext).launch {
delay(750)
gestureText = ""
}
}
Text(
text = text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
color = MaterialTheme.colorScheme.onBackground
)
} else {
Text(
text = text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
),
modifier = Modifier
.fillMaxWidth()
)
}
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@Composable
private fun Plot() {
val acceleration by HeadTracking.acceleration.collectAsState()
val maxPoints = 100
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
val darkTheme = isSystemInDarkTheme()
var maxAbs by remember { mutableFloatStateOf(1000f) }
LaunchedEffect(acceleration) {
points.add(Pair(acceleration.horizontal, acceleration.vertical))
if (points.size > maxPoints) {
points.removeAt(0)
}
val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) }
maxAbs = maxOf(currentMax * 1.2f, 1000f)
}
Card(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(28.dp)
) {
val horizontalColor = MaterialTheme.colorScheme.primary
val verticalColor = MaterialTheme.colorScheme.onPrimary
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Canvas(
modifier = Modifier.fillMaxSize()
) {
val width = size.width
val height = size.height
val xScale = width / maxPoints
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
val zeroY = height / 2
val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
for (i in 0..maxPoints step 10) {
val x = i * xScale
drawLine(
color = gridColor,
start = Offset(x, 0f),
end = Offset(x, height),
strokeWidth = 1.dp.toPx()
)
}
val gridStep = maxAbs / 4
for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) {
val y = zeroY - value * yScale
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(width, y),
strokeWidth = 1.dp.toPx()
)
}
drawLine(
color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
start = Offset(0f, zeroY),
end = Offset(width, zeroY),
strokeWidth = 1.5f.dp.toPx()
)
if (points.size > 1) {
for (i in 0 until points.size - 1) {
val x1 = i * xScale
val x2 = (i + 1) * xScale
drawLine(
color = horizontalColor,
start = Offset(x1, zeroY - points[i].first * yScale),
end = Offset(x2, zeroY - points[i + 1].first * yScale),
strokeWidth = 2.dp.toPx()
)
drawLine(
color = verticalColor,
start = Offset(x1, zeroY - points[i].second * yScale),
end = Offset(x2, zeroY - points[i + 1].second * yScale),
strokeWidth = 2.dp.toPx()
)
}
}
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.RIGHT
}
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
drawText("0", 30.dp.toPx(), height/2, paint)
drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint)
}
val legendY = 15.dp.toPx()
val textOffsetY = legendY + 5.dp.toPx() / 2
drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.LEFT
}
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
}
drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.LEFT
}
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
}
}
}
}
}
@@ -16,21 +16,26 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens.apple
package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
import android.util.Log
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -41,30 +46,29 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings
import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.components.primitives.StyledScaffold
import me.kavishdevar.librepods.presentation.components.primitives.StyledSlider
import me.kavishdevar.librepods.presentation.components.primitives.StyledToggle
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingAidAdjustmentsScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) {
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
val verticalScrollState = rememberScrollState()
val uiState by viewModel.uiState.collectAsState()
val state = uiState.state
val state by viewModel.uiState.collectAsState()
val debounceJob = remember { mutableStateOf<Job?>(null) }
@@ -79,14 +83,6 @@ fun HearingAidAdjustmentsScreen(
val initialized = rememberSaveable { mutableStateOf(false) }
DisposableEffect(Unit) {
viewModel.observeATTCharacteristic(ATTHandle.HEARING_AID)
onDispose {
viewModel.stopObservingATTCharacteristic()
}
}
val hearingAidSettings = remember { mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
@@ -144,87 +140,86 @@ fun HearingAidAdjustmentsScreen(
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::writeATTCharacteristic)
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
}
StyledScaffold(
title = stringResource(R.string.adjustments),
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val 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
StyledSlider(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = it
},
startImageVector = LocalIcons.current.SpeakerMin,
endImageVector = LocalIcons.current.SpeakerMax,
independent = true,
)
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification),
checked = state.controlStates[ControlCommandIdentifier.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommand(ControlCommandIdentifier.HPS_GAIN_SWIPE, it) },
description = stringResource(R.string.swipe_amplification_description)
)
StyledSlider(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = it
},
startIcon = "􀊥",
endIcon = "􀊩",
independent = true,
)
StyledSlider(
label = stringResource(R.string.balance),
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = it
},
snapPoints = listOf(-1f, 0f, 1f),
startLabel = stringResource(R.string.left),
endLabel = stringResource(R.string.right),
independent = true,
)
StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
description = stringResource(R.string.swipe_amplification_description)
)
StyledSlider(
label = stringResource(R.string.tone),
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = it
},
startLabel = stringResource(R.string.darker),
endLabel = stringResource(R.string.brighter),
independent = true,
)
StyledSlider(
label = stringResource(R.string.balance),
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = it
},
snapPoints = listOf(-1f, 0f, 1f),
startLabel = stringResource(R.string.left),
endLabel = stringResource(R.string.right),
independent = true,
)
StyledSlider(
label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it
},
startLabel = stringResource(R.string.less),
endLabel = stringResource(R.string.more),
independent = true,
)
StyledSlider(
label = stringResource(R.string.tone),
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = it
},
startLabel = stringResource(R.string.darker),
endLabel = stringResource(R.string.brighter),
independent = true,
)
StyledToggle(
label = stringResource(R.string.conversation_boost),
checked = conversationBoostEnabled.value,
onCheckedChange = { conversationBoostEnabled.value = it },
description = stringResource(R.string.conversation_boost_description)
)
StyledSlider(
label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it
},
startLabel = stringResource(R.string.less),
endLabel = stringResource(R.string.more),
independent = true,
)
Spacer(modifier = Modifier.height(bottomPadding))
}
StyledToggle(
label = stringResource(R.string.conversation_boost),
checked = conversationBoostEnabled.value,
onCheckedChange = { conversationBoostEnabled.value = it },
description = stringResource(R.string.conversation_boost_description)
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -0,0 +1,243 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) {
val verticalScrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop()
val showDialog = remember { mutableStateOf(false) }
val initialLoad = remember { mutableStateOf(true) }
val state by viewModel.uiState.collectAsState()
val hearingAidEnabled = remember {
val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
}
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.layerBackdrop(backdrop)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
// val mediaAssistEnabled = remember { mutableStateOf(false) }
// val adjustMediaEnabled = remember { mutableStateOf(false) }
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
LaunchedEffect(hearingAidEnabled.value) {
if (hearingAidEnabled.value && !initialLoad.value) {
showDialog.value = true
} else if (!hearingAidEnabled.value && !initialLoad.value) {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
hearingAidEnabled.value = false
}
initialLoad.value = false
}
// fun onAdjustPhoneChange(value: Boolean) {
// // TODO
// }
// fun onAdjustMediaChange(value: Boolean) {
// // TODO
// }
StyledList (title = stringResource(R.string.hearing_aid)) {
StyledToggle(
label = stringResource(R.string.hearing_aid),
checked = hearingAidEnabled.value,
onCheckedChange = { hearingAidEnabled.value = it },
)
StyledListItem(
name = stringResource(R.string.adjustments),
onClick = onNavigateHearingAidAdjustments,
)
}
Text(
text = stringResource(R.string.hearing_aid_description),
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
StyledListItem(
name = stringResource(R.string.update_hearing_test),
onClick = onNavigateHearingTest,
)
// not implemented yet
// StyledToggle(
// titleRes = stringResource(R.string.media_assist),
// label = stringResource(R.string.media_assist),
// checkedState = mediaAssistEnabled,
// independent = true,
// descriptionRes = stringResource(R.string.media_assist_description)
// )
// Spacer(modifier = Modifier.height(8.dp))
// Column (
// modifier = Modifier
// .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp))
// ) {
// StyledToggle(
// label = stringResource(R.string.adjust_media),
// checkedState = adjustMediaEnabled,
// onCheckedChange = { onAdjustMediaChange(it) },
// independent = false
// )
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888),
// modifier = Modifier
// .padding(horizontal = 12.dp)
// )
// StyledToggle(
// label = stringResource(R.string.adjust_calls),
// checkedState = adjustPhoneEnabled,
// onCheckedChange = { onAdjustPhoneChange(it) },
// independent = false
// )
// }
Spacer(modifier = Modifier.height(bottomPadding))
}
ConfirmationDialog(
showDialog = showDialog,
title = "Enable Hearing Aid",
message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.",
confirmText = "Enable",
dismissText = "Cancel",
onConfirm = {
showDialog.value = false
val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
if (!enrolled) {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} else {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
}
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch {
try {
if (state.hearingAidData.isEmpty()) {
Log.w(TAG, "read failed")
return@launch
}
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
if (parsed == null) {
Log.w(TAG, "transparency parse failed")
return@launch
}
val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}")
}
}
},
onDismiss = {
hearingAidEnabled.value = false
showDialog.value = false
},
backdrop = backdrop
)
}
@@ -0,0 +1,118 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (state.vendorIdHook) {
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte())
)
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(12.dp))
}
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
0
)?.toInt() == 1,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
)
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -0,0 +1,35 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer),
contentAlignment = Alignment.Center
) {
CircularWavyProgressIndicator(
modifier = Modifier
.size(120.dp)
)
}
}
@Preview
@Composable
fun LoadingScreenPreview() {
LibrePodsTheme {
LoadingScreen()
}
}
@@ -0,0 +1,101 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun MicrophoneSettingsRoute(
viewModel: AirPodsViewModel
) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
MicrophoneSettingsScreen(
selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0,
topPadding = topPadding,
bottomPadding = bottomPadding,
onMicrophoneSettingsChanged = {
viewModel.setControlCommandInt(id, it)
}
)
}
}
@Composable
fun MicrophoneSettingsScreen(
selectedMode: Int,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
onMicrophoneSettingsChanged: (Int) -> Unit
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = stringResource(R.string.microphone_automatic),
selected = selectedMode == 0,
onClick = { onMicrophoneSettingsChanged(0) }
)
StyledListItem(
name = stringResource(R.string.microphone_always_right),
selected = selectedMode == 1,
onClick = { onMicrophoneSettingsChanged(1) }
)
StyledListItem(
name = stringResource(R.string.microphone_always_left),
selected = selectedMode == 2,
onClick = { onMicrophoneSettingsChanged(2) }
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -0,0 +1,87 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun OpenSourceLicensesScreen() {
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val context = LocalContext.current
val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader()
.use { it.readText() }
}
LibrariesContainer(
libraries = libraries,
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -0,0 +1,233 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState()
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val scrollState = rememberScrollState()
Column (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = stringResource(R.string.noise_control),
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = {
viewModel.setLongPressAction(
name,
StemAction.CYCLE_NOISE_CONTROL_MODES
)
}
)
StyledListItem(
name = stringResource(R.string.digital_assistant),
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = {
viewModel.setLongPressAction(
name,
StemAction.DIGITAL_ASSISTANT
)
},
enabled = state.isPremium
)
}
if (!state.isPremium) {
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Spacer(modifier = Modifier.height(32.dp))
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
StyledList(
title = stringResource(R.string.noise_control),
description = stringResource(R.string.press_and_hold_noise_control_description)
) {
if (state.offListeningMode) {
StyledListItem(
name = stringResource(R.string.off),
description = stringResource(R.string.listening_mode_off_description),
selected = (currentByte and 0x01) != 0,
onClick = {
viewModel.toggleListeningMode(0x01)
},
orientation = ListItemOrientation.Vertical,
leadingContent = {
Icon(
painter = painterResource(R.drawable.noise_cancellation),
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.height(42.dp)
.wrapContentWidth()
)
}
)
}
StyledListItem(
name = stringResource(R.string.transparency),
description = stringResource(R.string.listening_mode_transparency_description),
selected = (currentByte and 0x04) != 0,
onClick = {
viewModel.toggleListeningMode(0x04)
},
orientation = ListItemOrientation.Vertical,
leadingContent = {
Icon(
painter = painterResource(R.drawable.transparency),
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.height(42.dp)
.wrapContentWidth()
)
}
)
StyledListItem(
name = stringResource(R.string.adaptive),
description = stringResource(R.string.listening_mode_adaptive_description),
selected = (currentByte and 0x08) != 0,
onClick = {
viewModel.toggleListeningMode(0x08)
},
orientation = ListItemOrientation.Vertical,
leadingContent = {
Icon(
painter = painterResource(R.drawable.adaptive),
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.height(42.dp)
.wrapContentWidth()
)
}
)
StyledListItem(
name = stringResource(R.string.noise_cancellation),
description = stringResource(R.string.listening_mode_noise_cancellation_description),
selected = (currentByte and 0x02) != 0,
onClick = {
viewModel.toggleListeningMode(0x02)
},
orientation = ListItemOrientation.Vertical,
leadingContent = {
Icon(
painter = painterResource(R.drawable.noise_cancellation),
contentDescription = "Icon",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.height(42.dp)
.wrapContentWidth()
)
}
)
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -0,0 +1,217 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.navigation.Screen
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun PurchaseScreen(
viewModel: PurchaseViewModel = viewModel(),
backStack: SnapshotStateList<Screen>
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.layerBackdrop(backdrop)
.verticalScroll(scrollState)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
LaunchedEffect(state.isPremium) {
if (state.isPremium) {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
}
}
if (!state.isPremium) {
StyledList(title = stringResource(R.string.free_features)) {
StyledListItem(
name = stringResource(R.string.ear_detection),
description = stringResource(R.string.ear_detection_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.battery),
description = stringResource(R.string.battery_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.noise_control),
description = stringResource(R.string.noise_control_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
if (XposedState.isAvailable) {
StyledListItem(
name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})",
description = stringResource(R.string.hearing_aid_description)
.substringBefore("\n\n"),
enabled = false,
orientation = ListItemOrientation.Vertical
)
}
}
Spacer(modifier = Modifier.height(24.dp))
StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) {
StyledListItem(
name = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.digital_assistant_on_long_press),
description = stringResource(R.string.digital_assistant_on_long_press_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.head_gestures),
description = stringResource(R.string.head_gestures_details),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.advanced_device_settings),
description = stringResource(R.string.advanced_device_settings_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.automatic_connection),
description = stringResource(R.string.automatic_connection_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.customizations),
description = stringResource(R.string.customizations_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.support_the_development),
description = stringResource(R.string.support_development_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
}
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = {
viewModel.purchase(context)
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary,
materialButtonStyle = MaterialButtonStyle.Filled
) {
Text(
stringResource(R.string.buy_price, state.price),
style = MaterialTheme.typography.bodyMediumEmphasized,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(8.dp))
StyledButton(
onClick = {
viewModel.restorePurchases()
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
isInteractive = false,
materialButtonStyle = MaterialButtonStyle.Outlined
) {
Text(
stringResource(R.string.restore_purchases),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -100,11 +100,11 @@ fun ReleaseNotesScreen(
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
LibrePodsTheme(designSystem = DesignSystem.Material) {
LibrePodsTheme(m3eEnabled = true) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(52.dp)),
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(52.dp)),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -372,7 +372,7 @@ fun ReleaseNotesScreen(
@Composable
fun ReleaseNotesScreenPreview() {
LibrePodsTheme(
designSystem = DesignSystem.Apple
m3eEnabled = false
) {
ReleaseNotesScreen(
updates = updates,
@@ -0,0 +1,94 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun RenameScreen(viewModel: AirPodsViewModel) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val name = sharedPreferences.getString("name", "")?: ""
val textFieldState = rememberTextFieldState(initialText = name)
LaunchedEffect(textFieldState.text) {
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
viewModel.setName(textFieldState.text.toString())
}
StyledInputField(
textFieldState,
focusRequester
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -0,0 +1,365 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "TransparencySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = rememberSaveable { mutableStateOf(false) }
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val eq = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8) { 0.5f }) }
val initialized = rememberSaveable { mutableStateOf(false) }
val transparencySettings = remember {
mutableStateOf(
TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
)
)
}
LaunchedEffect(
enabled.value,
amplificationSliderValue.floatValue,
balanceSliderValue.floatValue,
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
eq.value
) {
if (!initialized.value) return@LaunchedEffect
transparencySettings.value = TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
)
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
LaunchedEffect(state.transparencyData) {
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
eq.value = parsedSettings.leftEQ.copyOf()
}
initialized.value = true
}
if (state.vendorIdHook) {
StyledToggle(
label = stringResource(R.string.transparency_mode),
checked = enabled.value,
description = stringResource(R.string.customize_transparency_mode_description),
onCheckedChange = { enabled.value = it }
)
Spacer(modifier = Modifier.height(4.dp))
StyledSlider(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = it
},
startIcon = "􀊥",
endIcon = "􀊩",
independent = true
)
StyledSlider(
label = stringResource(R.string.balance),
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = it
},
snapPoints = listOf(-1f, 0f, 1f),
startLabel = stringResource(R.string.left),
endLabel = stringResource(R.string.right),
independent = true,
)
StyledSlider(
label = stringResource(R.string.tone),
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = it
},
startLabel = stringResource(R.string.darker),
endLabel = stringResource(R.string.brighter),
independent = true,
)
StyledSlider(
label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it
},
startLabel = stringResource(R.string.less),
endLabel = stringResource(R.string.more),
independent = true,
)
StyledToggle(
label = stringResource(R.string.conversation_boost),
checked = conversationBoostEnabled.value,
description = stringResource(R.string.conversation_boost_description),
onCheckedChange = { conversationBoostEnabled.value = it }
)
Text(
text = stringResource(R.string.equalizer),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(16.dp, bottom = 4.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
for (i in 0 until 8) {
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
) {
Text(
text = String.format("%.2f", eqValue.floatValue),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Slider(
value = eqValue.floatValue,
onValueChange = { newVal ->
eqValue.floatValue = newVal
val newEQ = eq.value.copyOf()
newEQ[i] = eqValue.floatValue
eq.value = newEQ
},
valueRange = 0f..100f,
modifier = Modifier
.fillMaxWidth(0.9f)
.height(36.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
Box(
modifier = Modifier
.size(24.dp)
.shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape)
)
},
track = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp),
contentAlignment = Alignment.CenterStart
)
{
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(trackColor, RoundedCornerShape(4.dp))
)
Box(
modifier = Modifier
.fillMaxWidth(eqValue.floatValue / 100f)
.height(4.dp)
.background(
activeTrackColor,
RoundedCornerShape(4.dp)
)
)
}
}
)
Text(
text = stringResource(R.string.band_label, i + 1),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -0,0 +1,920 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.LogCollector
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun CustomIconButton(
onClick: () -> Unit,
content: @Composable () -> Unit
) {
Box(
modifier = Modifier
.clickable(onClick = onClick)
.padding(8.dp),
contentAlignment = Alignment.Center
) {
content()
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun TroubleshootingScreen() {
val context = LocalContext.current
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val logCollector = remember { LogCollector(context) }
val savedLogs = remember { mutableStateListOf<File>() }
var isCollectingLogs by remember { mutableStateOf(false) }
var showTroubleshootingSteps by remember { mutableStateOf(false) }
var currentStep by remember { mutableIntStateOf(0) }
var logContent by remember { mutableStateOf("") }
var selectedLogFile by remember { mutableStateOf<File?>(null) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showDeleteAllDialog by remember { mutableStateOf(false) }
var isLoadingLogContent by remember { mutableStateOf(false) }
var logContentLoaded by remember { mutableStateOf(false) }
LaunchedEffect(isCollectingLogs) {
while (isCollectingLogs) {
delay(250)
delay(250)
}
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var showBottomSheet by remember { mutableStateOf(false) }
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD)
var instructionText by remember { mutableStateOf("") }
val isDarkTheme = isSystemInDarkTheme()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
val logsDir = File(context.filesDir, "logs")
if (logsDir.exists()) {
savedLogs.clear()
savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") }
?.sortedByDescending { it.lastModified() } ?: emptyList())
}
}
}
val saveLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
if (uri != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(logContent.toByteArray())
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"Failed to save log: ${e.localizedMessage}",
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
LaunchedEffect(currentStep) {
instructionText = when (currentStep) {
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
1 -> "Please put your AirPods in the case and close it, so they disconnect completely."
2 -> "Preparing to collect logs... Please wait."
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
4 -> "Log collection complete! You can now save or share the logs."
else -> ""
}
}
fun openLogBottomSheet(file: File) {
selectedLogFile = file
logContent = ""
isLoadingLogContent = false
logContentLoaded = false
showBottomSheet = true
}
val backdrop = rememberLayerBackdrop()
Box(
modifier = Modifier.fillMaxSize()
) {
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(scrollState)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
Text(
text = stringResource(R.string.saved_logs),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
if (savedLogs.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.no_logs_found),
fontSize = 16.sp,
color = textColor
)
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Total Logs: ${savedLogs.size}",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
if (savedLogs.size > 1) {
TextButton(
onClick = { showDeleteAllDialog = true },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete All")
}
}
}
savedLogs.forEach { logFile ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.clickable {
openLogBottomSheet(logFile)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = logFile.name,
fontSize = 16.sp,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
.format(Date(logFile.lastModified())),
fontSize = 14.sp,
color = textColor.copy(alpha = 0.6f)
)
}
CustomIconButton(
onClick = {
selectedLogFile = logFile
showDeleteDialog = true
}
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(
visible = !showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(300))
) {
Button(
onClick = { showTroubleshootingSteps = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
enabled = !isCollectingLogs
) {
Text(stringResource(R.string.collect_logs))
}
}
AnimatedVisibility(
visible = showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(animationSpec = tween(300)) { it / 2 },
exit = fadeOut(animationSpec = tween(300)) +
slideOutVertically(animationSpec = tween(300)) { it / 2 }
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.troubleshooting_steps),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(16.dp)
) {
val textAlpha = animateFloatAsState(
targetValue = 1f,
animationSpec = tween(durationMillis = 300),
label = "textAlpha"
)
Text(
text = instructionText,
fontSize = 16.sp,
color = textColor.copy(alpha = textAlpha.value),
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(16.dp))
when (currentStep) {
0 -> {
Button(
onClick = {
coroutineScope.launch {
logCollector.openXposedSettings(context)
delay(2000)
currentStep = 1
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Open Xposed Settings")
}
}
1 -> {
Button(
onClick = {
currentStep = 2
isCollectingLogs = true
coroutineScope.launch {
try {
logCollector.clearLogs()
logCollector.addLogMarker(LogCollector.LogMarkerType.START)
logCollector.killBluetoothService()
withContext(Dispatchers.Main) {
delay(500)
currentStep = 3
}
val timestamp = SimpleDateFormat(
"yyyyMMdd_HHmmss",
Locale.US
).format(Date())
logContent =
logCollector.startLogCollection(
listener = { /* Removed live log display */ },
connectionDetectedCallback = {
launch {
delay(5000)
withContext(Dispatchers.Main) {
if (isCollectingLogs) {
logCollector.stopLogCollection()
currentStep = 4
isCollectingLogs =
false
}
}
}
}
)
val logFile =
logCollector.saveLogToInternalStorage(
"airpods_log_$timestamp.txt",
logContent
)
logFile?.let {
withContext(Dispatchers.Main) {
savedLogs.add(0, it)
selectedLogFile = it
Toast.makeText(
context,
"Log saved: ${it.name}",
Toast.LENGTH_SHORT
).show()
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"Error collecting logs: ${e.message}",
Toast.LENGTH_SHORT
).show()
isCollectingLogs = false
currentStep = 0
}
}
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Continue")
}
}
2, 3 -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = accentColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
fontSize = 14.sp,
color = textColor
)
if (currentStep == 3) {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
coroutineScope.launch {
logCollector.addLogMarker(
LogCollector.LogMarkerType.CUSTOM,
"Manual stop requested by user"
)
delay(1000)
logCollector.stopLogCollection()
delay(500)
withContext(Dispatchers.Main) {
currentStep = 4
isCollectingLogs = false
Toast.makeText(
context,
"Log collection stopped",
Toast.LENGTH_SHORT
).show()
}
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier
.fillMaxWidth()
) {
Text("Stop Collection")
}
}
}
}
4 -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Button(
onClick = {
selectedLogFile?.let { file ->
val fileUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
val shareIntent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(
Intent.EXTRA_STREAM,
fileUri
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
"Share log file"
)
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.width(150.dp)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Share")
}
Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = {
selectedLogFile?.let { file ->
saveLauncher.launch(
file.absolutePath
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.width(150.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_save),
contentDescription = "Save"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Save")
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
currentStep = 0
showTroubleshootingSteps = false
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Done")
}
}
}
}
}
}
if (showDeleteDialog && selectedLogFile != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Log File") },
text = {
Text("Are you sure you want to delete this log file? This action cannot be undone.")
},
confirmButton = {
TextButton(
onClick = {
selectedLogFile?.let { file ->
if (file.delete()) {
savedLogs.remove(file)
Toast.makeText(
context,
"Log file deleted",
Toast.LENGTH_SHORT
)
.show()
} else {
Toast.makeText(
context,
"Failed to delete log file",
Toast.LENGTH_SHORT
).show()
}
}
showDeleteDialog = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
if (showDeleteAllDialog) {
AlertDialog(
onDismissRequest = { showDeleteAllDialog = false },
title = { Text("Delete All Logs") },
text = {
Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
},
confirmButton = {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
var deletedCount = 0
savedLogs.forEach { file ->
if (file.delete()) {
deletedCount++
}
}
withContext(Dispatchers.Main) {
if (deletedCount > 0) {
savedLogs.clear()
Toast.makeText(
context,
"Deleted $deletedCount log files",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
"Failed to delete log files",
Toast.LENGTH_SHORT
).show()
}
}
}
showDeleteAllDialog = false
}
) {
Text("Delete All", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteAllDialog = false }) {
Text("Cancel")
}
}
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
tonalElevation = 8.dp
) {
LaunchedEffect(selectedLogFile) {
if (!logContentLoaded) {
delay(300)
withContext(Dispatchers.IO) {
isLoadingLogContent = true
logContent = try {
selectedLogFile?.readText() ?: ""
} catch (e: Exception) {
"Error loading log content: ${e.message}"
}
isLoadingLogContent = false
logContentLoaded = true
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 32.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
) {
Text(
text = selectedLogFile?.name ?: "Log Content",
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
color = textColor
)
Text(
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
.format(Date(selectedLogFile?.lastModified() ?: 0)),
fontSize = 14.sp,
color = textColor.copy(alpha = 0.7f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
}
if (isLoadingLogContent) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = accentColor)
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(
color = Color.Black,
shape = RoundedCornerShape(8.dp)
)
) {
val horizontalScrollState = rememberScrollState()
val verticalScrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
.horizontalScroll(horizontalScrollState)
.verticalScroll(verticalScrollState)
) {
Text(
text = logContent,
fontSize = 14.sp,
color = Color.LightGray,
lineHeight = 20.sp,
fontFamily = FontFamily.Monospace,
softWrap = false
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = {
selectedLogFile?.let { file ->
val fileUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
"Share log file"
)
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Share")
}
Button(
onClick = {
selectedLogFile?.let { file ->
saveLauncher.launch(file.absolutePath)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(id = R.drawable.ic_save),
contentDescription = "Save"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Save")
}
}
}
}
}
}
DisposableEffect(Unit) {
onDispose {
logCollector.stopLogCollection()
}
}
}
@@ -0,0 +1,332 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.demoState
private const val TAG = "UpdateHearingTestScreen"
@Composable
fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = state,
topPadding = topPadding,
bottomPadding = bottomPadding,
setATTCharacteristicValue = viewModel::setATTCharacteristicValue
)
}
}
@Composable
fun UpdateHearingTestScreen(
state: AirPodsUiState,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit
) {
val verticalScrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(topPadding))
Text(
text = stringResource(R.string.hearing_test_value_instruction),
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center,
)
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val rightEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val debounceJob = remember { mutableStateOf<Job?>(null) }
val initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = leftAmplification.floatValue,
rightAmplification = rightAmplification.floatValue,
leftTone = tone.floatValue,
rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
)
}
LaunchedEffect(state.hearingAidData) {
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
tone.floatValue = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
initialized.value = true
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
LaunchedEffect(
leftEQ.value,
rightEQ.value,
conversationBoostEnabled.value,
leftAmplification.floatValue,
rightAmplification.floatValue,
tone.floatValue,
ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialized.value) return@LaunchedEffect
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = leftAmplification.floatValue,
rightAmplification = rightAmplification.floatValue,
leftTone = tone.floatValue,
rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue)
}
val frequencies =
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.width(60.dp))
Text(
text = stringResource(R.string.left),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMediumEmphasized
)
Text(
text = stringResource(R.string.right),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMediumEmphasized
)
}
frequencies.forEachIndexed { index, freq ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = freq,
modifier = Modifier
.width(60.dp)
.align(Alignment.CenterVertically),
textAlign = TextAlign.End,
style = MaterialTheme.typography.labelSmall
)
OutlinedTextField(
value = leftEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = leftEQ.value.copyOf()
newArray[index] = parsed
leftEQ.value = newArray
Log.d(TAG, "Left EQ updated at index $index to $parsed")
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = rightEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = rightEQ.value.copyOf()
newArray[index] = parsed
rightEQ.value = newArray
Log.d(TAG, "Right EQ updated at index $index to $parsed")
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@Preview(name = "Apple")
@Composable
fun UpdateHearingTestScreenPreviewApple() {
LibrePodsTheme(
m3eEnabled = false
) {
Box (
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = demoState,
setATTCharacteristicValue = { _, _ -> }
)
}
}
}
@Preview(name = "Material")
@Composable
fun UpdateHearingTestScreenPreviewMaterial() {
LibrePodsTheme(
m3eEnabled = true
) {
Box (
modifier = Modifier
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = demoState,
setATTCharacteristicValue = { _, _ -> }
)
}
}
}
@@ -0,0 +1,81 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun VersionScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList(title = stringResource(R.string.version)) {
StyledListItem(
name = stringResource(R.string.version) + " 1",
description = state.version1,
enabled = false
)
StyledListItem(
name = stringResource(R.string.version) + " 2",
description = state.version2,
enabled = false
)
StyledListItem(
name = stringResource(R.string.version) + " 3",
description = state.version3,
enabled = false
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -4,8 +4,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -14,13 +12,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.common.AppInfoCard
import me.kavishdevar.librepods.presentation.components.common.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.StyledListItem
@Composable
fun NotSupportedPage(
@@ -30,10 +27,9 @@ fun NotSupportedPage(
Box(
modifier = Modifier.background(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(42.dp)
)
.clip(RoundedCornerShape(42.dp))
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(42.dp)
)
) {
Column(
modifier = Modifier
@@ -41,7 +37,6 @@ fun NotSupportedPage(
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.check_the_repository_for_more_info),
style = MaterialTheme.typography.bodyMedium,
@@ -52,11 +47,11 @@ fun NotSupportedPage(
)
DeviceInfoCard()
AppInfoCard()
StyledListItem(
contentText = stringResource(R.string.bypass_compatibility_check),
name = stringResource(R.string.bypass_compatibility_check),
onClick = bypassCompatibilityCheck
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -40,14 +40,16 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.XposedState
import me.kavishdevar.librepods.utils.bypassDeviceCheck
import me.kavishdevar.librepods.utils.isSupported
import me.kavishdevar.librepods.utils.removeDeviceCheckBypass
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun OnboardingScreen(
onOnboardingComplete: () -> Unit,
@@ -82,12 +84,12 @@ fun OnboardingScreen(
}
LibrePodsTheme(
designSystem = DesignSystem.Material
m3eEnabled = true
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer),
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -99,15 +101,14 @@ fun OnboardingScreen(
.padding(12.dp),
state = state,
itemWidth = LocalWindowInfo.current.containerDpSize.width - 24.dp,
userScrollEnabled = false,
userScrollEnabled = false
) { index ->
val shape = rememberMaskShape(RoundedCornerShape(52.dp))
Surface(
shape = shape,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(52.dp)),
color = MaterialTheme.colorScheme.surfaceContainer
.clip(RoundedCornerShape(52.dp))
) {
Column(
modifier = Modifier,
@@ -135,7 +136,7 @@ fun OnboardingScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.welcome_to_appname),
text = "Welcome to",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
@@ -191,6 +192,9 @@ fun OnboardingScreen(
NotSupportedPage(
bypassCompatibilityCheck = {
bypassDeviceCheck(sharedPreferences)
animationScope.launch {
state.animateScrollToItem(3)
}
}
)
}
@@ -199,6 +203,9 @@ fun OnboardingScreen(
onBackward = {
animationScope.launch {
if (state.canScrollBackward) state.animateScrollToItem(if (isSupported) 1 else 2)
if (!isSupported) {
removeDeviceCheckBypass(sharedPreferences)
}
}
},
onForward = onOnboardingComplete
@@ -213,6 +220,8 @@ fun OnboardingScreen(
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Preview
@Composable
fun OnboardingScreenPreview(){
@@ -37,11 +37,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.HeartRateRecord
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -49,12 +46,12 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
import me.kavishdevar.librepods.presentation.MaterialIcons
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionsPage(
onBackward: () -> Unit,
@@ -66,8 +63,11 @@ fun PermissionsPage(
val context = LocalContext.current
val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val healthPermissions = rememberPermissionState(
HealthPermission.getWritePermission(HeartRateRecord::class)
val phonePermissionState = rememberMultiplePermissionsState(
listOf(
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS"
)
) {
if (grantingAll) {
if (!canDrawOverlays.value) {
@@ -80,27 +80,15 @@ fun PermissionsPage(
}
}
val phonePermissionState = rememberMultiplePermissionsState(
listOf(
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS"
)
) {
if (grantingAll) {
if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
}
}
val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") {
if (grantingAll) {
if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
}
}
val bluetoothPermissionsState = rememberMultiplePermissionsState(
listOf(
"android.permission.BLUETOOTH_CONNECT",
@@ -113,7 +101,6 @@ fun PermissionsPage(
if (grantingAll) {
if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest()
else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
}
}
@@ -138,7 +125,7 @@ fun PermissionsPage(
Box(
modifier = Modifier.background(
color = MaterialTheme.colorScheme.surfaceContainerLow,
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(42.dp)
)
) {
@@ -149,22 +136,22 @@ fun PermissionsPage(
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
StyledList(title = stringResource(R.string.required_permissions)) {
val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
StyledList(title = "Required Permissions") {
val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
val animatedBluetoothContainerColor by animateColorAsState(
if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
)
StyledListItem(
contentText = stringResource(R.string.bluetooth),
name = "Bluetooth",
onClick = if (!bluetoothPermissionsState.allPermissionsGranted) {
{
grantingAll = false
bluetoothPermissionsState.launchMultiplePermissionRequest()
}
} else null,
supportingText = stringResource(R.string.permission_description_bluetooth),
orientation = StyledListItemOrientation.Vertical,
description = "Required to communicate with AirPods",
orientation = ListItemOrientation.Vertical,
leadingContent = {
Box(
modifier = Modifier
@@ -177,7 +164,7 @@ fun PermissionsPage(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.Bluetooth,
imageVector = MaterialIcons.bluetooth,
contentDescription = "bluetooth",
modifier = Modifier.size(24.dp),
tint = animatedBluetoothIconColor
@@ -186,28 +173,28 @@ fun PermissionsPage(
},
)
}
StyledList(title = stringResource(R.string.optional_permissions)) {
StyledList(title = "Optional Permissions") {
val animatedNotificationsIconColor by animateColorAsState(
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
val animatedNotificationsContainerColor by animateColorAsState(
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
)
val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
val animatedPhoneContainerColor by animateColorAsState(
if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
)
StyledListItem(
contentText = stringResource(R.string.notifications),
name = "Notifications",
onClick = if (!notificationPermissionState.status.isGranted) {
{
grantingAll = false
notificationPermissionState.launchPermissionRequest()
}
} else null,
supportingText = stringResource(R.string.permission_description_notification),
orientation = StyledListItemOrientation.Vertical,
description = "Show battery status",
orientation = ListItemOrientation.Vertical,
leadingContent = {
Box(
modifier = Modifier
@@ -220,7 +207,7 @@ fun PermissionsPage(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.Notifications,
imageVector = MaterialIcons.notifications,
contentDescription = "notifications",
modifier = Modifier.size(24.dp),
tint = animatedNotificationsIconColor
@@ -229,15 +216,15 @@ fun PermissionsPage(
},
)
StyledListItem(
contentText = stringResource(R.string.phone),
name = "Phone",
onClick = if (!phonePermissionState.allPermissionsGranted) {
{
grantingAll = false
phonePermissionState.launchMultiplePermissionRequest()
}
} else null,
supportingText = stringResource(R.string.permission_description_phone),
orientation = StyledListItemOrientation.Vertical,
description = "Respond to phone calls with head gestures",
orientation = ListItemOrientation.Vertical,
leadingContent = {
Box(
modifier = Modifier
@@ -250,8 +237,8 @@ fun PermissionsPage(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.Call,
contentDescription = "call",
imageVector = MaterialIcons.call,
contentDescription = "bluetooth",
modifier = Modifier.size(24.dp),
tint = animatedPhoneIconColor
)
@@ -260,44 +247,11 @@ fun PermissionsPage(
)
}
val animatedHealthConnectIconColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
val animatedHealthConnectContainerColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)
val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest)
StyledListItem(
contentText = stringResource(R.string.permission_healthconnect),
onClick = if (!healthPermissions.status.isGranted) {
{
grantingAll = false
healthPermissions.launchPermissionRequest()
}
} else null,
supportingText = stringResource(R.string.permission_description_healthconnect),
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
animatedHealthConnectContainerColor,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.VitalSigns,
contentDescription = "vital signs",
modifier = Modifier.size(24.dp),
tint = animatedHealthConnectIconColor
)
}
}
)
val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)
StyledListItem(
contentText = stringResource(R.string.permission_overlay),
name = "Display over other apps",
onClick = if (!canDrawOverlays.value) {
{
grantingAll = false
@@ -308,8 +262,8 @@ fun PermissionsPage(
context.startActivity(intent)
}
} else null,
supportingText = stringResource(R.string.permission_description_overlay),
orientation = StyledListItemOrientation.Vertical,
description = "Show popups when AirPods are nearby or audio switches to them.",
orientation = ListItemOrientation.Vertical,
leadingContent = {
Box(
modifier = Modifier
@@ -322,8 +276,8 @@ fun PermissionsPage(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.Overlay,
contentDescription = "overlay",
imageVector = MaterialIcons.stack,
contentDescription = "bluetooth",
modifier = Modifier.size(24.dp),
tint = animatedOverlayIconColor
)
@@ -29,7 +29,7 @@ fun PrivacyPolicyPage(
Box(
modifier = Modifier.background(
color = MaterialTheme.colorScheme.surfaceContainerLow,
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(42.dp)
)
) {
@@ -48,50 +48,42 @@ fun PrivacyPolicyPage(
Text(
text = "Overview",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.titleLarge
)
Text(
text = "LibrePods does not collect, store, sell, or share personal information for advertising, analytics, tracking, or profiling purposes. The app does not include analytics, crash reporting, telemetry, advertising SDKs, or tracking services.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "All information remains on your device unless you explicitly choose to contact me, create a GitHub issue from the app, or make a purchase or sponsorship through a third-party platform.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Third Party Services",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.titleLarge
)
Text(
text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Email",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.titleMedium
)
Text(
text = "If you contact me by email, I receive your email address and any information you choose to include in your message. When using the contact form within LibrePods, your email client will open with a pre-filled email address, the subject line and body that you fill out. The body will also include LibrePods version information and device information to help with troubleshooting.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "You can edit or remove any of this information before sending the email.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
@@ -101,26 +93,22 @@ fun PrivacyPolicyPage(
Text(
text = "The app provides a link to the LibrePods Discord server. If you choose to join the Discord server, you will be subject to Discord's privacy policy.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "I do not receive any information about you from Discord other than what is publicly visible in the Discord server, such as your username, joining date, common servers, and any messages or content you post in the server, unless you choose to share it with me in the Discord server.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "GitHub Issues",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.titleMedium
)
Text(
text = "When creating a GitHub issue through LibrePods, the app will pre-fill the issue form with:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Column(
@@ -129,86 +117,68 @@ fun PrivacyPolicyPage(
) {
Text(
"• LibrePods version name and version code",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "• Device manufacturer and model",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "• Android build information",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "• Installation source (Google Play or GitHub)",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "This information helps diagnose bugs and provide support. No information is sent automatically. The information is only submitted if you choose to create the GitHub issue.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Payments",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
text = "Payments", style = MaterialTheme.typography.titleLarge
)
if (BuildConfig.PLAY_BUILD) {
Text(
text = "Google Play",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
text = "Google Play", style = MaterialTheme.typography.titleMedium
)
Text(
text = "When using the version available on Google Play, purchases are processed by Google Play.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "LibrePods verifies the purchase with Google Play on-device, not with a remote server that I control. I do not receive any information about you or your purchase from Google Play. Payment processing is handled entirely by Google Play, and I do not have access to any of your payment information.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
} else {
Text(
text = "GitHub Sponsors",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
text = "GitHub Sponsors", style = MaterialTheme.typography.titleMedium
)
Text(
text = "When using the FOSS version available on GitHub, the upgrade button links to GitHub Sponsors. If you choose to sponsor LibrePods, your sponsorship is processed by GitHub.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Your username and country/region are shared with me when you sponsor LibrePods. Depending on your GitHub Sponsors privacy settings, I may also receive your email address.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "Contact",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
text = "Contact", style = MaterialTheme.typography.titleLarge
)
Text(
text = "If you have questions about this privacy policy, please contact me via email at privacy@kavish.xyz.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
style = MaterialTheme.typography.bodyMedium
)
Button(
@@ -217,7 +187,7 @@ fun PrivacyPolicyPage(
) {
Text(
text = stringResource(R.string.i_agree),
style = MaterialTheme.typography.labelMediumEmphasized,
style = MaterialTheme.typography.labelMediumEmphasized
)
}
@@ -16,23 +16,15 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
package me.kavishdevar.librepods.presentation.theme
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
import androidx.compose.ui.graphics.Color
// TODO: remove
object HeadTracking {
private val _acceleration = MutableStateFlow(Acceleration())
val acceleration = _acceleration.asStateFlow()
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
fun addAccel(vert: Float, horiz: Float) {
_acceleration.value = Acceleration(vert, horiz)
}
fun reset() {
_acceleration.value = Acceleration()
}
}
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -1,8 +1,12 @@
package me.kavishdevar.librepods.presentation.theme
import androidx.compose.runtime.compositionLocalOf
import kotlinx.serialization.Serializable
enum class DesignSystem {
Apple,
Material
}
val LocalDesignSystem = compositionLocalOf {
DesignSystem.Material
DesignSystem.Apple
}
@@ -30,86 +30,58 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import me.kavishdevar.librepods.presentation.icons.AppleIcons
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
val ColorScheme.sectionHeader: Color
get() = onBackground.copy(alpha = 0.6f)
private val AppleDarkColorScheme = darkColorScheme(
background = Color(0xFF000000),
surfaceContainer = Color(0xFF000000), // for some reason background is not used as the background in gmail and settings app, but surfacecontainer, so using that
onBackground = Color(0xFFFFFFFF),
surfaceContainer = Color(0xFF000000),
surface = Color(0xFF1C1C1E),
onSurface = Color(0xFFFFFFFF),
surfaceDim = Color(0x40888888),
primary = Color(0xFF0091FF),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF003258),
onPrimaryContainer = Color(0xFFB3D9FF),
secondaryContainer = Color(0xFF366AA8),
onSecondaryContainer = Color(0xFFB3D9FF),
tertiary = Color(0xFFEA7B00),
scrim = Color(0x8C000000),
surfaceContainerHigh = Color(0xFF1C1C1E),
surfaceContainerLow = Color(0xFF2C2C2E)
onSecondaryContainer = Color(0xFF0091FF),
onPrimary = Color(0xFFFFFFFF)
)
private val AppleLightColorScheme = lightColorScheme(
background = Color(0xFFF2F2F7),
onBackground = Color(0xFF000000),
surfaceContainer = Color(0xFFF2F2F7),
onBackground = Color(0xFF000000),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF000000),
surfaceDim = Color(0x40D9D9D9),
primary = Color(0xFF0088FF),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFB3D9FF),
onPrimaryContainer = Color(0xFF003258),
secondaryContainer = Color(0xFF6BC0FF),
onSecondaryContainer = Color(0xFF003258),
tertiary = Color(0xFFEA7B00),
scrim = Color(0xD9F2F2F7),
surfaceContainerHigh = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFE7E7E7)
onSecondaryContainer = Color(0xFF0088FF),
primary = Color(0xFF0088FF),
onPrimary = Color(0xFFFFFFFF)
)
@Composable
fun LibrePodsTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
designSystem: DesignSystem = DesignSystem.Material,
overrideMaterialColor: Color? = null,
m3eEnabled: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when(designSystem) {
DesignSystem.Material -> {
val colorScheme = when {
m3eEnabled -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
DesignSystem.Apple -> if (darkTheme) AppleDarkColorScheme else AppleLightColorScheme
}
val typography = when(designSystem) {
DesignSystem.Material -> MaterialTypography
DesignSystem.Apple -> AppleTypography
darkTheme -> AppleDarkColorScheme
else -> AppleLightColorScheme
}
CompositionLocalProvider(
LocalDesignSystem provides designSystem,
LocalIcons provides when (designSystem) {
DesignSystem.Material -> MaterialIcons
DesignSystem.Apple -> AppleIcons
}
LocalDesignSystem provides
if (m3eEnabled) DesignSystem.Material
else DesignSystem.Apple
) {
// var colorScheme = colorScheme
//
// if (designSystem == DesignSystem.Material) {
//
// }
MaterialExpressiveTheme(
colorScheme = colorScheme,
motionScheme = MotionScheme.expressive(),
typography = typography,
typography = if (m3eEnabled) MaterialTypography else AppleTypography,
content = content
)
}
@@ -35,83 +35,65 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
val interFamily = FontFamily(Font(R.font.inter))
val sfProFamily = FontFamily(Font(R.font.sf_pro))
val AppleTypography = Typography().run {
copy(
displayLarge = displayLarge.copy(fontFamily = interFamily),
displayMedium = displayMedium.copy(fontFamily = interFamily),
displaySmall = displaySmall.copy(fontFamily = interFamily),
displayLarge = displayLarge.copy(fontFamily = sfProFamily),
displayMedium = displayMedium.copy(fontFamily = sfProFamily),
displaySmall = displaySmall.copy(fontFamily = sfProFamily),
headlineLarge = headlineLarge.copy(fontFamily = interFamily),
headlineMedium = headlineMedium.copy(fontFamily = interFamily),
headlineSmall = headlineSmall.copy(fontFamily = interFamily),
headlineLarge = headlineLarge.copy(fontFamily = sfProFamily),
headlineMedium = headlineMedium.copy(fontFamily = sfProFamily),
headlineSmall = headlineSmall.copy(fontFamily = sfProFamily),
titleLarge = titleLarge.copy(fontFamily = interFamily),
titleMedium = titleMedium.copy(fontFamily = interFamily),
titleSmall = titleSmall.copy(fontFamily = interFamily),
titleLarge = titleLarge.copy(fontFamily = sfProFamily),
titleMedium = titleMedium.copy(fontFamily = sfProFamily),
titleSmall = titleSmall.copy(fontFamily = sfProFamily),
bodyLarge = bodyLarge.copy(fontFamily = sfProFamily),
bodyMedium = bodyMedium.copy(
fontFamily = sfProFamily,
fontSize = 16.sp
),
bodySmall = bodySmall.copy(
fontFamily = interFamily,
fontFamily = sfProFamily,
fontSize = 14.sp,
lineHeight = 18.sp
),
bodySmallEmphasized = bodySmallEmphasized.copy(
fontFamily = interFamily,
fontSize = 14.sp,
lineHeight = 18.sp,
fontWeight = FontWeight.Bold
),
bodyMedium = bodyMedium.copy(
fontFamily = interFamily,
fontSize = 16.sp,
lineHeight = 22.sp
),
bodyMediumEmphasized = bodyMediumEmphasized.copy(
fontFamily = interFamily,
fontSize = 16.sp,
lineHeight = 22.sp,
fontWeight = FontWeight.Bold
),
bodyLarge = bodyLarge.copy(
fontFamily = interFamily,
fontSize = 18.sp,
lineHeight = 24.sp
),
bodyLargeEmphasized = bodyLargeEmphasized.copy(
fontFamily = interFamily,
fontSize = 18.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.Bold
),
labelLarge = labelLarge.copy(fontFamily = interFamily),
labelLarge = labelLarge.copy(fontFamily = sfProFamily),
labelMedium = labelMedium.copy(
fontFamily = interFamily,
fontFamily = sfProFamily,
fontSize = 16.sp,
),
labelMediumEmphasized = labelMediumEmphasized.copy(
fontFamily = interFamily,
fontFamily = sfProFamily,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
),
labelSmallEmphasized = labelSmallEmphasized.copy(
fontFamily = interFamily,
fontFamily = sfProFamily,
fontSize = 14.sp,
lineHeight = 18.sp,
fontWeight = FontWeight.Bold
)
)
}
val provider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs
)
private fun robotoFlex(
wght: Float = 400f,
slnt: Float = 0f,
@@ -121,8 +103,11 @@ private fun robotoFlex(
xopq: Float = 96f,
yopq: Float = 79f,
) = FontFamily(
Font(
resId = R.font.roboto_flex,
androidx.compose.ui.text.googlefonts.Font(
// Font(
// resId = R.font.roboto_flex,
googleFont = GoogleFont("Roboto Flex"),
fontProvider = provider,
variationSettings = FontVariation.Settings(
FontVariation.Setting("wght", wght),
FontVariation.Setting("wdth", wdth),
@@ -251,13 +236,13 @@ val MaterialTypography = Typography().run {
bodyMedium = bodyMedium.copy(
fontFamily = body,
fontSize = 16.sp,
lineHeight = 22.sp,
lineHeight = 24.sp,
),
bodyLarge = bodyLarge.copy(
fontFamily = body,
fontSize = 18.sp,
lineHeight = 24.sp,
lineHeight = 28.sp,
),
bodySmallEmphasized = bodySmallEmphasized.copy(
@@ -269,13 +254,13 @@ val MaterialTypography = Typography().run {
bodyMediumEmphasized = bodyMediumEmphasized.copy(
fontFamily = bodyEmphasized,
fontSize = 16.sp,
lineHeight = 22.sp,
lineHeight = 24.sp,
),
bodyLargeEmphasized = bodyLargeEmphasized.copy(
fontFamily = bodyEmphasized,
fontSize = 18.sp,
lineHeight = 24.sp,
lineHeight = 28.sp,
),
labelSmall = labelSmall.copy(
@@ -323,7 +308,7 @@ val MaterialTypography = Typography().run {
)
@Composable
private fun TypographyPreview() {
LibrePodsTheme (designSystem = DesignSystem.Material) {
LibrePodsTheme (m3eEnabled = true) {
Box(
modifier = Modifier
.fillMaxSize()
@@ -0,0 +1,784 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.viewmodel
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.CustomEq
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService
@Suppress("ArrayInDataClass")
data class AirPodsUiState(
val deviceName: String = "AirPods",
val isLocallyConnected: Boolean = false,
val instance: AirPodsInstance? = null,
val capabilities: Set<Capability> = emptySet(),
val controlStates: Map<ControlCommandIdentifiers, ByteArray> = emptyMap(),
val offListeningMode: Boolean = true,
val battery: List<Battery> = emptyList(),
val ancMode: Int = 3,
val modelName: String = "",
val actualModel: String = "",
val serialNumbers: List<String> = emptyList(),
val version1: String = "",
val version2: String = "",
val version3: String = "",
val headTrackingActive: Boolean = false,
val headGesturesEnabled: Boolean = true,
val eqData: FloatArray = floatArrayOf(),
val automaticEarDetectionEnabled: Boolean = true,
val automaticConnectionEnabled: Boolean = true,
val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val loudSoundReductionEnabled: Boolean = false,
val transparencyData: ByteArray = byteArrayOf(),
val hearingAidData: ByteArray = byteArrayOf(),
val isPremium: Boolean = false,
val vendorIdHook: Boolean = false,
val dynamicEndOfCharge: Boolean = false,
val connectionSuccessful: Boolean = false,
val timeUntilFOSSPremiumExpiry: Long = 0L,
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
)
val demoInstance = AirPodsInstance(
name = "[DEMO] 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,
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()
}
}
}
}
@@ -0,0 +1,260 @@
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) }
}
}
@@ -20,7 +20,18 @@
package me.kavishdevar.librepods.presentation.widgets
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
class BatteryWidget : AppWidgetProvider()
class BatteryWidget : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
ServiceManager.getService()?.updateBattery()
}
}

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