From e8481cc8cdd6b7457702744ca93f4e86aaaf54f3 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 3 Jun 2025 14:54:40 +0200 Subject: [PATCH] Add preferences for AirPods Identity and Encryption Keys This commit introduces settings for users to input their AirPods' Identity Resolving Key (IRK) and Encryption Key. These keys, obtainable via a MacBook, allow CAPod to reliably identify the AirPods and decrypt detailed status information. Key changes: - Added new preferences in General Settings for IRK and Encryption Key. - Implemented a custom dialog for key input, including validation and a link to a guide. - Added a Moshi adapter for serializing/deserializing `ByteArray` to/from Base64 for storing the keys. - Included new string resources for labels, descriptions, and explanations related to the keys. - Added new icons for the key preferences. --- README.md | 5 +- .../eu/darken/capod/common/WebpageTool.kt | 4 +- .../common/serialization/ByteArrayAdapter.kt | 15 ++++ .../serialization/SerializationModule.kt | 1 + .../darken/capod/main/core/GeneralSettings.kt | 3 + .../settings/general/AirPodKeyInputDialog.kt | 71 +++++++++++++++++++ .../general/GeneralSettingsFragment.kt | 27 +++++++ app/src/main/res/drawable/ic_key_24.xml | 11 +++ .../main/res/drawable/ic_key_outline_24.xml | 11 +++ app/src/main/res/values/strings.xml | 14 +++- app/src/main/res/xml/preferences_general.xml | 12 ++++ 11 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 app-common/src/main/java/eu/darken/capod/common/serialization/ByteArrayAdapter.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/settings/general/AirPodKeyInputDialog.kt create mode 100644 app/src/main/res/drawable/ic_key_24.xml create mode 100644 app/src/main/res/drawable/ic_key_outline_24.xml diff --git a/README.md b/README.md index b03d06d7..7856bb32 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Companion App for AirPods (CAPod) + [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases)](https://github.com/d4rken-org/capod/releases/latest) [![Code tests & eval](https://github.com/d4rken-org/capod/actions/workflows/code-checks.yml/badge.svg)](https://github.com/d4rken/capod/actions/workflows/code-checks.yml) [![Crowdin](https://badges.crowdin.net/capod/localized.svg)](https://crowdin.com/project/capod) @@ -56,7 +57,6 @@ Currently supported models: | [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [![](https://img.shields.io/f-droid/v/eu.darken.capod)](https://f-droid.org/en/packages/eu.darken.capod/) | | [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) | - ## Support the project * Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) @@ -72,6 +72,7 @@ Currently supported models: + ## Thanks to * The [OpenPods](https://github.com/adolfintel/OpenPods) project, @@ -82,6 +83,8 @@ Currently supported models: * Celosia, Guillaume. (2020). Privacy Challenges in Wireless Communications of the Internet of Things. * The [MagicPods](https://github.com/steam3d/MagicPods-Windows) project. If you are looking for "CAPod for Windows", check it out. +* [@kavishdevar](https://github.com/kavishdevar/librepods) and + his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff. ## License diff --git a/app-common/src/main/java/eu/darken/capod/common/WebpageTool.kt b/app-common/src/main/java/eu/darken/capod/common/WebpageTool.kt index 36395abf..be96755d 100644 --- a/app-common/src/main/java/eu/darken/capod/common/WebpageTool.kt +++ b/app-common/src/main/java/eu/darken/capod/common/WebpageTool.kt @@ -2,7 +2,7 @@ package eu.darken.capod.common import android.content.Context import android.content.Intent -import android.net.Uri +import androidx.core.net.toUri import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR @@ -15,7 +15,7 @@ class WebpageTool @Inject constructor( ) { fun open(address: String) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(address)).apply { + val intent = Intent(Intent.ACTION_VIEW, address.toUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } try { diff --git a/app-common/src/main/java/eu/darken/capod/common/serialization/ByteArrayAdapter.kt b/app-common/src/main/java/eu/darken/capod/common/serialization/ByteArrayAdapter.kt new file mode 100644 index 00000000..68d16db1 --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/common/serialization/ByteArrayAdapter.kt @@ -0,0 +1,15 @@ +package eu.darken.capod.common.serialization + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +class ByteArrayAdapter { + @ToJson + fun toJson(obj: ByteArray): String = Base64.encode(obj) + + @FromJson + fun fromJson(base64: String): ByteArray? = Base64.decode(base64) +} \ No newline at end of file diff --git a/app-common/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt b/app-common/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt index 1d06066f..4f2512d8 100644 --- a/app-common/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt +++ b/app-common/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt @@ -15,6 +15,7 @@ class SerializationModule { @Singleton fun moshi(): Moshi = Moshi.Builder() .add(JavaInstantAdapter()) + .add(ByteArrayAdapter()) .build() } diff --git a/app-common/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt b/app-common/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt index fa15c86a..47b48ba3 100644 --- a/app-common/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt +++ b/app-common/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt @@ -35,6 +35,9 @@ class GeneralSettings @Inject constructor( val mainDeviceAddress = preferences.createFlowPreference("core.maindevice.address", null) val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi) + val mainDeviceIdentityKey = preferences.createFlowPreference("core.maindevice.identitykey", null, moshi) + val mainDeviceEncryptionKey = + preferences.createFlowPreference("core.maindevice.encryptionkey", null, moshi) val isOffloadedFilteringDisabled = preferences.createFlowPreference( "core.compat.offloaded.filtering.disabled", diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/AirPodKeyInputDialog.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/AirPodKeyInputDialog.kt new file mode 100644 index 00000000..3dc036d5 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/AirPodKeyInputDialog.kt @@ -0,0 +1,71 @@ +package eu.darken.capod.main.ui.settings.general + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doOnTextChanged +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import eu.darken.capod.R +import eu.darken.capod.common.UIConverter + +class AirPodKeyInputDialog(private val context: Context) { + fun create( + mode: Mode, + current: String, + onKey: (String) -> Unit, + onGuide: () -> Unit, + ): AlertDialog { + val inputEditText = TextInputEditText(context).apply { + setText(current) + } + val inputLayout = + TextInputLayout(context, null, com.google.android.material.R.attr.textInputOutlinedStyle).apply { + hint = context.getString( + R.string.general_example_label, + "FE-D0-1C-54-11-81-BC-BC-87-D2-C4-3F-31-64-5F-EE" + ) + addView(inputEditText) + val padding = UIConverter.convertDpToPixels(context, 24f) + setPadding(padding, padding / 2, padding, 0) + } + + val dialog = MaterialAlertDialogBuilder(context).apply { + setView(inputLayout) + when (mode) { + Mode.IRK -> { + setTitle(R.string.settings_maindevice_identitykey_label) + setMessage(R.string.settings_maindevice_identitykey_explanation) + } + + Mode.ENC -> { + setTitle(R.string.settings_maindevice_encryptionkey_label) + setMessage(R.string.settings_maindevice_encryptionkey_explanation) + } + } + + setPositiveButton(R.string.general_save_action) { _, _ -> onKey(inputEditText.text.toString()) } + setNegativeButton(R.string.general_cancel_action) { _, _ -> } + setNeutralButton(R.string.general_guide_action) { _, _ -> onGuide() } + }.create() + + dialog.setOnShowListener { + val saveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + + val keyRegex = Regex("^([0-9A-F]{2}-){15}[0-9A-F]{2}$") + fun validateInput() { + val isValid = inputEditText.text.toString().matches(keyRegex) + saveButton.isEnabled = isValid || inputEditText.length() == 0 + } + inputEditText.doOnTextChanged { _, _, _, _ -> validateInput() } + validateInput() + } + return dialog + } + + enum class Mode { + IRK, + ENC, + ; + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt index 15e74b68..a681db54 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt @@ -8,6 +8,7 @@ import androidx.preference.ListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import eu.darken.capod.R +import eu.darken.capod.common.WebpageTool import eu.darken.capod.common.bluetooth.ScannerMode import eu.darken.capod.common.preferences.PercentSliderPreference import eu.darken.capod.common.uix.PreferenceFragment3 @@ -25,6 +26,7 @@ class GeneralSettingsFragment : PreferenceFragment3() { @Inject lateinit var generalSettings: GeneralSettings @Inject lateinit var upgradeRepo: UpgradeRepo + @Inject lateinit var webpageTool: WebpageTool override val settings: GeneralSettings get() = generalSettings @@ -35,6 +37,8 @@ class GeneralSettingsFragment : PreferenceFragment3() { private val scanModePref by lazy { findPreference(generalSettings.scannerMode.key)!! } private val mainDeviceAddressPref by lazy { findPreference(generalSettings.mainDeviceAddress.key)!! } private val mainDeviceModelPref by lazy { findPreference(generalSettings.mainDeviceModel.key)!! } + private val mainDeviceIdentityKeyPref by lazy { findPreference(generalSettings.mainDeviceIdentityKey.key)!! } + private val mainDeviceEncryptionKeyPref by lazy { findPreference(generalSettings.mainDeviceEncryptionKey.key)!! } override fun onPreferencesCreated() { monitorModePref.apply { @@ -45,9 +49,32 @@ class GeneralSettingsFragment : PreferenceFragment3() { entries = ScannerMode.values().map { getString(it.labelRes) }.toTypedArray() entryValues = ScannerMode.values().map { settings.scannerMode.rawWriter(it) as String }.toTypedArray() } + mainDeviceIdentityKeyPref.setOnPreferenceClickListener { + AirPodKeyInputDialog(requireContext()).create( + mode = AirPodKeyInputDialog.Mode.IRK, + current = generalSettings.mainDeviceIdentityKey.value?.toHumanReadable() ?: "", + onKey = { generalSettings.mainDeviceIdentityKey.value = it.fromHumanReadable() }, + onGuide = { webpageTool.open("https://github.com/d4rken-org/capod/wiki/airpod-Keys") } + ).show() + true + } + mainDeviceEncryptionKeyPref.setOnPreferenceClickListener { + AirPodKeyInputDialog(requireContext()).create( + mode = AirPodKeyInputDialog.Mode.ENC, + current = generalSettings.mainDeviceEncryptionKey.value?.toHumanReadable() ?: "", + onKey = { generalSettings.mainDeviceEncryptionKey.value = it.fromHumanReadable() }, + onGuide = { webpageTool.open("https://github.com/d4rken-org/capod/wiki/airpod-Keys") } + ).show() + true + } + super.onPreferencesCreated() } + private fun ByteArray.toHumanReadable(): String = joinToString("-") { "%02X".format(it) } + + private fun String.fromHumanReadable(): ByteArray = split("-").map { it.toInt(16).toByte() }.toByteArray() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { vm.bondedDevices.observe2 { devices -> mainDeviceAddressPref.setOnPreferenceClickListener { diff --git a/app/src/main/res/drawable/ic_key_24.xml b/app/src/main/res/drawable/ic_key_24.xml new file mode 100644 index 00000000..d5a4c6bd --- /dev/null +++ b/app/src/main/res/drawable/ic_key_24.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_key_outline_24.xml b/app/src/main/res/drawable/ic_key_outline_24.xml new file mode 100644 index 00000000..62243112 --- /dev/null +++ b/app/src/main/res/drawable/ic_key_outline_24.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f68d8af2..668fcf6c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,11 @@ Upgrade Check Close + Save + Guide + Continue + + E.g.: %s Upgrade CAPod Get additional features and support the developer. @@ -49,6 +54,13 @@ None Your device model The model of your main device. This helps the app recognize your device when it is not connected to your phone. + Identity Key + Your device\'s Identity Resolving Key (IRK), which helps CAPod identify it among nearby devices. + AirPods frequently change their Bluetooth address for privacy. The IRK helps CAPod recognize your device. You need one-time access to a MacBook. + Encryption Key + Your device\'s encryption key, which allows CAPod to retrieve detailed status information. + AirPods send a status message, part of which is encrypted. The encryption key allows CAPod to decrypt the full message. You need one-time access to a MacBook. + One pod mode Wearing both pods is not required, wearing a single pod is sufficient to trigger reactions. Show case popup @@ -134,6 +146,4 @@ CAPod has no ads and does not collect your data. You can upgrade to CAPod Pro to gain extra features and support development. - Continue - \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_general.xml b/app/src/main/res/xml/preferences_general.xml index 3ff44fda..ff5fe7a5 100644 --- a/app/src/main/res/xml/preferences_general.xml +++ b/app/src/main/res/xml/preferences_general.xml @@ -41,6 +41,18 @@ android:key="core.maindevice.model" android:summary="@string/settings_maindevice_model_description" android:title="@string/settings_maindevice_model_label" /> + + + +