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
+11
View File
@@ -194,3 +194,14 @@ jobs:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
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
includeInBundle = false
}
proguardFiles("proguard-rules-foss.pro")
}
create("gplay") {
dimension = "version"
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 { *; }
-dontobfuscate
# 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.
@@ -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.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError
import eu.darken.capod.common.debug.logging.logTag
class GplayServiceUnavailableException(cause: Throwable) :
BillingException("Google Play services are unavailable.", cause), HasLocalizedError {
@@ -48,10 +49,11 @@ class GplayServiceUnavailableException(cause: Throwable) :
)
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 {
private const val GPLAY_PKG = "com.android.vending"
private val TAG = logTag("Upgrade", "Gplay", "ServiceUnavailable")
}
}
@@ -1,10 +1,13 @@
import android.content.BroadcastReceiver
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
fun BroadcastReceiver.PendingResult.finish2(): Boolean = try {
finish()
true
} catch (e: IllegalStateException) {
log { "BroadcastReceiver.PendingResult.finish() failed: $e" }
log(TAG) { "BroadcastReceiver.PendingResult.finish() failed: $e" }
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.log
import javax.inject.Inject
import eu.darken.capod.common.debug.logging.logTag
@Reusable
class WebpageTool @Inject constructor(
@@ -25,9 +26,12 @@ class WebpageTool @Inject constructor(
context.startActivity(intent)
true
} catch (e: Exception) {
log(ERROR) { "Failed to launch: ${e.asLog()}" }
log(TAG, ERROR) { "Failed to launch: ${e.asLog()}" }
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.log
import eu.darken.capod.common.debug.logging.logTag
suspend fun Collection<BleScanResult>.onlyNewAndUnique(): List<BleScanResult> = this
.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
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
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
}
private val TAG = logTag("Bluetooth", "Scanner")
@@ -6,6 +6,7 @@ import android.bluetooth.le.ScanResult
import android.os.ParcelUuid
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
return uuids?.contains(uuid) ?: false
@@ -21,6 +22,8 @@ fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
fun ScanFilter.matchesSafe(scanResult: ScanResult): Boolean = try {
matches(scanResult)
} catch (e: NullPointerException) {
log { "AOSP error: ${e.asLog()}" }
log(TAG) { "AOSP error: ${e.asLog()}" }
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.map
import kotlinx.coroutines.runBlocking
import eu.darken.capod.common.debug.logging.logTag
class DataStoreValue<T>(
private val dataStore: DataStore<Preferences>,
@@ -45,10 +46,14 @@ class DataStoreValue<T>(
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")
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()
@@ -34,6 +34,8 @@ object Logging {
)
}
private val TAG = logTag("Logging")
private val internalLoggers = mutableListOf<Logger>()
val loggers: List<Logger>
@@ -46,11 +48,11 @@ object Logging {
fun install(logger: Logger) {
synchronized(internalLoggers) { internalLoggers.add(logger) }
log { "Was installed $logger" }
log(TAG) { "Was installed $logger" }
}
fun remove(logger: Logger) {
log { "Removing: $logger" }
log(TAG) { "Removing: $logger" }
synchronized(internalLoggers) { internalLoggers.remove(logger) }
}
@@ -77,26 +79,11 @@ object Logging {
}
fun clearAll() {
log { "Clearing all loggers" }
log(TAG) { "Clearing all loggers" }
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(
tag: String,
priority: Logging.Priority = Logging.Priority.DEBUG,
@@ -135,15 +122,3 @@ fun Throwable.asLogSummary(): String {
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.log
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
@@ -46,7 +47,7 @@ class SingleLiveEvent<T> : MutableLiveData<T>() {
@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
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
@@ -73,4 +74,8 @@ class SingleLiveEvent<T> : MutableLiveData<T>() {
fun call() {
value = null
}
companion object {
private val TAG = logTag("SingleLiveEvent")
}
}
@@ -42,7 +42,7 @@ abstract class ViewModel2(
if (this is ErrorEventSource) {
return CoroutineExceptionHandler { _, ex ->
log(WARN) { "Error during launch: ${ex.asLog()}" }
log(TAG, WARN) { "Error during launch: ${ex.asLog()}" }
errorEvents.postValue(ex)
}
}
@@ -34,7 +34,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
log(TAG, WARN) { "Event without Bluetooth device association." }
return
} else {
log { "Event related to $bluetoothDevice" }
log(TAG) { "Event related to $bluetoothDevice" }
}
val supportedFeatures = try {
ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
@@ -47,7 +47,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
log(TAG) { "Device has no features we support." }
return
} 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) {
@@ -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.DualBlePodSnapshot
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,
HasDualMicrophone, HasAppleColor {
@@ -38,7 +39,7 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
log(TAG) { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
@@ -59,7 +60,7 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
log(TAG) { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
@@ -131,7 +132,7 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
return when (val value = pubCaseBattery.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
log(TAG) { "Case: Above 100% battery: $value" }
1.0f
} else {
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.lowerNibble
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
@@ -17,7 +18,7 @@ interface SingleApplePods : ApplePods, SingleBlePodSnapshot, HasAppleColor {
return when (val value = pubPodsBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Headset above 100% battery: $value" }
log(TAG) { "Headset above 100% battery: $value" }
1.0f
} else {
(value / 10f)
@@ -25,3 +26,5 @@ interface SingleApplePods : ApplePods, SingleBlePodSnapshot, HasAppleColor {
}
}
}
private val TAG = logTag("Pod", "Apple", "SingleApplePods")
@@ -14,7 +14,7 @@ object ProximityPairing {
class Decoder @Inject constructor() {
fun decode(message: ContinuityProtocol.Message): ProximityMessage? {
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
}
@@ -141,7 +141,7 @@ class DeviceProfilesRepo @Inject constructor(
if (addFirst) add(0, profile) else add(profile)
}.toList()
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 {
@@ -152,7 +152,7 @@ class DeviceProfilesRepo @Inject constructor(
if (it.id == profile.id) profile else it
}
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)
val updatedProfiles = currentContainer.profiles.map { if (it.id == id) updated else it }
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 {
val currentContainer = settings.profiles.valueBlocking
val updatedProfiles = currentContainer.profiles.filter { it.id != profileId }
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)
batteryDrainStore.delete(profileId)
}
@@ -196,7 +196,7 @@ class DeviceProfilesRepo @Inject constructor(
val byId = current.associateBy { it.id }
val reordered = orderedIds.map { byId.getValue(it) }
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 {