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)
+
[](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:



+
## 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" />
+
+
+
+