mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -1,6 +1,7 @@
|
||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
|
||||
|
||||
# Companion App for AirPods (CAPod)
|
||||
|
||||
[](https://github.com/d4rken-org/capod/releases/latest)
|
||||
[](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
||||
[](https://crowdin.com/project/capod)
|
||||
@@ -56,7 +57,6 @@ Currently supported models:
|
||||
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [](https://f-droid.org/en/packages/eu.darken.capod/) |
|
||||
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user