Add core device profile infrastructure

- Implement DeviceProfile interface with sealed interface pattern
- Add AppleDeviceProfile for Apple devices with IRK support
- Create DeviceProfilesRepo for profile persistence with Moshi
- Add NameBasedPolyJsonAdapterFactory for polymorphic JSON serialization
- Update PodDevice.Meta to include profile reference
- Extend ApplePods with AppleMeta containing profile information
- Add utility extensions for profile-based device filtering

This establishes the foundation for device profile management system.
This commit is contained in:
darken
2025-09-29 13:36:46 +02:00
parent 1689153070
commit 0af469af07
11 changed files with 79 additions and 37 deletions
@@ -1,2 +1,16 @@
package eu.darken.capod.common.bluetooth
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
suspend fun Collection<BleScanResult>.onlyNewAndUnique(): List<BleScanResult> = this
.groupBy { it.address }
.values
.map { sameAdrDevs ->
// For each address we only want the newest result, upstream may batch data
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log( VERBOSE) { "Discarding stale results: $it" }
}
newest
}
@@ -1,11 +1,17 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package eu.darken.sdmse.common.serialization
package eu.darken.capod.common.serialization
import com.squareup.moshi.*
import java.lang.reflect.Type
import java.util.*
import javax.annotation.CheckReturnValue
import kotlin.apply
import kotlin.collections.isNotEmpty
import kotlin.collections.plus
import kotlin.collections.toTypedArray
import kotlin.io.use
import kotlin.jvm.javaClass
class NameBasedPolyJsonAdapterFactory<T> internal constructor(
val baseType: Class<T>,
@@ -5,6 +5,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.devices.core.DeviceProfile
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@@ -13,9 +14,10 @@ class SerializationModule {
@Provides
@Singleton
fun moshi(): Moshi = Moshi.Builder()
.add(JavaInstantAdapter())
.add(ByteArrayAdapter())
.build()
fun moshi(): Moshi = Moshi.Builder().apply {
add(JavaInstantAdapter())
add(ByteArrayAdapter())
add(DeviceProfile.MOSHI_FACTORY)
}.build()
}
@@ -1,3 +1,22 @@
package eu.darken.capod.devices.core
data class AppleDeviceProfile()
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey
import kotlinx.parcelize.Parcelize
import java.util.UUID
@Parcelize
@JsonClass(generateAdapter = true)
data class AppleDeviceProfile(
@Json(name = "id") override val id: ProfileId = UUID.randomUUID().toString(),
@Json(name = "name") override val name: String,
@Json(name = "minimumSignalQuality") override val minimumSignalQuality: Float = 0.20f,
@Json(name = "isEnabled") override val isEnabled: Boolean = true,
@Json(name = "model") override val model: PodDevice.Model = PodDevice.Model.UNKNOWN,
@Json(name = "priority") override val priority: Int = 0,
@Json(name = "identityKey") val identityKey: IdentityResolvingKey? = null,
@Json(name = "encryptionKey") val encryptionKey: ProximityEncryptionKey? = null,
) : DeviceProfile
@@ -1,23 +1,21 @@
package eu.darken.capod.devices.core
import android.os.Parcelable
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.serialization.NameBasedPolyJsonAdapterFactory
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey
import kotlinx.parcelize.Parcelize
import java.util.UUID
import kotlin.jvm.java
@Parcelize
@JsonClass(generateAdapter = true)
data class DeviceProfile(
val id: String = UUID.randomUUID().toString(),
val name: String,
val address: BluetoothAddress? = null,
val model: PodDevice.Model = PodDevice.Model.UNKNOWN,
val identityKey: IdentityResolvingKey? = null,
val encryptionKey: ProximityEncryptionKey? = null,
val minimumSignalQuality: Float = 0.20f,
val isEnabled: Boolean = true
) : Parcelable
sealed interface DeviceProfile : Parcelable {
val id: ProfileId
val name: String
val minimumSignalQuality: Float
val isEnabled: Boolean
val model: PodDevice.Model
val priority: Int
companion object {
val MOSHI_FACTORY: NameBasedPolyJsonAdapterFactory<DeviceProfile> =
NameBasedPolyJsonAdapterFactory.of(DeviceProfile::class.java)
.withSubtype(AppleDeviceProfile::class.java, "identityKey")
}
}
@@ -1,2 +1,3 @@
package eu.darken.capod.devices.core
typealias ProfileId = String
@@ -15,7 +15,7 @@ import javax.inject.Singleton
@Singleton
class DeviceProfilesRepo @Inject constructor(
@param:ApplicationContext private val context: Context,
@ApplicationContext private val context: Context,
private val moshi: Moshi,
) {
@@ -77,10 +77,6 @@ class DeviceProfilesRepo @Inject constructor(
saveProfiles()
}
fun getProfile(profileId: String): DeviceProfile? {
return _profiles.value.find { it.id == profileId }
}
companion object {
private const val KEY_PROFILES = "profiles"
}
@@ -21,10 +21,6 @@ class DeviceProfileVH(parent: ViewGroup) :
deviceName.text = profile.name
deviceDetails.text = buildString {
profile.address?.let {
append(it.toString())
append("")
}
append(profile.model.name)
}
@@ -6,6 +6,6 @@ import kotlinx.coroutines.flow.map
import kotlin.collections.firstOrNull
fun PodMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
.map { devices -> devices.filter { it.profile != null } }
.map { devices -> devices.filter { it.meta.profile != null } }
fun PodMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
@@ -9,6 +9,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.devices.core.DeviceProfile
import java.time.Duration
import java.time.Instant
import java.util.UUID
@@ -58,6 +59,12 @@ interface PodDevice {
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
}
interface Meta {
val profile: DeviceProfile?
}
val meta: Meta
fun getLabel(context: Context): String = model.label
@get:DrawableRes
@@ -2,13 +2,13 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.devices.core.AppleDeviceProfile
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
interface ApplePods : PodDevice {
val payload: ProximityPayload
val flags: Flags
// We start counting at the airpods prefix byte
val pubPrefix: UByte
@@ -56,7 +56,10 @@ interface ApplePods : PodDevice {
)
}
data class Flags(
data class AppleMeta(
val isIRKMatch: Boolean,
)
override val profile: AppleDeviceProfile?,
) : PodDevice.Meta
override val meta: AppleMeta
}