dep updates, logging hardening, crypto storage modernization

- Replace EncryptedSharedPreferences/MasterKey with direct AndroidKeyStore
  AES-256-GCM via new SecureStringPreferences wrapper; remove dead
  androidx.security.crypto dependency
- Gate high-volume debug logs behind BuildConfig.DEBUG in ServerConnection
  and PrismServerClient; redact token/channelID in remaining logs;
  stop logging raw message payloads on deserialization failure
- Remove noisy onDestroy lifecycle log from MainActivity
- Bump activityCompose 1.12.4→1.13.0, uiTooling 1.10.4→1.10.5
- Bump ktlint plugin 14.1.0→14.2.0, pin ktlint engine to 1.8.0
- Auto-fix ktlint 1.8.0 when-block blank line violations
This commit is contained in:
Egor 2026-03-12 17:16:26 -07:00
parent 48ba01de18
commit d5fd3d2564
17 changed files with 239 additions and 133 deletions

View file

@ -1 +1 @@
1.0.0 1.0.1

View file

@ -11,6 +11,10 @@ detekt {
buildUponDefaultConfig = true buildUponDefaultConfig = true
} }
ktlint {
version.set("1.8.0")
}
android { android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
@ -108,6 +112,5 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.ui.tooling.preview.android) implementation(libs.androidx.ui.tooling.preview.android)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.security.crypto)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
} }

View file

