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.
This commit is contained in:
darken
2025-06-06 06:03:41 +02:00
committed by Matthias Urhahn
parent eb8a9b3b46
commit e8481cc8cd
11 changed files with 169 additions and 5 deletions
+4 -1
View File
@@ -1,6 +1,7 @@
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
# 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:
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
## 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
@@ -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 {
@@ -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)
}
@@ -15,6 +15,7 @@ class SerializationModule {
@Singleton
fun moshi(): Moshi = Moshi.Builder()
.add(JavaInstantAdapter())
.add(ByteArrayAdapter())
.build()
}
@@ -35,6 +35,9 @@ class GeneralSettings @Inject constructor(
val mainDeviceAddress = preferences.createFlowPreference<String?>("core.maindevice.address", null)
val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi)
val mainDeviceIdentityKey = preferences.createFlowPreference<ByteArray?>("core.maindevice.identitykey", null, moshi)
val mainDeviceEncryptionKey =
preferences.createFlowPreference<ByteArray?>("core.maindevice.encryptionkey", null, moshi)
val isOffloadedFilteringDisabled = preferences.createFlowPreference(
"core.compat.offloaded.filtering.disabled",
@@ -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,
;
}
}
@@ -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<ListPreference>(generalSettings.scannerMode.key)!! }
private val mainDeviceAddressPref by lazy { findPreference<Preference>(generalSettings.mainDeviceAddress.key)!! }
private val mainDeviceModelPref by lazy { findPreference<Preference>(generalSettings.mainDeviceModel.key)!! }
private val mainDeviceIdentityKeyPref by lazy { findPreference<Preference>(generalSettings.mainDeviceIdentityKey.key)!! }
private val mainDeviceEncryptionKeyPref by lazy { findPreference<Preference>(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 {
+11
View File
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M7 14C5.9 14 5 13.1 5 12S5.9 10 7 10 9 10.9 9 12 8.1 14 7 14M12.6 10C11.8 7.7 9.6 6 7 6C3.7 6 1 8.7 1 12S3.7 18 7 18C9.6 18 11.8 16.3 12.6 14H16V18H20V14H23V10H12.6Z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M21 18H15V15H13.3C12.2 17.4 9.7 19 7 19C3.1 19 0 15.9 0 12S3.1 5 7 5C9.7 5 12.2 6.6 13.3 9H24V15H21V18M17 16H19V13H22V11H11.9L11.7 10.3C11 8.3 9.1 7 7 7C4.2 7 2 9.2 2 12S4.2 17 7 17C9.1 17 11 15.7 11.7 13.7L11.9 13H17V16M7 15C5.3 15 4 13.7 4 12S5.3 9 7 9 10 10.3 10 12 8.7 15 7 15M7 11C6.4 11 6 11.4 6 12S6.4 13 7 13 8 12.6 8 12 7.6 11 7 11Z" />
</vector>
+12 -2
View File
@@ -7,6 +7,11 @@
<string name="general_upgrade_action">Upgrade</string>
<string name="general_check_action">Check</string>
<string name="general_close_action">Close</string>
<string name="general_save_action">Save</string>
<string name="general_guide_action">Guide</string>
<string name="general_continue_action">Continue</string>
<string name="general_example_label">E.g.: %s</string>
<string name="upgrade_capod_label">Upgrade CAPod</string>
<string name="upgrade_capod_description">Get additional features and support the developer.</string>
@@ -49,6 +54,13 @@
<string name="settings_maindevice_address_none">None</string>
<string name="settings_maindevice_model_label">Your device model</string>
<string name="settings_maindevice_model_description">The model of your main device. This helps the app recognize your device when it is not connected to your phone.</string>
<string name="settings_maindevice_identitykey_label">Identity Key</string>
<string name="settings_maindevice_identitykey_description">Your device\'s Identity Resolving Key (IRK), which helps CAPod identify it among nearby devices.</string>
<string name="settings_maindevice_identitykey_explanation">AirPods frequently change their Bluetooth address for privacy. The IRK helps CAPod recognize your device. You need one-time access to a MacBook.</string>
<string name="settings_maindevice_encryptionkey_label">Encryption Key</string>
<string name="settings_maindevice_encryptionkey_description">Your device\'s encryption key, which allows CAPod to retrieve detailed status information.</string>
<string name="settings_maindevice_encryptionkey_explanation">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.</string>
<string name="settings_onepod_mode_label">One pod mode</string>
<string name="settings_onepod_mode_description">Wearing both pods is not required, wearing a single pod is sufficient to trigger reactions.</string>
<string name="settings_popup_caseopen_label">Show case popup</string>
@@ -134,6 +146,4 @@
<string name="onboarding_body3">CAPod has no ads and does not collect your data.</string>
<string name="onboarding_body4">You can upgrade to CAPod Pro to gain extra features and support development.</string>
<string name="general_continue_action">Continue</string>
</resources>
@@ -41,6 +41,18 @@
android:key="core.maindevice.model"
android:summary="@string/settings_maindevice_model_description"
android:title="@string/settings_maindevice_model_label" />
<Preference
android:icon="@drawable/ic_key_outline_24"
android:key="core.maindevice.identitykey"
android:summary="@string/settings_maindevice_identitykey_description"
android:title="@string/settings_maindevice_identitykey_label" />
<Preference
android:icon="@drawable/ic_key_24"
android:key="core.maindevice.encryptionkey"
android:summary="@string/settings_maindevice_encryptionkey_description"
android:title="@string/settings_maindevice_encryptionkey_label" />
</PreferenceCategory>
<PreferenceCategory