Compare commits

...
2 Commits
Author SHA1 Message Date
darken 43740d8b2e chore(build): Enable R8 obfuscation for the Google Play flavor
Google Play scores the obfuscation share of uploaded bundles and flags
listings below its threshold ("App optimisation is below our threshold",
fix by Feb 2027). Minify and shrink were already on; the shared
-dontobfuscate was the only thing keeping the score at 0%.

-dontobfuscate moves to a FOSS-only rule file; the Play rule file keeps
SourceFile/LineNumberTable for retracing and pins names only where they
reach users or logs: AapSetting/AapCommand subclasses (session logs),
ViewModel1 subclasses (log tag), Throwables (error dialog label). All
reflective targets (BuildConfig, ArtMirror, NeverCall, InvokeStub,
AncModeActionCallback) already carry @Keep or explicit keeps and are
identity-mapped in the gplayRelease mapping. The release workflow
archives the mapping next to the Play upload.
2026-09-06 10:41:37 +02:00
darken 83849efe80 refactor(logging): Replace receiver-derived log tags with explicit tags
The Any.log overload built its tag from this::class.java.name, which turns
into CAP:a once the class is obfuscated. Every call site now passes a
logTag(...) constant and the overload is gone so it cannot be reintroduced.
2026-09-06 10:41:37 +02:00
19 changed files with 97 additions and 55 deletions
+12 -1
View File
@@ -193,4 +193,15 @@ jobs:
env: env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
# The Play flavor is obfuscated; the bundle embeds this mapping, but keep it reachable
# for retracing user-submitted logs without going through Play Console.
- name: Archive R8 mapping
if: always() && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: r8-mapping-${{ github.ref_name }}
path: app/build/outputs/mapping/gplay*/mapping.txt
if-no-files-found: warn
overwrite: true
+2
View File
@@ -55,10 +55,12 @@ android {
includeInApk = false includeInApk = false
includeInBundle = false includeInBundle = false
} }
proguardFiles("proguard-rules-foss.pro")
} }
create("gplay") { create("gplay") {
dimension = "version" dimension = "version"
signingConfig = signingConfigs["releaseGplay"] signingConfig = signingConfigs["releaseGplay"]
proguardFiles("proguard-rules-gplay.pro")
} }
} }
+3
View File
@@ -0,0 +1,3 @@
# The FOSS build is open source and ships on GitHub/F-Droid; readable stack traces are worth more
# than smaller identifiers there. Only the Google Play flavor obfuscates (proguard-rules-gplay.pro).
-dontobfuscate
+21
View File
@@ -0,0 +1,21 @@
# Google Play scores the obfuscation share of every uploaded bundle and restricts listings that
# stay below its threshold, so the Play flavor obfuscates. The FOSS flavor does not
# (proguard-rules-foss.pro).
# Keep stack traces retraceable with the mapping file that the bundle embeds.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# AAP session logs identify settings and commands by their simple class name
# (AapSessionEngine, AapAncController, AapOutboundController). Names only; unused classes are
# still removed.
-keepnames class eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
-keepnames class * extends eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
-keepnames class eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
-keepnames class * extends eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
# ViewModel1 derives its log tag from the subclass name (VM:OverviewViewModel).
-keepnames class * extends eu.darken.capod.common.uix.ViewModel1
# Error dialogs and log summaries show exceptions by class name (LocalizedError, asLogSummary).
-keepnames class * extends java.lang.Throwable
-1
View File
@@ -1,5 +1,4 @@
-keep class eu.darken.capod.BuildConfig { *; } -keep class eu.darken.capod.BuildConfig { *; }
-dontobfuscate
# work-runtime 2.7.1 (pulled by Glance) uses Class.newInstance() reflection throughout. # work-runtime 2.7.1 (pulled by Glance) uses Class.newInstance() reflection throughout.
# R8 full mode strips no-arg constructors not reachable by static analysis. # R8 full mode strips no-arg constructors not reachable by static analysis.
@@ -10,6 +10,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.HasLocalizedError import eu.darken.capod.common.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError import eu.darken.capod.common.error.LocalizedError
import eu.darken.capod.common.debug.logging.logTag
class GplayServiceUnavailableException(cause: Throwable) : class GplayServiceUnavailableException(cause: Throwable) :
BillingException("Google Play services are unavailable.", cause), HasLocalizedError { BillingException("Google Play services are unavailable.", cause), HasLocalizedError {
@@ -48,10 +49,11 @@ class GplayServiceUnavailableException(cause: Throwable) :
) )
private fun onLaunchFailed(e: Exception) { private fun onLaunchFailed(e: Exception) {
log(ERROR) { "Can't launch settings intent for Google Play: $e" } log(TAG, ERROR) { "Can't launch settings intent for Google Play: $e" }
} }
companion object { companion object {
private const val GPLAY_PKG = "com.android.vending" private const val GPLAY_PKG = "com.android.vending"
private val TAG = logTag("Upgrade", "Gplay", "ServiceUnavailable")
} }
} }
@@ -1,10 +1,13 @@
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
fun BroadcastReceiver.PendingResult.finish2(): Boolean = try { fun BroadcastReceiver.PendingResult.finish2(): Boolean = try {
finish() finish()
true true
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
log { "BroadcastReceiver.PendingResult.finish() failed: $e" } log(TAG) { "BroadcastReceiver.PendingResult.finish() failed: $e" }
false false
} }
private val TAG = logTag("Common", "BroadcastReceiver")
@@ -9,6 +9,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import javax.inject.Inject import javax.inject.Inject
import eu.darken.capod.common.debug.logging.logTag
@Reusable @Reusable
class WebpageTool @Inject constructor( class WebpageTool @Inject constructor(
@@ -25,9 +26,12 @@ class WebpageTool @Inject constructor(
context.startActivity(intent) context.startActivity(intent)
true true
} catch (e: Exception) { } catch (e: Exception) {
log(ERROR) { "Failed to launch: ${e.asLog()}" } log(TAG, ERROR) { "Failed to launch: ${e.asLog()}" }
false false
} }
} }
companion object {
private val TAG = logTag("WebpageTool")
}
} }
@@ -2,6 +2,7 @@ package eu.darken.capod.common.bluetooth
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
suspend fun Collection<BleScanResult>.onlyNewAndUnique(): List<BleScanResult> = this suspend fun Collection<BleScanResult>.onlyNewAndUnique(): List<BleScanResult> = this
.groupBy { it.address } .groupBy { it.address }
@@ -10,7 +11,9 @@ suspend fun Collection<BleScanResult>.onlyNewAndUnique(): List<BleScanResult> =
// For each address we only want the newest result, upstream may batch data // For each address we only want the newest result, upstream may batch data
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!! val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
sameAdrDevs.minus(newest).let { sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log(VERBOSE) { "Discarding stale results: ${it.logSummary()}" } if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: ${it.logSummary()}" }
} }
newest newest
} }
private val TAG = logTag("Bluetooth", "Scanner")
@@ -6,6 +6,7 @@ import android.bluetooth.le.ScanResult
import android.os.ParcelUuid import android.os.ParcelUuid
import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean { fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
return uuids?.contains(uuid) ?: false return uuids?.contains(uuid) ?: false
@@ -21,6 +22,8 @@ fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
fun ScanFilter.matchesSafe(scanResult: ScanResult): Boolean = try { fun ScanFilter.matchesSafe(scanResult: ScanResult): Boolean = try {
matches(scanResult) matches(scanResult)
} catch (e: NullPointerException) { } catch (e: NullPointerException) {
log { "AOSP error: ${e.asLog()}" } log(TAG) { "AOSP error: ${e.asLog()}" }
false false
} }
private val TAG = logTag("Bluetooth", "Extensions")
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import eu.darken.capod.common.debug.logging.logTag
class DataStoreValue<T>( class DataStoreValue<T>(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
@@ -45,10 +46,14 @@ class DataStoreValue<T>(
prefs[key as Preferences.Key<Any>] = raw prefs[key as Preferences.Key<Any>] = raw
} }
} }
log(VERBOSE) { "DataStoreValue($keyName) updated from $old to $new" } log(TAG, VERBOSE) { "DataStoreValue($keyName) updated from $old to $new" }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return Updated(old as T, new as T) return Updated(old as T, new as T)
} }
companion object {
private val TAG = logTag("DataStore", "Value")
}
} }
suspend fun <T> DataStoreValue<T>.value(): T = flow.first() suspend fun <T> DataStoreValue<T>.value(): T = flow.first()
@@ -34,6 +34,8 @@ object Logging {
) )
} }
private val TAG = logTag("Logging")
private val internalLoggers = mutableListOf<Logger>() private val internalLoggers = mutableListOf<Logger>()
val loggers: List<Logger> val loggers: List<Logger>
@@ -46,11 +48,11 @@ object Logging {
fun install(logger: Logger) { fun install(logger: Logger) {
synchronized(internalLoggers) { internalLoggers.add(logger) } synchronized(internalLoggers) { internalLoggers.add(logger) }
log { "Was installed $logger" } log(TAG) { "Was installed $logger" }
} }
fun remove(logger: Logger) { fun remove(logger: Logger) {
log { "Removing: $logger" } log(TAG) { "Removing: $logger" }
synchronized(internalLoggers) { internalLoggers.remove(logger) } synchronized(internalLoggers) { internalLoggers.remove(logger) }
} }
@@ -77,26 +79,11 @@ object Logging {
} }
fun clearAll() { fun clearAll() {
log { "Clearing all loggers" } log(TAG) { "Clearing all loggers" }
synchronized(internalLoggers) { internalLoggers.clear() } synchronized(internalLoggers) { internalLoggers.clear() }
} }
} }
inline fun Any.log(
priority: Logging.Priority = Logging.Priority.DEBUG,
metaData: Map<String, Any>? = null,
message: () -> String,
) {
if (Logging.hasReceivers) {
Logging.logInternal(
tag = "CAP:${logTagViaCallSite()}",
priority = priority,
metaData = metaData,
message = message(),
)
}
}
inline fun log( inline fun log(
tag: String, tag: String,
priority: Logging.Priority = Logging.Priority.DEBUG, priority: Logging.Priority = Logging.Priority.DEBUG,
@@ -135,15 +122,3 @@ fun Throwable.asLogSummary(): String {
private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull() private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull()
@PublishedApi
internal fun Any.logTagViaCallSite(): String {
val javaClass = this::class.java
val fullClassName = javaClass.name
val outerClassName = fullClassName.substringBefore('$')
val simplerOuterClassName = outerClassName.substringAfterLast('.')
return if (simplerOuterClassName.isEmpty()) {
fullClassName
} else {
simplerOuterClassName.removeSuffix("Kt")
}
}
@@ -25,6 +25,7 @@ import androidx.lifecycle.Observer
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import eu.darken.capod.common.debug.logging.logTag
/** /**
* A lifecycle-aware observable that sends only new updates after subscription, used for events like * A lifecycle-aware observable that sends only new updates after subscription, used for events like
@@ -46,7 +47,7 @@ class SingleLiveEvent<T> : MutableLiveData<T>() {
@MainThread @MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) { override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
if (hasActiveObservers()) { if (hasActiveObservers()) {
log(WARN) { "Multiple observers registered but only one will be notified of changes." } log(TAG, WARN) { "Multiple observers registered but only one will be notified of changes." }
} }
// Observe the internal MutableLiveData // Observe the internal MutableLiveData
@@ -73,4 +74,8 @@ class SingleLiveEvent<T> : MutableLiveData<T>() {
fun call() { fun call() {
value = null value = null
} }
companion object {
private val TAG = logTag("SingleLiveEvent")
}
} }
@@ -42,7 +42,7 @@ abstract class ViewModel2(
if (this is ErrorEventSource) { if (this is ErrorEventSource) {
return CoroutineExceptionHandler { _, ex -> return CoroutineExceptionHandler { _, ex ->
log(WARN) { "Error during launch: ${ex.asLog()}" } log(TAG, WARN) { "Error during launch: ${ex.asLog()}" }
errorEvents.postValue(ex) errorEvents.postValue(ex)
} }
} }
@@ -34,7 +34,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
log(TAG, WARN) { "Event without Bluetooth device association." } log(TAG, WARN) { "Event without Bluetooth device association." }
return return
} else { } else {
log { "Event related to $bluetoothDevice" } log(TAG) { "Event related to $bluetoothDevice" }
} }
val supportedFeatures = try { val supportedFeatures = try {
ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) } ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
@@ -47,7 +47,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
log(TAG) { "Device has no features we support." } log(TAG) { "Device has no features we support." }
return return
} else { } else {
log { "Device has the following we features we support $supportedFeatures" } log(TAG) { "Device has the following we features we support $supportedFeatures" }
} }
when (intent.action) { when (intent.action) {
@@ -8,6 +8,7 @@ import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_DECILE
import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_PERCENT import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_PERCENT
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot.Pod import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot.Pod
import eu.darken.capod.common.debug.logging.logTag
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot, HasEarDetectionDual, HasCase, interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot, HasEarDetectionDual, HasCase,
HasDualMicrophone, HasAppleColor { HasDualMicrophone, HasAppleColor {
@@ -38,7 +39,7 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
return when (value) { return when (value) {
15 -> null 15 -> null
else -> if (value > 10) { else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" } log(TAG) { "Left pod: Above 100% battery: $value" }
1.0f 1.0f
} else { } else {
(value / 10f) (value / 10f)
@@ -59,7 +60,7 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
return when (value) { return when (value) {
15 -> null 15 -> null
else -> if (value > 10) { else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" } log(TAG) { "Right pod: Above 100% battery: $value" }
1.0f 1.0f
} else { } else {
value / 10f value / 10f
@@ -131,7 +132,7 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
return when (val value = pubCaseBattery.toInt()) { return when (val value = pubCaseBattery.toInt()) {
15 -> null 15 -> null
else -> if (value > 10) { else -> if (value > 10) {
log { "Case: Above 100% battery: $value" } log(TAG) { "Case: Above 100% battery: $value" }
1.0f 1.0f
} else { } else {
value / 10f value / 10f
@@ -206,3 +207,5 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
} }
} }
private val TAG = logTag("Pod", "Apple", "DualApplePods")
@@ -3,6 +3,7 @@ package eu.darken.capod.pods.core.apple.ble.devices
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble import eu.darken.capod.common.lowerNibble
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.common.debug.logging.logTag
/** /**
* Devices that only present a single charge level, e.g. most Beats devices * Devices that only present a single charge level, e.g. most Beats devices
@@ -17,11 +18,13 @@ interface SingleApplePods : ApplePods, SingleBlePodSnapshot, HasAppleColor {
return when (val value = pubPodsBattery.lowerNibble.toInt()) { return when (val value = pubPodsBattery.lowerNibble.toInt()) {
15 -> null 15 -> null
else -> if (value > 10) { else -> if (value > 10) {
log { "Headset above 100% battery: $value" } log(TAG) { "Headset above 100% battery: $value" }
1.0f 1.0f
} else { } else {
(value / 10f) (value / 10f)
} }
} }
} }
} }
private val TAG = logTag("Pod", "Apple", "SingleApplePods")
@@ -14,7 +14,7 @@ object ProximityPairing {
class Decoder @Inject constructor() { class Decoder @Inject constructor() {
fun decode(message: ContinuityProtocol.Message): ProximityMessage? { fun decode(message: ContinuityProtocol.Message): ProximityMessage? {
if (message.type != CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING) { if (message.type != CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING) {
log { "Not a proximity pairing message: $this" } log(TAG) { "Not a proximity pairing message: $message" }
return null return null
} }
@@ -141,7 +141,7 @@ class DeviceProfilesRepo @Inject constructor(
if (addFirst) add(0, profile) else add(profile) if (addFirst) add(0, profile) else add(profile)
}.toList() }.toList()
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles) settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Added device profile: ${profile.label}" } log(TAG, VERBOSE) { "Added device profile: ${profile.label}" }
} }
suspend fun updateProfile(profile: DeviceProfile) = mutex.withLock { suspend fun updateProfile(profile: DeviceProfile) = mutex.withLock {
@@ -152,7 +152,7 @@ class DeviceProfilesRepo @Inject constructor(
if (it.id == profile.id) profile else it if (it.id == profile.id) profile else it
} }
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles) settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Updated device profile: ${profile.label}" } log(TAG, VERBOSE) { "Updated device profile: ${profile.label}" }
} }
/** /**
@@ -176,14 +176,14 @@ class DeviceProfilesRepo @Inject constructor(
checkAddressUniqueness(updated, otherProfiles) checkAddressUniqueness(updated, otherProfiles)
val updatedProfiles = currentContainer.profiles.map { if (it.id == id) updated else it } val updatedProfiles = currentContainer.profiles.map { if (it.id == id) updated else it }
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles) settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Updated apple device profile: ${updated.label}" } log(TAG, VERBOSE) { "Updated apple device profile: ${updated.label}" }
} }
suspend fun removeProfile(profileId: ProfileId) = mutex.withLock { suspend fun removeProfile(profileId: ProfileId) = mutex.withLock {
val currentContainer = settings.profiles.valueBlocking val currentContainer = settings.profiles.valueBlocking
val updatedProfiles = currentContainer.profiles.filter { it.id != profileId } val updatedProfiles = currentContainer.profiles.filter { it.id != profileId }
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles) settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Removed device profile with ID: $profileId" } log(TAG, VERBOSE) { "Removed device profile with ID: $profileId" }
deviceStateCache.delete(profileId) deviceStateCache.delete(profileId)
batteryDrainStore.delete(profileId) batteryDrainStore.delete(profileId)
} }
@@ -196,7 +196,7 @@ class DeviceProfilesRepo @Inject constructor(
val byId = current.associateBy { it.id } val byId = current.associateBy { it.id }
val reordered = orderedIds.map { byId.getValue(it) } val reordered = orderedIds.map { byId.getValue(it) }
settings.profiles.valueBlocking = DeviceProfilesContainer(reordered) settings.profiles.valueBlocking = DeviceProfilesContainer(reordered)
log(VERBOSE) { "Reordered ${reordered.size} device profiles by ID" } log(TAG, VERBOSE) { "Reordered ${reordered.size} device profiles by ID" }
} }
suspend fun clear() = mutex.withLock { suspend fun clear() = mutex.withLock {