@ -1,11 +1,8 @@
package app.lonecloud.prism package app.lonecloud.prism
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Base64 import android.util.Base64
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class EncryptionKeyStore(context: Context) { class EncryptionKeyStore(context: Context) {
data class EncryptionKeys( data class EncryptionKeys(
@ -14,18 +11,11 @@ class EncryptionKeyStore(context: Context) {
val p256dh: String val p256dh: String
) )
private val sharedPreferences: SharedPreferences = run { private val securePreferences = SecureStringPreferences(
val masterKey = MasterKey.Builder(context.applicationContext) context = context,
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) prefName = PREF_NAME,
.build() keyAlias = KEY_ALIAS
EncryptedSharedPreferences.create( )
context.applicationContext,
PREF_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun storeKeys( fun storeKeys(
channelId: String, channelId: String,
@ -33,17 +23,15 @@ class EncryptionKeyStore(context: Context) {
authSecret: ByteArray, authSecret: ByteArray,
publicKey: String publicKey: String
) { ) {
sharedPreferences.edit(commit = true) { securePreferences.putString(keyFor(channelId, KEY_PRIVATE), base64Encode(privateKey))
putString(keyFor(channelId, KEY_PRIVATE), base64Encode(privateKey)) securePreferences.putString(keyFor(channelId, KEY_AUTH), base64Encode(authSecret))
putString(keyFor(channelId, KEY_AUTH), base64Encode(authSecret)) securePreferences.putString(keyFor(channelId, KEY_PUBLIC), publicKey)
putString(keyFor(channelId, KEY_PUBLIC), publicKey)
}
} }
fun getKeys(channelId: String): EncryptionKeys? { fun getKeys(channelId: String): EncryptionKeys? {
val privateKeyB64 = sharedPreferences.getString(keyFor(channelId, KEY_PRIVATE), null) val privateKeyB64 = securePreferences.getString(keyFor(channelId, KEY_PRIVATE), null)
val authSecretB64 = sharedPreferences.getString(keyFor(channelId, KEY_AUTH), null) val authSecretB64 = securePreferences.getString(keyFor(channelId, KEY_AUTH), null)
val publicKey = sharedPreferences.getString(keyFor(channelId, KEY_PUBLIC), null) val publicKey = securePreferences.getString(keyFor(channelId, KEY_PUBLIC), null)
if (privateKeyB64 == null || authSecretB64 == null || publicKey == null) return null if (privateKeyB64 == null || authSecretB64 == null || publicKey == null) return null
@ -56,16 +44,14 @@ class EncryptionKeyStore(context: Context) {
} }
fun deleteKeys(channelId: String) { fun deleteKeys(channelId: String) {
sharedPreferences.edit(commit = true) { securePreferences.remove(keyFor(channelId, KEY_PRIVATE))
remove(keyFor(channelId, KEY_PRIVATE)) securePreferences.remove(keyFor(channelId, KEY_AUTH))
remove(keyFor(channelId, KEY_AUTH)) securePreferences.remove(keyFor(channelId, KEY_PUBLIC))
remove(keyFor(channelId, KEY_PUBLIC))
}
} }
fun hasKeys(channelId: String): Boolean = sharedPreferences.contains(keyFor(channelId, KEY_PRIVATE)) && fun hasKeys(channelId: String): Boolean = securePreferences.contains(keyFor(channelId, KEY_PRIVATE)) &&
sharedPreferences.contains(keyFor(channelId, KEY_AUTH)) && securePreferences.contains(keyFor(channelId, KEY_AUTH)) &&
sharedPreferences.contains(keyFor(channelId, KEY_PUBLIC)) securePreferences.contains(keyFor(channelId, KEY_PUBLIC))
private fun keyFor(channelId: String, suffix: String): String = "$PREF_PREFIX$channelId$suffix" private fun keyFor(channelId: String, suffix: String): String = "$PREF_PREFIX$channelId$suffix"
@ -75,6 +61,7 @@ class EncryptionKeyStore(context: Context) {
companion object { companion object {
private const val PREF_NAME = "WebPushEncryptionKeys" private const val PREF_NAME = "WebPushEncryptionKeys"
private const val KEY_ALIAS = "prism_webpush_encryption_keys"
private const val PREF_PREFIX = "channel_" private const val PREF_PREFIX = "channel_"
private const val KEY_PRIVATE = "_private" private const val KEY_PRIVATE = "_private"
private const val KEY_AUTH = "_auth" private const val KEY_AUTH = "_auth"

View file

@ -1,10 +1,7 @@
package app.lonecloud.prism package app.lonecloud.prism
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.unifiedpush.android.distributor.MigrationManager import org.unifiedpush.android.distributor.MigrationManager
import org.unifiedpush.android.distributor.Store import org.unifiedpush.android.distributor.Store
@ -12,18 +9,11 @@ class PrismPreferences(private val context: Context) :
Store(context, PREF_NAME), Store(context, PREF_NAME),
MigrationManager.MigrationStore { MigrationManager.MigrationStore {
private val securePreferences: SharedPreferences by lazy { private val securePreferences = SecureStringPreferences(
val masterKey = MasterKey.Builder(context.applicationContext) context = context,
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) prefName = SECURE_PREF_NAME,
.build() keyAlias = SECURE_PREF_KEY_ALIAS
EncryptedSharedPreferences.create( )
context.applicationContext,
SECURE_PREF_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
var uaid: String? var uaid: String?
get() = sharedPreferences get() = sharedPreferences
.getString(PREF_UAID, null) .getString(PREF_UAID, null)
@ -107,9 +97,7 @@ class PrismPreferences(private val context: Context) :
var prismApiKey: String? var prismApiKey: String?
get() = securePreferences.getString(PREF_PRISM_API_KEY, null) get() = securePreferences.getString(PREF_PRISM_API_KEY, null)
set(value) = securePreferences.edit { set(value) = securePreferences.putString(PREF_PRISM_API_KEY, value)
if (value == null) remove(PREF_PRISM_API_KEY) else putString(PREF_PRISM_API_KEY, value)
}
var introCompleted: Boolean var introCompleted: Boolean
get() = sharedPreferences get() = sharedPreferences
@ -169,13 +157,13 @@ class PrismPreferences(private val context: Context) :
} }
fun setVapidPrivateKey(connectorToken: String, privateKey: String) { fun setVapidPrivateKey(connectorToken: String, privateKey: String) {
securePreferences.edit(commit = true) { putString("vapid_private_$connectorToken", privateKey) } securePreferences.putString("vapid_private_$connectorToken", privateKey)
} }
fun getVapidPrivateKey(connectorToken: String): String? = securePreferences.getString("vapid_private_$connectorToken", null) fun getVapidPrivateKey(connectorToken: String): String? = securePreferences.getString("vapid_private_$connectorToken", null)
fun removeVapidPrivateKey(connectorToken: String) { fun removeVapidPrivateKey(connectorToken: String) {
securePreferences.edit(commit = true) { remove("vapid_private_$connectorToken") } securePreferences.remove("vapid_private_$connectorToken")
} }
fun addPendingChannelDeletion(channelId: String) { fun addPendingChannelDeletion(channelId: String) {
@ -225,6 +213,7 @@ class PrismPreferences(private val context: Context) :
companion object { companion object {
internal const val PREF_NAME = "Prism" internal const val PREF_NAME = "Prism"
private const val SECURE_PREF_NAME = "PrismSecure" private const val SECURE_PREF_NAME = "PrismSecure"
private const val SECURE_PREF_KEY_ALIAS = "prism_secure_preferences"
private const val PREF_UAID = "uaid" private const val PREF_UAID = "uaid"
private const val PREF_API_URL = "api_url" private const val PREF_API_URL = "api_url"
private const val PREF_FALLBACK_INTRO_SHOWN = "fallback_intro_shown" private const val PREF_FALLBACK_INTRO_SHOWN = "fallback_intro_shown"

View file

@ -42,7 +42,7 @@ object PrismServerClient {
val config = resolveServerConfig(context) val config = resolveServerConfig(context)
if (config == null) { if (config == null) {
val error = "Prism server not configured" val error = "Prism server not configured"
Log.d(TAG, "$error, skipping registration") debugLog { "$error, skipping registration" }
onError(error) onError(error)
return return
} }
@ -62,23 +62,22 @@ object PrismServerClient {
val existingSubscriptionId = store.getSubscriptionId(registration.connectorToken) val existingSubscriptionId = store.getSubscriptionId(registration.connectorToken)
?: getSubscriptionIdFromDb(context, registration.connectorToken)?.also { ?: getSubscriptionIdFromDb(context, registration.connectorToken)?.also {
store.setSubscriptionId(registration.connectorToken, it) store.setSubscriptionId(registration.connectorToken, it)
Log.d(TAG, "registerApp: restored subscriptionId for token=${redactIdentifier(registration.connectorToken)}") debugLog { "registerApp: restored subscriptionId for token=${redactIdentifier(registration.connectorToken)}" }
} }
val knownEndpoint = store.getRegisteredEndpoint(registration.connectorToken) val knownEndpoint = store.getRegisteredEndpoint(registration.connectorToken)
?: getEndpointFromDb(context, registration.connectorToken)?.also { ?: getEndpointFromDb(context, registration.connectorToken)?.also {
store.setRegisteredEndpoint(registration.connectorToken, it) store.setRegisteredEndpoint(registration.connectorToken, it)
Log.d(TAG, "registerApp: restored endpoint for token=${redactIdentifier(registration.connectorToken)}") debugLog { "registerApp: restored endpoint for token=${redactIdentifier(registration.connectorToken)}" }
} }
Log.d( debugLog {
TAG,
"registerApp decision: token=${redactIdentifier( "registerApp decision: token=${redactIdentifier(
registration.connectorToken registration.connectorToken
)} existingSub=${!existingSubscriptionId.isNullOrBlank()} endpointMatch=${knownEndpoint == registration.webpushUrl}" )} existingSub=${!existingSubscriptionId.isNullOrBlank()} endpointMatch=${knownEndpoint == registration.webpushUrl}"
) }
if (!existingSubscriptionId.isNullOrBlank() && knownEndpoint == registration.webpushUrl) { if (!existingSubscriptionId.isNullOrBlank() && knownEndpoint == registration.webpushUrl) {
Log.d(TAG, "registerApp: skipping duplicate create for ${registration.appName} (endpoint unchanged)") debugLog { "registerApp: skipping duplicate create for ${registration.appName} (endpoint unchanged)" }
withContext(Dispatchers.Main) { onSuccess() } withContext(Dispatchers.Main) { onSuccess() }
return@launch return@launch
} }
@ -91,7 +90,7 @@ object PrismServerClient {
postSubscription(serverUrl, apiKey, store, registration, requireNotNull(registration.vapidPrivateKey)) postSubscription(serverUrl, apiKey, store, registration, requireNotNull(registration.vapidPrivateKey))
.onSuccess { .onSuccess {
Log.d(TAG, "Successfully registered app: ${registration.appName} (ID: ${redactIdentifier(it)})") debugLog { "Successfully registered app: ${registration.appName} (ID: ${redactIdentifier(it)})" }
withContext(Dispatchers.Main) { onSuccess() } withContext(Dispatchers.Main) { onSuccess() }
} }
.onFailure { withContext(Dispatchers.Main) { onError(it.message ?: "Failed to register app") } } .onFailure { withContext(Dispatchers.Main) { onError(it.message ?: "Failed to register app") } }
@ -155,7 +154,7 @@ object PrismServerClient {
.addHeader("Content-Type", "application/json") .addHeader("Content-Type", "application/json")
.post(json.toString().toRequestBody("application/json".toMediaType())) .post(json.toString().toRequestBody("application/json".toMediaType()))
.build() .build()
Log.d(TAG, "Registering app with Prism server: ${registration.appName}") debugLog { "Registering app with Prism server: ${registration.appName}" }
HttpClientFactory.shared.newCall(request).execute().use { response -> HttpClientFactory.shared.newCall(request).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
return try { return try {
@ -178,7 +177,7 @@ object PrismServerClient {
fun registerAllApps(context: Context) { fun registerAllApps(context: Context) {
val config = resolveServerConfig(context) val config = resolveServerConfig(context)
if (config == null) { if (config == null) {
Log.d(TAG, "Prism server not configured, skipping bulk registration") debugLog { "Prism server not configured, skipping bulk registration" }
return return
} }
val store = PrismPreferences(context) val store = PrismPreferences(context)
@ -189,7 +188,7 @@ object PrismServerClient {
val manualApps = listManualApps(context) val manualApps = listManualApps(context)
val channelByVapid = db.listChannelIdVapid().associate { (channelId, vapid) -> vapid to channelId } val channelByVapid = db.listChannelIdVapid().associate { (channelId, vapid) -> vapid to channelId }
Log.d(TAG, "Registering ${manualApps.size} manual apps with Prism server") debugLog { "Registering ${manualApps.size} manual apps with Prism server" }
manualApps.forEach { app -> manualApps.forEach { app ->
app.endpoint?.let { endpoint -> app.endpoint?.let { endpoint ->
@ -235,7 +234,7 @@ object PrismServerClient {
val config = resolveServerConfig(context, serverUrl, apiKey) val config = resolveServerConfig(context, serverUrl, apiKey)
if (config == null) { if (config == null) {
Log.d(TAG, "Prism server not configured, skipping deletion") debugLog { "Prism server not configured, skipping deletion" }
return return
} }
val (url, key) = config val (url, key) = config
@ -314,7 +313,7 @@ object PrismServerClient {
val config = resolveServerConfig(context, serverUrl, apiKey) val config = resolveServerConfig(context, serverUrl, apiKey)
if (config == null) { if (config == null) {
Log.d(TAG, "Prism server not configured, skipping bulk deletion") debugLog { "Prism server not configured, skipping bulk deletion" }
return return
} }
val (url, key) = config val (url, key) = config
@ -323,7 +322,7 @@ object PrismServerClient {
try { try {
val manualApps = listManualApps(context) val manualApps = listManualApps(context)
Log.d(TAG, "Deleting ${manualApps.size} manual apps from Prism server") debugLog { "Deleting ${manualApps.size} manual apps from Prism server" }
manualApps.forEach { app -> manualApps.forEach { app ->
deleteApp(context, app.connectorToken, url, key) deleteApp(context, app.connectorToken, url, key)
@ -430,4 +429,10 @@ object PrismServerClient {
private fun listManualApps(context: Context) = DatabaseFactory.getDb(context) private fun listManualApps(context: Context) = DatabaseFactory.getDb(context)
.listApps() .listApps()
.filter { DescriptionParser.isManualApp(it.description) } .filter { DescriptionParser.isManualApp(it.description) }
private inline fun debugLog(message: () -> String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, message())
}
}
} }

View file

@ -0,0 +1,100 @@
package app.lonecloud.prism
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.core.content.edit
import java.nio.charset.StandardCharsets
import java.security.GeneralSecurityException
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class SecureStringPreferences(
context: Context,
private val prefName: String,
private val keyAlias: String
) {
private val appContext = context.applicationContext
private val sharedPreferences = appContext.getSharedPreferences(prefName, Context.MODE_PRIVATE)
private val secretKey: SecretKey by lazy { getOrCreateSecretKey(keyAlias) }
fun putString(key: String, value: String?) {
if (value == null) {
remove(key)
return
}
sharedPreferences.edit(commit = true) { putString(key, encrypt(value)) }
}
fun getString(key: String, defaultValue: String? = null): String? {
val encrypted = sharedPreferences.getString(key, null) ?: return defaultValue
return decrypt(encrypted) ?: defaultValue
}
fun remove(key: String) {
sharedPreferences.edit(commit = true) { remove(key) }
}
fun contains(key: String): Boolean = sharedPreferences.contains(key)
private fun encrypt(value: String): String {
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val iv = cipher.iv
val encrypted = cipher.doFinal(value.toByteArray(StandardCharsets.UTF_8))
val payload = ByteArray(iv.size + encrypted.size)
System.arraycopy(iv, 0, payload, 0, iv.size)
System.arraycopy(encrypted, 0, payload, iv.size, encrypted.size)
return Base64.encodeToString(payload, Base64.NO_WRAP)
}
private fun decrypt(value: String): String? {
val payload = try {
Base64.decode(value, Base64.NO_WRAP)
} catch (_: IllegalArgumentException) {
return null
}
if (payload.size <= IV_SIZE_BYTES) return null
val iv = payload.copyOfRange(0, IV_SIZE_BYTES)
val encrypted = payload.copyOfRange(IV_SIZE_BYTES, payload.size)
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
return try {
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_SIZE_BITS, iv))
val plain = cipher.doFinal(encrypted)
String(plain, StandardCharsets.UTF_8)
} catch (_: GeneralSecurityException) {
null
}
}
private fun getOrCreateSecretKey(alias: String): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keyStore.getKey(alias, null) as? SecretKey)?.let { return it }
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
val keySpec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setKeySize(KEY_SIZE_BITS)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
keyGenerator.init(keySpec)
return keyGenerator.generateKey()
}
companion object {
private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_SIZE_BYTES = 12
private const val GCM_TAG_SIZE_BITS = 128
private const val KEY_SIZE_BITS = 256
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
}
}

View file

@ -9,14 +9,12 @@
package app.lonecloud.prism.activities package app.lonecloud.prism.activities
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import app.lonecloud.prism.activities.ui.App import app.lonecloud.prism.activities.ui.App
import app.lonecloud.prism.activities.ui.theme.AppTheme import app.lonecloud.prism.activities.ui.theme.AppTheme
import app.lonecloud.prism.utils.TAG
import org.unifiedpush.android.distributor.ipc.InternalMessenger import org.unifiedpush.android.distributor.ipc.InternalMessenger
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -39,9 +37,4 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
override fun onDestroy() {
Log.d(TAG, "Destroy")
super.onDestroy()
}
} }

View file

@ -20,6 +20,7 @@ import app.lonecloud.prism.utils.ManualAppNotifications
import app.lonecloud.prism.utils.TAG import app.lonecloud.prism.utils.TAG
import app.lonecloud.prism.utils.VapidKeyGenerator import app.lonecloud.prism.utils.VapidKeyGenerator
import app.lonecloud.prism.utils.WebPushEncryptionKeys import app.lonecloud.prism.utils.WebPushEncryptionKeys
import app.lonecloud.prism.utils.redactIdentifier
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -208,7 +209,7 @@ class MainViewModel(
val channelId = UUID.randomUUID().toString() val channelId = UUID.randomUUID().toString()
val connectorToken = "manual_app_${UUID.randomUUID()}" val connectorToken = "manual_app_${UUID.randomUUID()}"
Log.d(TAG, "Creating manual app: $name, token: $connectorToken") Log.d(TAG, "Creating manual app: $name, token=${redactIdentifier(connectorToken)}")
val vapidKeys = VapidKeyGenerator.generateKeyPair() val vapidKeys = VapidKeyGenerator.generateKeyPair()
val encryptionKeys = WebPushEncryptionKeys.generateKeySet() val encryptionKeys = WebPushEncryptionKeys.generateKeySet()

View file

@ -20,13 +20,17 @@ class ViewModelFactory(val application: Application, val messenger: InternalMess
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = when { override fun <T : ViewModel> create(modelClass: Class<T>): T = when {
modelClass.isAssignableFrom(MainViewModel::class.java) -> MainViewModel(requireBatteryOptimization, messenger, application) modelClass.isAssignableFrom(MainViewModel::class.java) -> MainViewModel(requireBatteryOptimization, messenger, application)
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(messenger, application) modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(messenger, application)
modelClass.isAssignableFrom(ThemeViewModel::class.java) -> ThemeViewModel(messenger, application) modelClass.isAssignableFrom(ThemeViewModel::class.java) -> ThemeViewModel(messenger, application)
modelClass.isAssignableFrom(DistribMigrationViewModel::class.java) -> DistribMigrationViewModel( modelClass.isAssignableFrom(DistribMigrationViewModel::class.java) -> DistribMigrationViewModel(
DistribMigrationState(), DistribMigrationState(),
PrismConfig, PrismConfig,
messenger messenger
) )
else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
} as T } as T
} }
@ -48,6 +52,7 @@ class PreviewFactory(val context: Context) : ViewModelProvider.Factory {
null null
) )
} }
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> { modelClass.isAssignableFrom(SettingsViewModel::class.java) -> {
SettingsViewModel( SettingsViewModel(
SettingsState( SettingsState(
@ -60,7 +65,9 @@ class PreviewFactory(val context: Context) : ViewModelProvider.Factory {
null null
) )
} }
modelClass.isAssignableFrom(ThemeViewModel::class.java) -> ThemeViewModel(null, null) modelClass.isAssignableFrom(ThemeViewModel::class.java) -> ThemeViewModel(null, null)
modelClass.isAssignableFrom(DistribMigrationViewModel::class.java) -> { modelClass.isAssignableFrom(DistribMigrationViewModel::class.java) -> {
DistribMigrationViewModel( DistribMigrationViewModel(
DistribMigrationState(), DistribMigrationState(),
@ -68,6 +75,7 @@ class PreviewFactory(val context: Context) : ViewModelProvider.Factory {
null null
) )
} }
else -> throw IllegalArgumentException("Unknown ViewModel class") else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T } as T
} }

View file

@ -124,6 +124,7 @@ fun App(
} }
) )
} }
AppScreen.RegistrationDetails -> { AppScreen.RegistrationDetails -> {
AppBar( AppBar(
title = R.string.registration_details_title, title = R.string.registration_details_title,
@ -143,6 +144,7 @@ fun App(
} }
) )
} }
else -> null else -> null
} ?: DefaultTopBar( } ?: DefaultTopBar(
currentScreen, currentScreen,

View file

@ -87,6 +87,7 @@ fun MainScreen(
uiActionsFlow?.collect { action -> uiActionsFlow?.collect { action ->
when (action) { when (action) {
UiActions.REFRESH_REGISTRATIONS -> viewModel.refreshRegistrations() UiActions.REFRESH_REGISTRATIONS -> viewModel.refreshRegistrations()
UiActions.UPDATE_PRISM_SERVER_CONFIGURED -> { UiActions.UPDATE_PRISM_SERVER_CONFIGURED -> {
viewModel.application?.let { app -> viewModel.application?.let { app ->
val store = PrismPreferences(app) val store = PrismPreferences(app)

View file

@ -107,6 +107,7 @@ fun AppTheme(
} }
darkTheme -> darkScheme darkTheme -> darkScheme
else -> lightScheme else -> lightScheme
} }

View file

@ -14,6 +14,7 @@ import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import app.lonecloud.prism.BuildConfig
import app.lonecloud.prism.DatabaseFactory import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.Distributor import app.lonecloud.prism.Distributor
import app.lonecloud.prism.Distributor.sendMessage import app.lonecloud.prism.Distributor.sendMessage
@ -49,7 +50,7 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
fun start(): WebSocket { fun start(): WebSocket {
val url = ApiUrlCandidate.getTest() ?: store.apiUrl val url = ApiUrlCandidate.getTest() ?: store.apiUrl
val uaid = store.uaid val uaid = store.uaid
Log.d(TAG, "Connecting to ${redactUrl(url)} [uaid?=${uaid != null}]") debugLog { "Connecting to ${redactUrl(url)} [uaid?=${uaid != null}]" }
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.build() .build()
@ -62,39 +63,45 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
SourceManager.setConnected(context, webSocket) SourceManager.setConnected(context, webSocket)
releaseLock() releaseLock()
Log.d(TAG, "onOpen: " + response.code) debugLog { "onOpen: ${response.code}" }
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
val message = ServerMessage.deserialize(text) ?: run { val message = ServerMessage.deserialize(text) ?: run {
Log.d(TAG, "Couldn't deserialize incoming server message") debugLog { "Couldn't deserialize incoming server message" }
return return
} }
Log.d(TAG, "New message: ${message::class.java.simpleName}") debugLog { "New message: ${message::class.java.simpleName}" }
lastEventDate = Calendar.getInstance() lastEventDate = Calendar.getInstance()
when (message) { when (message) {
is ServerMessage.Broadcast -> ignoreEvent() is ServerMessage.Broadcast -> ignoreEvent()
is ServerMessage.Hello -> onHello(webSocket, message) is ServerMessage.Hello -> onHello(webSocket, message)
is ServerMessage.Notification -> onNotification(webSocket, message) is ServerMessage.Notification -> onNotification(webSocket, message)
ServerMessage.Ping -> onPing(webSocket) ServerMessage.Ping -> onPing(webSocket)
is ServerMessage.Register -> onRegister(message) is ServerMessage.Register -> onRegister(message)
is ServerMessage.Unregister -> onUnregister(webSocket, message) is ServerMessage.Unregister -> onUnregister(webSocket, message)
is ServerMessage.Urgency -> { is ServerMessage.Urgency -> {
Log.d(TAG, "Urgency status=${message.status}") debugLog { "Urgency status=${message.status}" }
} }
} }
} }
private fun ignoreEvent() { private fun ignoreEvent() {
Log.d(TAG, "Ignoring event") debugLog { "Ignoring event" }
} }
private fun onHello(webSocket: WebSocket, message: ServerMessage.Hello) { private fun onHello(webSocket: WebSocket, message: ServerMessage.Hello) {
Log.d(TAG, "Hello") debugLog { "Hello" }
SourceManager.debugStarted() SourceManager.debugStarted()
ApiUrlCandidate.finish(context)?.let { ApiUrlCandidate.finish(context)?.let {
store.apiUrl = it store.apiUrl = it
Log.d(TAG, "Successfully using ${redactUrl(it)}") debugLog { "Successfully using ${redactUrl(it)}" }
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
Toast.makeText( Toast.makeText(
context, context,
@ -105,7 +112,7 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
} }
val db = DatabaseFactory.getDb(context) val db = DatabaseFactory.getDb(context)
if (message.uaid != store.uaid) { if (message.uaid != store.uaid) {
Log.d(TAG, "We received a new uaid") debugLog { "We received a new uaid" }
store.uaid = message.uaid store.uaid = message.uaid
db.listChannelIdVapid().forEach { pair -> db.listChannelIdVapid().forEach { pair ->
ClientMessage.Register( ClientMessage.Register(
@ -116,11 +123,11 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
db.deleteDisabledApps() db.deleteDisabledApps()
} else { } else {
db.listDisabledChannelIds().forEach { db.listDisabledChannelIds().forEach {
Log.d(TAG, "Hello, unregistering $it") debugLog { "Hello, unregistering $it" }
ClientMessage.Unregister(channelID = it).send(webSocket) ClientMessage.Unregister(channelID = it).send(webSocket)
} }
db.listPendingChannelIdVapid().forEach { pair -> db.listPendingChannelIdVapid().forEach { pair ->
Log.d(TAG, "Hello, registering channel=${redactIdentifier(pair.first)}") debugLog { "Hello, registering channel=${redactIdentifier(pair.first)}" }
ClientMessage.Register( ClientMessage.Register(
channelID = pair.first, channelID = pair.first,
key = pair.second key = pair.second
@ -131,7 +138,7 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
private fun decryptNotificationData(channelID: String, encryptedData: ByteArray): ByteArray { private fun decryptNotificationData(channelID: String, encryptedData: ByteArray): ByteArray {
val keys = EncryptionKeyStore(context).getKeys(channelID) ?: run { val keys = EncryptionKeyStore(context).getKeys(channelID) ?: run {
Log.d(TAG, "No encryption keys found for manual channel=${redactIdentifier(channelID)}, message may be unencrypted") debugLog { "No encryption keys found for manual channel=${redactIdentifier(channelID)}, message may be unencrypted" }
return encryptedData return encryptedData
} }
val publicKeyBytes = Base64.decode(keys.p256dh, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) val publicKeyBytes = Base64.decode(keys.p256dh, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
@ -169,7 +176,7 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
val payload = NotificationPayload.fromJson(dataString) val payload = NotificationPayload.fromJson(dataString)
if (payload != null) { if (payload != null) {
Log.d(TAG, "Displaying notification for manual app '${app.title}': ${payload.title}") debugLog { "Displaying notification for manual app '${app.title}': ${payload.title}" }
ManualAppNotifications.showNotification( ManualAppNotifications.showNotification(
context, context,
message.channelID, message.channelID,
@ -192,15 +199,15 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
private fun onPing(webSocket: WebSocket) { private fun onPing(webSocket: WebSocket) {
SourceManager.debugNewPing(context) SourceManager.debugNewPing(context)
if (!waitingPong.getAndSet(false)) { if (!waitingPong.getAndSet(false)) {
Log.d(TAG, "Sending Pong") debugLog { "Sending Pong" }
ClientMessage.Ping.send(webSocket) ClientMessage.Ping.send(webSocket)
} else { } else {
Log.d(TAG, "Received Pong") debugLog { "Received Pong" }
} }
} }
private fun onRegister(message: ServerMessage.Register) { private fun onRegister(message: ServerMessage.Register) {
Log.d(TAG, "New endpoint received for channel=${redactIdentifier(message.channelID)}") debugLog { "New endpoint received for channel=${redactIdentifier(message.channelID)}" }
Distributor.finishRegistration( Distributor.finishRegistration(
context, context,
ChannelCreationStatus.Ok(message.channelID, message.pushEndpoint) ChannelCreationStatus.Ok(message.channelID, message.pushEndpoint)
@ -219,7 +226,7 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
if (isManualChannel) { if (isManualChannel) {
val vapidKey = channelVapidPair.second val vapidKey = channelVapidPair.second
if (PrismPreferences(context).isPendingChannelDeletion(message.channelID)) { if (PrismPreferences(context).isPendingChannelDeletion(message.channelID)) {
Log.d(TAG, "Channel ${redactIdentifier(message.channelID)} is pending deletion, skipping re-registration") debugLog { "Channel ${redactIdentifier(message.channelID)} is pending deletion, skipping re-registration" }
PrismPreferences(context).removePendingChannelDeletion(message.channelID) PrismPreferences(context).removePendingChannelDeletion(message.channelID)
return return
} }
@ -241,7 +248,7 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
code: Int, code: Int,
reason: String reason: String
) { ) {
Log.d(TAG, "onClosed: $webSocket") debugLog { "onClosed: $webSocket" }
webSocket.cancel() webSocket.cancel()
releaseLock() releaseLock()
if (shouldRestart() && SourceManager.addFail(context, webSocket)) { if (shouldRestart() && SourceManager.addFail(context, webSocket)) {
@ -255,23 +262,23 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
t: Throwable, t: Throwable,
response: Response? response: Response?
) { ) {
Log.d(TAG, "onFailure: An error occurred: $t") debugLog { "onFailure: An error occurred: $t" }
response?.let { response?.let {
Log.d(TAG, "onFailure: ${it.code}") debugLog { "onFailure: ${it.code}" }
} }
releaseLock() releaseLock()
if (failToUseUrlCandidate(context)) return if (failToUseUrlCandidate(context)) return
if (!shouldRestart()) return if (!shouldRestart()) return
if (SourceManager.addFail(context, webSocket)) { if (SourceManager.addFail(context, webSocket)) {
val delay = SourceManager.getTimeout() ?: return val delay = SourceManager.getTimeout() ?: return
Log.d(TAG, "Retrying in $delay ms") debugLog { "Retrying in $delay ms" }
RestartWorker.run(context, delay = delay) RestartWorker.run(context, delay = delay)
} }
} }
private fun failToUseUrlCandidate(context: Context): Boolean { private fun failToUseUrlCandidate(context: Context): Boolean {
ApiUrlCandidate.finish(context)?.let { url -> ApiUrlCandidate.finish(context)?.let { url ->
Log.d(TAG, "Fail to use ${redactUrl(url)}") debugLog { "Fail to use ${redactUrl(url)}" }
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
Toast.makeText( Toast.makeText(
context, context,
@ -285,24 +292,25 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
return false return false
} }
/**
* Check if service is started and if there is internet if the service has not started.
*
* @return [FgService.isServiceStarted]
*/
@Suppress("ReturnCount") @Suppress("ReturnCount")
private fun shouldRestart(): Boolean { private fun shouldRestart(): Boolean {
if (!FgService.isServiceStarted()) { if (!FgService.isServiceStarted()) {
Log.d(TAG, "StartService not started") debugLog { "StartService not started" }
return false return false
} }
if (!NetworkCallbackFactory.hasInternet()) { if (!NetworkCallbackFactory.hasInternet()) {
Log.d(TAG, "No Internet: do not restart") debugLog { "No Internet: do not restart" }
return false return false
} }
return true return true
} }
private inline fun debugLog(message: () -> String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, message())
}
}
companion object { companion object {
var lastEventDate: Calendar? = null var lastEventDate: Calendar? = null
var waitingPong = AtomicBoolean(false) var waitingPong = AtomicBoolean(false)

View file

@ -140,7 +140,11 @@ sealed class ServerMessage {
null null
} }
} catch (innerE: SerializationException) { } catch (innerE: SerializationException) {
android.util.Log.w("ServerMessage", "Failed to deserialize: $jsonStr", innerE) android.util.Log.w(
"ServerMessage",
"Failed to deserialize server message (length=${jsonStr.length})",
innerE
)
null null
} }
} }

View file

@ -9,6 +9,7 @@ import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.utils.HttpClientFactory import app.lonecloud.prism.utils.HttpClientFactory
import app.lonecloud.prism.utils.ManualAppNotifications import app.lonecloud.prism.utils.ManualAppNotifications
import app.lonecloud.prism.utils.TAG import app.lonecloud.prism.utils.TAG
import app.lonecloud.prism.utils.redactIdentifier
import java.io.IOException import java.io.IOException
import java.net.URI import java.net.URI
import java.util.Locale import java.util.Locale
@ -40,7 +41,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
} }
} }
Log.d(TAG, "Notification action triggered: $actionLabel ($actionID) for channel $channelID") Log.d(TAG, "Notification action triggered: $actionLabel ($actionID) for channel=${redactIdentifier(channelID)}")
if (notificationTag.isNotEmpty()) { if (notificationTag.isNotEmpty()) {
ManualAppNotifications.dismissNotification(context, notificationTag, connectorToken) ManualAppNotifications.dismissNotification(context, notificationTag, connectorToken)
@ -51,15 +52,19 @@ class NotificationActionReceiver : BroadcastReceiver() {
try { try {
executeAction(context, actionEndpoint, actionMethod, data) executeAction(context, actionEndpoint, actionMethod, data)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Failed to execute notification action: ${e.message}", e) logActionFailure(e)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Log.e(TAG, "Failed to execute notification action: ${e.message}", e) logActionFailure(e)
} finally { } finally {
pendingResult.finish() pendingResult.finish()
} }
} }
} }
private fun logActionFailure(error: Exception) {
Log.e(TAG, "Failed to execute notification action: ${error.message}", error)
}
private fun executeAction( private fun executeAction(
context: Context, context: Context,
endpoint: String, endpoint: String,

View file

@ -18,10 +18,12 @@ class PrismConfigReceiver : BroadcastReceiver() {
val url = intent.getStringExtra(EXTRA_URL) ?: "" val url = intent.getStringExtra(EXTRA_URL) ?: ""
store.prismServerUrl = url store.prismServerUrl = url
} }
ACTION_SET_PRISM_API_KEY -> { ACTION_SET_PRISM_API_KEY -> {
val apiKey = intent.getStringExtra(EXTRA_API_KEY) ?: "" val apiKey = intent.getStringExtra(EXTRA_API_KEY) ?: ""
store.prismApiKey = apiKey store.prismApiKey = apiKey
} }
ACTION_SET_PUSH_SERVICE_URL -> { ACTION_SET_PUSH_SERVICE_URL -> {
val url = intent.getStringExtra(EXTRA_URL) ?: "" val url = intent.getStringExtra(EXTRA_URL) ?: ""
if (url.isBlank()) { if (url.isBlank()) {

View file

@ -1,50 +1,47 @@
[versions] [versions]
android-gradle-plugin = "9.1.0" android-gradle-plugin = "9.1.0"
androidx-activityCompose = "1.12.4" androidx-activityCompose = "1.13.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-work = "2.11.1" androidx-work = "2.11.1"
unifiedpush_distributor = "0.7.4" detekt = "1.23.8"
unifiedpush_distributor_base = "0.7.3"
tink = "1.20.0"
kotlin = "2.3.10" kotlin = "2.3.10"
kotlinx_serializationJson = "1.10.0" kotlinx_serializationJson = "1.10.0"
ktlint = "14.1.0" ktlint = "14.2.0"
detekt = "1.23.8"
version-catalog-update = "1.1.0"
material3Android = "1.4.0" material3Android = "1.4.0"
materialIconsCore = "1.7.8" materialIconsCore = "1.7.8"
okhttp = "5.3.2"
uiTooling = "1.10.4"
navigationCompose = "2.9.7" navigationCompose = "2.9.7"
security-crypto = "1.1.0" okhttp = "5.3.2"
tink = "1.20.0"
uiTooling = "1.10.5"
unifiedpush_distributor = "0.7.4"
unifiedpush_distributor_base = "0.7.3"
version-catalog-update = "1.1.0"
[libraries] [libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCore" } androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCore" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsCore" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsCore" }
androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } androidx-material3-android = { module = "androidx.compose.material3:material3-android", version.ref = "material3Android" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiTooling" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiTooling" }
androidx-ui-tooling-preview-android = { module = "androidx.compose.ui:ui-tooling-preview-android", version.ref = "uiTooling" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
detekt-gradle = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serializationJson" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink" }
unifiedpush-distributor = { module = "org.unifiedpush.android:distributor", version.ref = "unifiedpush_distributor" } unifiedpush-distributor = { module = "org.unifiedpush.android:distributor", version.ref = "unifiedpush_distributor" }
unifiedpush-distributor-base = { module = "org.unifiedpush.android:distributor-base", version.ref = "unifiedpush_distributor_base" } unifiedpush-distributor-base = { module = "org.unifiedpush.android:distributor-base", version.ref = "unifiedpush_distributor_base" }
unifiedpush-distributor-ui = { module = "org.unifiedpush.android:distributor-ui", version.ref = "unifiedpush_distributor" } unifiedpush-distributor-ui = { module = "org.unifiedpush.android:distributor-ui", version.ref = "unifiedpush_distributor" }
tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serializationJson" }
detekt-gradle = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt"}
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlin' }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" }