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
@@ -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