code cleanup and minor improvements, fix more linting issues

This commit is contained in:
Egor 2026-03-02 15:11:39 -08:00
parent 69009b7447
commit aaeb6b76a8
21 changed files with 300 additions and 232 deletions

View file

@ -1 +1 @@
0.3.0 0.4.0

View file

@ -39,10 +39,10 @@ android {
signingConfigs { signingConfigs {
create("release") { create("release") {
storeFile = file(System.getenv("ANDROID_SIGNING_STORE_FILE") ?: "../prism-release.keystore") storeFile = file(System.getenv("ANDROID_SIGNING_STORE_FILE") ?: "non-existent.keystore")
storePassword = System.getenv("ANDROID_SIGNING_STORE_PASSWORD") ?: "android123" storePassword = System.getenv("ANDROID_SIGNING_STORE_PASSWORD") ?: ""
keyAlias = System.getenv("ANDROID_SIGNING_KEY_ALIAS") ?: "prism" keyAlias = System.getenv("ANDROID_SIGNING_KEY_ALIAS") ?: ""
keyPassword = System.getenv("ANDROID_SIGNING_KEY_PASSWORD") ?: "android123" keyPassword = System.getenv("ANDROID_SIGNING_KEY_PASSWORD") ?: ""
} }
} }
@ -57,7 +57,13 @@ android {
resValue("string", "app_name", "Prism") resValue("string", "app_name", "Prism")
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
signingConfig = signingConfigs.getByName("release") val signingEnvVarsPresent = listOf(
"ANDROID_SIGNING_STORE_FILE",
"ANDROID_SIGNING_STORE_PASSWORD",
"ANDROID_SIGNING_KEY_ALIAS",
"ANDROID_SIGNING_KEY_PASSWORD"
).all { System.getenv(it) != null }
if (signingEnvVarsPresent) signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@ -83,6 +89,10 @@ android {
namespace = "app.lonecloud.prism" namespace = "app.lonecloud.prism"
} }
kotlin {
jvmToolchain(21)
}
dependencies { dependencies {
implementation(libs.unifiedpush.distributor) implementation(libs.unifiedpush.distributor)
implementation(libs.unifiedpush.distributor.base) implementation(libs.unifiedpush.distributor.base)
@ -99,5 +109,6 @@ 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

@ -0,0 +1,7 @@
package app.lonecloud.prism
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
object AppScope : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.IO)

View file

@ -22,9 +22,8 @@ object DatabaseFactory {
fun getDb(context: Context): Database { fun getDb(context: Context): Database {
return db.get() ?: run { return db.get() ?: run {
val db = MainDatabase(context.applicationContext) val newDb = MainDatabase(context.applicationContext)
this.db.set(db) if (db.compareAndSet(null, newDb)) newDb else db.get()!!
return db
} }
} }
} }

View file

@ -47,16 +47,13 @@ object Distributor : UnifiedPushDistributor() {
) = backendRegisterNewChannelId(context, packageName, channelId, title, vapid, description) ) = backendRegisterNewChannelId(context, packageName, channelId, title, vapid, description)
override fun backendUnregisterChannelId(context: Context, channelId: String) { override fun backendUnregisterChannelId(context: Context, channelId: String) {
val db = getDb(context) val preferences = PrismPreferences(context)
preferences.addPendingChannelDeletion(channelId)
val channelVapidPair = db.listChannelIdVapid().find { it.first == channelId } val app = getDb(context).getAppFromChanId(channelId, false)
if (channelVapidPair != null) {
val app = db.listApps().find { it.vapidKey == channelVapidPair.second }
if (app != null && DescriptionParser.isManualApp(app.description)) { if (app != null && DescriptionParser.isManualApp(app.description)) {
PrismServerClient.deleteApp(context, app.connectorToken) PrismServerClient.deleteApp(context, app.connectorToken)
} }
}
MessageSender.send( MessageSender.send(
context, context,

View file

@ -4,13 +4,29 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Base64 import android.util.Base64
import androidx.core.content.edit import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class EncryptionKeyStore(context: Context) { class EncryptionKeyStore(context: Context) {
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(
PREF_NAME, data class EncryptionKeys(
Context.MODE_PRIVATE val privateKey: ByteArray,
val authSecret: ByteArray,
val p256dh: String
) )
private val sharedPreferences: SharedPreferences = run {
val masterKey = MasterKey.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context.applicationContext,
PREF_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun storeKeys( fun storeKeys(
channelId: String, channelId: String,
privateKey: ByteArray, privateKey: ByteArray,
@ -24,17 +40,15 @@ class EncryptionKeyStore(context: Context) {
} }
} }
fun getKeys(channelId: String): Triple<ByteArray, ByteArray, String>? { fun getKeys(channelId: String): EncryptionKeys? {
val privateKeyB64 = sharedPreferences.getString(keyFor(channelId, KEY_PRIVATE), null) val privateKeyB64 = sharedPreferences.getString(keyFor(channelId, KEY_PRIVATE), null)
val authSecretB64 = sharedPreferences.getString(keyFor(channelId, KEY_AUTH), null) val authSecretB64 = sharedPreferences.getString(keyFor(channelId, KEY_AUTH), null)
val publicKey = sharedPreferences.getString(keyFor(channelId, KEY_PUBLIC), null) val publicKey = sharedPreferences.getString(keyFor(channelId, KEY_PUBLIC), null)
if (privateKeyB64 == null || authSecretB64 == null || publicKey == null) { if (privateKeyB64 == null || authSecretB64 == null || publicKey == null) return null
return null
}
return try { return try {
Triple(base64Decode(privateKeyB64), base64Decode(authSecretB64), publicKey) EncryptionKeys(base64Decode(privateKeyB64), base64Decode(authSecretB64), publicKey)
} catch (_: IllegalArgumentException) { } catch (_: IllegalArgumentException) {
deleteKeys(channelId) deleteKeys(channelId)
null null
@ -49,7 +63,8 @@ class EncryptionKeyStore(context: Context) {
} }
} }
fun hasKeys(channelId: String): Boolean = sharedPreferences.contains(keyFor(channelId, KEY_PRIVATE)) && fun hasKeys(channelId: String): Boolean =
sharedPreferences.contains(keyFor(channelId, KEY_PRIVATE)) &&
sharedPreferences.contains(keyFor(channelId, KEY_AUTH)) && sharedPreferences.contains(keyFor(channelId, KEY_AUTH)) &&
sharedPreferences.contains(keyFor(channelId, KEY_PUBLIC)) sharedPreferences.contains(keyFor(channelId, KEY_PUBLIC))

View file

@ -1,13 +1,29 @@
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
class PrismPreferences(context: Context) : class PrismPreferences(private val context: Context) :
Store(context, PREF_NAME), Store(context, PREF_NAME),
MigrationManager.MigrationStore { MigrationManager.MigrationStore {
private val securePreferences: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
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)
@ -90,11 +106,9 @@ class PrismPreferences(context: Context) :
} }
var prismApiKey: String? var prismApiKey: String?
get() = sharedPreferences get() = securePreferences.getString(PREF_PRISM_API_KEY, null)
.getString(PREF_PRISM_API_KEY, null) set(value) = securePreferences.edit {
set(value) = sharedPreferences if (value == null) remove(PREF_PRISM_API_KEY) else putString(PREF_PRISM_API_KEY, value)
.edit {
putOrRemove(PREF_PRISM_API_KEY, value)
} }
var introCompleted: Boolean var introCompleted: Boolean
@ -155,19 +169,32 @@ class PrismPreferences(context: Context) :
} }
fun setVapidPrivateKey(connectorToken: String, privateKey: String) { fun setVapidPrivateKey(connectorToken: String, privateKey: String) {
sharedPreferences.edit { securePreferences.edit { putString("vapid_private_$connectorToken", privateKey) }
putString("vapid_private_$connectorToken", privateKey)
}
} }
fun getVapidPrivateKey(connectorToken: String): String? = sharedPreferences.getString("vapid_private_$connectorToken", null) fun getVapidPrivateKey(connectorToken: String): String? = securePreferences.getString("vapid_private_$connectorToken", null)
fun removeVapidPrivateKey(connectorToken: String) { fun removeVapidPrivateKey(connectorToken: String) {
sharedPreferences.edit { securePreferences.edit { remove("vapid_private_$connectorToken") }
remove("vapid_private_$connectorToken")
} }
fun addPendingChannelDeletion(channelId: String) {
val ids = sharedPreferences.getStringSet(PREF_PENDING_CHANNEL_DELETIONS, emptySet())
?.toMutableSet() ?: mutableSetOf()
ids.add(channelId)
sharedPreferences.edit(commit = true) { putStringSet(PREF_PENDING_CHANNEL_DELETIONS, ids) }
} }
fun removePendingChannelDeletion(channelId: String) {
val ids = sharedPreferences.getStringSet(PREF_PENDING_CHANNEL_DELETIONS, emptySet())
?.toMutableSet() ?: mutableSetOf()
ids.remove(channelId)
sharedPreferences.edit(commit = true) { putStringSet(PREF_PENDING_CHANNEL_DELETIONS, ids) }
}
fun isPendingChannelDeletion(channelId: String): Boolean =
sharedPreferences.getStringSet(PREF_PENDING_CHANNEL_DELETIONS, emptySet())?.contains(channelId) == true
fun addPendingManualToken(connectorToken: String) { fun addPendingManualToken(connectorToken: String) {
val tokens = sharedPreferences.getStringSet(PREF_PENDING_MANUAL_TOKENS, emptySet()) val tokens = sharedPreferences.getStringSet(PREF_PENDING_MANUAL_TOKENS, emptySet())
?.toMutableSet() ?.toMutableSet()
@ -197,6 +224,7 @@ class PrismPreferences(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 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"
@ -209,5 +237,6 @@ class PrismPreferences(context: Context) :
private const val PREF_PRISM_API_KEY = "prism_api_key" private const val PREF_PRISM_API_KEY = "prism_api_key"
private const val PREF_INTRO_COMPLETED = "intro_completed" private const val PREF_INTRO_COMPLETED = "intro_completed"
private const val PREF_PENDING_MANUAL_TOKENS = "pending_manual_tokens" private const val PREF_PENDING_MANUAL_TOKENS = "pending_manual_tokens"
private const val PREF_PENDING_CHANNEL_DELETIONS = "pending_channel_deletions"
} }
} }

View file

@ -7,8 +7,8 @@ import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.PrismPreferences import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.utils.DescriptionParser import app.lonecloud.prism.utils.DescriptionParser
import app.lonecloud.prism.utils.HttpClientFactory import app.lonecloud.prism.utils.HttpClientFactory
import app.lonecloud.prism.utils.toBase64Url
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -20,18 +20,21 @@ import org.json.JSONObject
object PrismServerClient { object PrismServerClient {
private const val TAG = "PrismServerClient" private const val TAG = "PrismServerClient"
private const val VAPID_PRIVATE_DESC_PREFIX = "vp:"
data class WebPushRegistration(
val connectorToken: String,
val appName: String,
val webpushUrl: String,
val vapidPrivateKey: String? = null,
val p256dh: String? = null,
val auth: String? = null
)
private fun getAuthHeader(apiKey: String): String = "Bearer $apiKey" private fun getAuthHeader(apiKey: String): String = "Bearer $apiKey"
fun registerApp( fun registerApp(
context: Context, context: Context,
connectorToken: String, registration: WebPushRegistration,
appName: String,
webpushUrl: String,
vapidPrivateKey: String? = null,
p256dh: String? = null,
auth: String? = null,
onSuccess: () -> Unit = {}, onSuccess: () -> Unit = {},
onError: (String) -> Unit = {} onError: (String) -> Unit = {}
) { ) {
@ -44,112 +47,40 @@ object PrismServerClient {
} }
val (serverUrl, apiKey) = config val (serverUrl, apiKey) = config
val store = PrismPreferences(context) val store = PrismPreferences(context)
val existingSubscriptionId = store.getSubscriptionId(connectorToken) val existingSubscriptionId = store.getSubscriptionId(registration.connectorToken)
?: getSubscriptionIdFromDb(context, connectorToken)?.also { ?: getSubscriptionIdFromDb(context, registration.connectorToken)?.also {
store.setSubscriptionId(connectorToken, it) store.setSubscriptionId(registration.connectorToken, it)
Log.d(TAG, "registerApp: restored subscriptionId from description for $connectorToken") Log.d(TAG, "registerApp: restored subscriptionId from description for ${registration.connectorToken}")
} }
val knownEndpoint = store.getRegisteredEndpoint(connectorToken) val knownEndpoint = store.getRegisteredEndpoint(registration.connectorToken)
?: getEndpointFromDb(context, connectorToken)?.also { ?: getEndpointFromDb(context, registration.connectorToken)?.also {
store.setRegisteredEndpoint(connectorToken, it) store.setRegisteredEndpoint(registration.connectorToken, it)
Log.d(TAG, "registerApp: restored endpoint from db for $connectorToken") Log.d(TAG, "registerApp: restored endpoint from db for ${registration.connectorToken}")
} }
Log.d( Log.d(TAG, "registerApp decision: token=${registration.connectorToken} existingSub=${!existingSubscriptionId.isNullOrBlank()} endpointMatch=${knownEndpoint == registration.webpushUrl}")
TAG,
"registerApp decision: token=$connectorToken existingSub=${!existingSubscriptionId.isNullOrBlank()} endpointMatch=${knownEndpoint == webpushUrl}"
)
if (!existingSubscriptionId.isNullOrBlank() && knownEndpoint == webpushUrl) { if (!existingSubscriptionId.isNullOrBlank() && knownEndpoint == registration.webpushUrl) {
Log.d(TAG, "registerApp: skipping duplicate create for $appName (endpoint unchanged)") Log.d(TAG, "registerApp: skipping duplicate create for ${registration.appName} (endpoint unchanged)")
onSuccess() onSuccess()
return return
} }
if (vapidPrivateKey.isNullOrBlank() || !isValidVapidPrivateKey(vapidPrivateKey)) { if (registration.vapidPrivateKey.isNullOrBlank() || !isValidVapidPrivateKey(registration.vapidPrivateKey)) {
val error = "Invalid VAPID private key for $appName. Delete and re-register the app." val error = "Invalid VAPID private key for ${registration.appName}. Delete and re-register the app."
Log.e(TAG, error) Log.e(TAG, error)
onError(error) onError(error)
return return
} }
val validatedVapidPrivateKey = requireNotNull(vapidPrivateKey)
CoroutineScope(Dispatchers.IO).launch { AppScope.launch {
try { try {
if ( deleteStaleSubscription(serverUrl, apiKey, store, registration, existingSubscriptionId, knownEndpoint)
!existingSubscriptionId.isNullOrBlank() && .onFailure { withContext(Dispatchers.Main) { onError(it.message ?: "Failed to replace subscription") }; return@launch }
!knownEndpoint.isNullOrBlank() &&
knownEndpoint != webpushUrl
) {
val deleteRequest = Request.Builder()
.url("$serverUrl/api/v1/webpush/subscriptions/$existingSubscriptionId")
.addHeader("Authorization", getAuthHeader(apiKey))
.delete()
.build()
Log.w( postSubscription(serverUrl, apiKey, store, registration, requireNotNull(registration.vapidPrivateKey))
TAG, .onSuccess { Log.d(TAG, "Successfully registered app: ${registration.appName} (ID: $it)"); withContext(Dispatchers.Main) { onSuccess() } }
"registerApp: endpoint changed for $appName, replacing subscription $existingSubscriptionId" .onFailure { withContext(Dispatchers.Main) { onError(it.message ?: "Failed to register app") } }
)
HttpClientFactory.shared.newCall(deleteRequest).execute().use { deleteResponse ->
if (deleteResponse.isSuccessful || deleteResponse.code == 404) {
store.removeSubscriptionId(connectorToken)
store.removeRegisteredEndpoint(connectorToken)
} else {
val body = deleteResponse.body.string()
val error = "Failed to replace subscription: ${deleteResponse.code} ${deleteResponse.message}"
Log.e(TAG, "$error - Response: $body")
withContext(Dispatchers.Main) { onError(error) }
return@launch
}
}
}
val json = JSONObject().apply {
put("appName", appName)
put("pushEndpoint", webpushUrl)
put("vapidPrivateKey", validatedVapidPrivateKey)
p256dh?.let { put("p256dh", it) }
auth?.let { put("auth", it) }
}
val url = "$serverUrl/api/v1/webpush/subscriptions"
val request = Request.Builder()
.url(url)
.addHeader("Authorization", getAuthHeader(apiKey))
.addHeader("Content-Type", "application/json")
.post(json.toString().toRequestBody("application/json".toMediaType()))
.build()
Log.d(TAG, "Registering app with Prism server: $appName")
HttpClientFactory.shared.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val responseBody = response.body.string()
try {
val responseJson = JSONObject(responseBody)
val subscriptionId = responseJson.getString("subscriptionId")
store.setSubscriptionId(connectorToken, subscriptionId)
store.setRegisteredEndpoint(connectorToken, webpushUrl)
Log.d(TAG, "Successfully registered app: $appName (ID: $subscriptionId)")
withContext(Dispatchers.Main) { onSuccess() }
} catch (e: JSONException) {
val error = "Failed to parse registration response: ${e.message}"
Log.e(TAG, error)
Log.e(TAG, "Response body: $responseBody")
withContext(Dispatchers.Main) { onError(error) }
}
} else {
val responseBody = response.body.string()
val error = "Failed to register app: ${response.code} ${response.message}"
Log.e(TAG, "$error - Response: $responseBody")
withContext(Dispatchers.Main) { onError(error) }
}
}
} catch (e: IOException) { } catch (e: IOException) {
val error = "Error registering app: ${e.message}" val error = "Error registering app: ${e.message}"
Log.e(TAG, error, e) Log.e(TAG, error, e)
@ -158,6 +89,75 @@ object PrismServerClient {
} }
} }
private suspend fun deleteStaleSubscription(
serverUrl: String,
apiKey: String,
store: PrismPreferences,
registration: WebPushRegistration,
existingSubscriptionId: String?,
knownEndpoint: String?
): Result<Unit> {
if (existingSubscriptionId.isNullOrBlank() || knownEndpoint.isNullOrBlank() || knownEndpoint == registration.webpushUrl) {
return Result.success(Unit)
}
Log.w(TAG, "registerApp: endpoint changed for ${registration.appName}, replacing subscription $existingSubscriptionId")
val request = Request.Builder()
.url("$serverUrl/api/v1/webpush/subscriptions/$existingSubscriptionId")
.addHeader("Authorization", getAuthHeader(apiKey))
.delete()
.build()
HttpClientFactory.shared.newCall(request).execute().use { response ->
if (response.isSuccessful || response.code == 404) {
store.removeSubscriptionId(registration.connectorToken)
store.removeRegisteredEndpoint(registration.connectorToken)
return Result.success(Unit)
}
val error = "Failed to replace subscription: ${response.code} ${response.message}"
Log.e(TAG, "$error - Response: ${response.body.string()}")
return Result.failure(IOException(error))
}
}
private suspend fun postSubscription(
serverUrl: String,
apiKey: String,
store: PrismPreferences,
registration: WebPushRegistration,
vapidPrivateKey: String
): Result<String> {
val json = JSONObject().apply {
put("appName", registration.appName)
put("pushEndpoint", registration.webpushUrl)
put("vapidPrivateKey", vapidPrivateKey)
registration.p256dh?.let { put("p256dh", it) }
registration.auth?.let { put("auth", it) }
}
val request = Request.Builder()
.url("$serverUrl/api/v1/webpush/subscriptions")
.addHeader("Authorization", getAuthHeader(apiKey))
.addHeader("Content-Type", "application/json")
.post(json.toString().toRequestBody("application/json".toMediaType()))
.build()
Log.d(TAG, "Registering app with Prism server: ${registration.appName}")
HttpClientFactory.shared.newCall(request).execute().use { response ->
if (response.isSuccessful) {
return try {
val subscriptionId = JSONObject(response.body.string()).getString("subscriptionId")
store.setSubscriptionId(registration.connectorToken, subscriptionId)
store.setRegisteredEndpoint(registration.connectorToken, registration.webpushUrl)
Result.success(subscriptionId)
} catch (e: JSONException) {
val error = "Failed to parse registration response: ${e.message}"
Log.e(TAG, error)
Result.failure(IOException(error))
}
}
val error = "Failed to register app: ${response.code} ${response.message}"
Log.e(TAG, "$error - Response: ${response.body.string()}")
return Result.failure(IOException(error))
}
}
fun registerAllApps(context: Context) { fun registerAllApps(context: Context) {
val config = PrismPreferences(context).getPrismServerConfig() val config = PrismPreferences(context).getPrismServerConfig()
if (config == null) { if (config == null) {
@ -166,7 +166,7 @@ object PrismServerClient {
} }
val store = PrismPreferences(context) val store = PrismPreferences(context)
CoroutineScope(Dispatchers.IO).launch { AppScope.launch {
try { try {
val db = DatabaseFactory.getDb(context) val db = DatabaseFactory.getDb(context)
val apps = db.listApps() val apps = db.listApps()
@ -191,17 +191,14 @@ object PrismServerClient {
registerApp( registerApp(
context, context,
app.connectorToken, WebPushRegistration(
appName, connectorToken = app.connectorToken,
endpoint, appName = appName,
webpushUrl = endpoint,
vapidPrivateKey = storedVapidPrivateKey, vapidPrivateKey = storedVapidPrivateKey,
p256dh = keys?.third, p256dh = keys?.p256dh,
auth = keys?.second?.let { authBytes -> auth = keys?.authSecret?.toBase64Url()
android.util.Base64.encodeToString(
authBytes,
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP
) )
}
) )
} ?: run { } ?: run {
Log.w(TAG, "Skipping app ${app.title} - no endpoint available") Log.w(TAG, "Skipping app ${app.title} - no endpoint available")
@ -221,8 +218,6 @@ object PrismServerClient {
onSuccess: () -> Unit = {}, onSuccess: () -> Unit = {},
onError: (String) -> Unit = {} onError: (String) -> Unit = {}
) { ) {
Log.d(TAG, "deleteApp called for connectorToken: $connectorToken")
val config = if (serverUrl != null && apiKey != null) { val config = if (serverUrl != null && apiKey != null) {
serverUrl to apiKey serverUrl to apiKey
} else { } else {
@ -240,7 +235,6 @@ object PrismServerClient {
?: getSubscriptionIdFromDb(context, connectorToken)?.also { ?: getSubscriptionIdFromDb(context, connectorToken)?.also {
store.setSubscriptionId(connectorToken, it) store.setSubscriptionId(connectorToken, it)
} }
Log.d(TAG, "Retrieved subscriptionId: $subscriptionId for token: $connectorToken")
if (subscriptionId == null) { if (subscriptionId == null) {
Log.w(TAG, "No subscription ID found for token: $connectorToken") Log.w(TAG, "No subscription ID found for token: $connectorToken")
@ -248,7 +242,7 @@ object PrismServerClient {
return return
} }
CoroutineScope(Dispatchers.IO).launch { AppScope.launch {
try { try {
val request = Request.Builder() val request = Request.Builder()
.url("$url/api/v1/webpush/subscriptions/$subscriptionId") .url("$url/api/v1/webpush/subscriptions/$subscriptionId")
@ -256,13 +250,10 @@ object PrismServerClient {
.delete() .delete()
.build() .build()
Log.d(TAG, "Deleting subscription from Prism server: $subscriptionId")
HttpClientFactory.shared.newCall(request).execute().use { response -> HttpClientFactory.shared.newCall(request).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
store.removeSubscriptionId(connectorToken) store.removeSubscriptionId(connectorToken)
store.removeRegisteredEndpoint(connectorToken) store.removeRegisteredEndpoint(connectorToken)
Log.d(TAG, "Successfully deleted subscription: $subscriptionId")
withContext(Dispatchers.Main) { onSuccess() } withContext(Dispatchers.Main) { onSuccess() }
} else { } else {
val error = "Failed to delete subscription: ${response.code} ${response.message}" val error = "Failed to delete subscription: ${response.code} ${response.message}"
@ -297,7 +288,7 @@ object PrismServerClient {
} }
private fun getVapidPrivateKeyFromDescription(description: String?): String? = private fun getVapidPrivateKeyFromDescription(description: String?): String? =
DescriptionParser.extractValue(description, VAPID_PRIVATE_DESC_PREFIX) DescriptionParser.extractValue(description, DescriptionParser.VAPID_PRIVATE_KEY_PREFIX)
private fun isValidVapidPrivateKey(privateKey: String): Boolean = try { private fun isValidVapidPrivateKey(privateKey: String): Boolean = try {
Base64.decode(privateKey, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).size == 32 Base64.decode(privateKey, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).size == 32
@ -322,7 +313,7 @@ object PrismServerClient {
} }
val (url, key) = config val (url, key) = config
CoroutineScope(Dispatchers.IO).launch { AppScope.launch {
try { try {
val db = DatabaseFactory.getDb(context) val db = DatabaseFactory.getDb(context)
val apps = db.listApps() val apps = db.listApps()
@ -346,7 +337,7 @@ object PrismServerClient {
onSuccess: () -> Unit, onSuccess: () -> Unit,
onError: (String) -> Unit onError: (String) -> Unit
) { ) {
CoroutineScope(Dispatchers.IO).launch { AppScope.launch {
try { try {
val healthUrl = "$serverUrl/api/v1/health" val healthUrl = "$serverUrl/api/v1/health"
val request = Request.Builder() val request = Request.Builder()
@ -384,7 +375,7 @@ object PrismServerClient {
} }
val (serverUrl, apiKey) = config val (serverUrl, apiKey) = config
CoroutineScope(Dispatchers.IO).launch { AppScope.launch {
try { try {
val request = Request.Builder() val request = Request.Builder()
.url("$serverUrl/api/v1/apps") .url("$serverUrl/api/v1/apps")

View file

@ -13,7 +13,6 @@ import androidx.lifecycle.viewModelScope
import app.lonecloud.prism.DatabaseFactory import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.EncryptionKeyStore import app.lonecloud.prism.EncryptionKeyStore
import app.lonecloud.prism.PrismPreferences import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.PrismServerClient
import app.lonecloud.prism.activities.ui.InstalledApp import app.lonecloud.prism.activities.ui.InstalledApp
import app.lonecloud.prism.activities.ui.MainUiState import app.lonecloud.prism.activities.ui.MainUiState
import app.lonecloud.prism.utils.DescriptionParser import app.lonecloud.prism.utils.DescriptionParser
@ -37,10 +36,6 @@ class MainViewModel(
val messenger: InternalMessenger?, val messenger: InternalMessenger?,
val application: Application? = null val application: Application? = null
) : ViewModel() { ) : ViewModel() {
private companion object {
private const val VAPID_PRIVATE_DESC_PREFIX = "vp:"
}
constructor(requireBatteryOpt: Boolean, messenger: InternalMessenger?, application: Application) : this( constructor(requireBatteryOpt: Boolean, messenger: InternalMessenger?, application: Application) : this(
mainUiState = MainUiState( mainUiState = MainUiState(
prismServerConfigured = PrismPreferences(application).getPrismServerConfig() != null prismServerConfigured = PrismPreferences(application).getPrismServerConfig() != null
@ -90,17 +85,13 @@ class MainViewModel(
fun deleteSelection() { fun deleteSelection() {
viewModelScope.launch { viewModelScope.launch {
Log.d(TAG, "deleteSelection called")
application?.let { app -> application?.let { app ->
val selectedTokens = registrationsViewModel.state.list val selectedTokens = registrationsViewModel.state.list
.filter { it.selected } .filter { it.selected }
.map { it.token } .map { it.token }
Log.d(TAG, "Deleting ${selectedTokens.size} apps: $selectedTokens")
selectedTokens.forEach { token -> selectedTokens.forEach { token ->
if (token.startsWith("manual_app_")) { if (token.startsWith("manual_app_")) {
PrismServerClient.deleteApp(app, token)
ManualAppNotifications.deleteChannelForToken(app, token) ManualAppNotifications.deleteChannelForToken(app, token)
} }
@ -112,6 +103,7 @@ class MainViewModel(
} }
registrationsViewModel.unselectAll() registrationsViewModel.unselectAll()
refreshRegistrations()
} }
} }
} }
@ -220,7 +212,7 @@ class MainViewModel(
val descriptionParts = mutableListOf("target:$targetPackageName") val descriptionParts = mutableListOf("target:$targetPackageName")
description?.takeIf { it.isNotBlank() }?.let { descriptionParts.add(it) } description?.takeIf { it.isNotBlank() }?.let { descriptionParts.add(it) }
descriptionParts.add("$VAPID_PRIVATE_DESC_PREFIX${vapidKeys.privateKey}") descriptionParts.add("${DescriptionParser.VAPID_PRIVATE_KEY_PREFIX}${vapidKeys.privateKey}")
val fullDescription = descriptionParts.joinToString("|") val fullDescription = descriptionParts.joinToString("|")
val keyStore = EncryptionKeyStore(app) val keyStore = EncryptionKeyStore(app)
@ -258,7 +250,6 @@ class MainViewModel(
viewModelScope.launch { viewModelScope.launch {
application?.let { app -> application?.let { app ->
if (token.startsWith("manual_app_")) { if (token.startsWith("manual_app_")) {
PrismServerClient.deleteApp(app, token)
ManualAppNotifications.deleteChannelForToken(app, token) ManualAppNotifications.deleteChannelForToken(app, token)
} }

View file

@ -28,8 +28,6 @@ import androidx.core.graphics.drawable.toBitmap
import app.lonecloud.prism.R import app.lonecloud.prism.R
import app.lonecloud.prism.utils.DescriptionParser import app.lonecloud.prism.utils.DescriptionParser
data class PrismServerApp(val name: String, val matchedInstalledApp: InstalledApp? = null)
@Composable @Composable
fun AppPickerScreen( fun AppPickerScreen(
apps: List<InstalledApp>, apps: List<InstalledApp>,
@ -135,7 +133,9 @@ fun AppPickerScreen(
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (showContent && prismAppsLoaded && prismServerApps.isNotEmpty() && onSelectPrismApp != null && searchQuery.isBlank()) { val showPrismAppsSection = showContent && prismAppsLoaded &&
prismServerApps.isNotEmpty() && onSelectPrismApp != null && searchQuery.isBlank()
if (showPrismAppsSection) {
item { item {
Text( Text(
text = stringResource(R.string.from_your_server), text = stringResource(R.string.from_your_server),

View file

@ -0,0 +1,3 @@
package app.lonecloud.prism.activities.ui
data class PrismServerApp(val name: String, val matchedInstalledApp: InstalledApp? = null)

View file

@ -34,6 +34,7 @@ import app.lonecloud.prism.utils.ManualAppNotifications
import app.lonecloud.prism.utils.TAG import app.lonecloud.prism.utils.TAG
import app.lonecloud.prism.utils.WebPushDecryptor import app.lonecloud.prism.utils.WebPushDecryptor
import app.lonecloud.prism.utils.WebPushEncryptionKeys import app.lonecloud.prism.utils.WebPushEncryptionKeys
import app.lonecloud.prism.utils.toBase64Url
import java.util.Calendar import java.util.Calendar
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import okhttp3.Request import okhttp3.Request
@ -69,6 +70,7 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
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 $text") Log.d(TAG, "Couldn't deserialize $text")
return
} }
Log.d(TAG, "New message: ${message::class.java.simpleName}") Log.d(TAG, "New message: ${message::class.java.simpleName}")
lastEventDate = Calendar.getInstance() lastEventDate = Calendar.getInstance()
@ -129,6 +131,24 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
} }
} }
private fun decryptNotificationData(
channelID: String,
encryptedData: ByteArray
): ByteArray {
val keys = EncryptionKeyStore(context).getKeys(channelID) ?: run {
Log.d(TAG, "No encryption keys found for manual app $channelID, message may be unencrypted")
return encryptedData
}
val publicKeyBytes = Base64.decode(keys.p256dh, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
return try {
WebPushDecryptor.decrypt(encryptedData, keys.privateKey, publicKeyBytes, keys.authSecret)
?: encryptedData.also { Log.e(TAG, "Decryption failed for channel $channelID") }
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Decryption error for channel $channelID: ${e.message}")
encryptedData
}
}
private fun onNotification(webSocket: WebSocket, message: ServerMessage.Notification) { private fun onNotification(webSocket: WebSocket, message: ServerMessage.Notification) {
val encryptedData = Base64.decode(message.data, Base64.URL_SAFE) val encryptedData = Base64.decode(message.data, Base64.URL_SAFE)
@ -144,34 +164,7 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
} }
val decryptedData = if (app != null) { val decryptedData = if (app != null) {
val keyStore = EncryptionKeyStore(context) decryptNotificationData(message.channelID, encryptedData)
val keys = keyStore.getKeys(message.channelID)
if (keys != null) {
val (privateKeyBytes, authSecret, publicKey) = keys
try {
val publicKeyBytes = Base64.decode(publicKey, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val decrypted = WebPushDecryptor.decrypt(
encryptedData,
privateKeyBytes,
publicKeyBytes,
authSecret
)
decrypted ?: run {
Log.e(TAG, "Decryption failed for channel ${message.channelID}")
encryptedData
}
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Decryption error for channel ${message.channelID}: ${e.message}")
encryptedData
}
} else {
Log.d(TAG, "No encryption keys found for manual app ${message.channelID}, message may be unencrypted")
encryptedData
}
} else { } else {
encryptedData encryptedData
} }
@ -231,7 +224,7 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
if (app != null) { if (app != null) {
Log.d(TAG, "Auto-registering manual app '${app.title}' with Prism server") Log.d(TAG, "Auto-registering manual app '${app.title}' with Prism server")
val vapidPrivateKey = DescriptionParser.extractValue(app.description, VAPID_PRIVATE_DESC_PREFIX) val vapidPrivateKey = DescriptionParser.extractValue(app.description, DescriptionParser.VAPID_PRIVATE_KEY_PREFIX)
?: PrismPreferences(context).getVapidPrivateKey(app.connectorToken) ?: PrismPreferences(context).getVapidPrivateKey(app.connectorToken)
if (vapidPrivateKey.isNullOrBlank()) { if (vapidPrivateKey.isNullOrBlank()) {
handleManualRegistrationFailure( handleManualRegistrationFailure(
@ -268,14 +261,13 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
PrismServerClient.registerApp( PrismServerClient.registerApp(
context, context,
app.connectorToken, PrismServerClient.WebPushRegistration(
app.title ?: "Unknown App", connectorToken = app.connectorToken,
message.pushEndpoint, appName = app.title ?: "Unknown App",
webpushUrl = message.pushEndpoint,
vapidPrivateKey = vapidPrivateKey, vapidPrivateKey = vapidPrivateKey,
p256dh = keys.third, p256dh = keys.p256dh,
auth = android.util.Base64.encodeToString( auth = keys.authSecret.toBase64Url()
keys.second,
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP
), ),
onSuccess = { onSuccess = {
Log.d(TAG, "Successfully registered '${app.title}' with Prism server") Log.d(TAG, "Successfully registered '${app.title}' with Prism server")
@ -352,6 +344,11 @@ 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)) {
Log.d(TAG, "Channel ${message.channelID} is pending deletion, skipping re-registration")
PrismPreferences(context).removePendingChannelDeletion(message.channelID)
return
}
Log.w(TAG, "Received unregister for manual channel ${message.channelID}; re-registering instead of deleting app") Log.w(TAG, "Received unregister for manual channel ${message.channelID}; re-registering instead of deleting app")
ClientMessage.Register( ClientMessage.Register(
channelID = message.channelID, channelID = message.channelID,
@ -430,7 +427,6 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
} }
companion object { companion object {
private const val VAPID_PRIVATE_DESC_PREFIX = "vp:"
var lastEventDate: Calendar? = null var lastEventDate: Calendar? = null
var waitingPong = AtomicBoolean(false) var waitingPong = AtomicBoolean(false)
fun destroy() = SourceManager.removeSource() fun destroy() = SourceManager.removeSource()

View file

@ -4,13 +4,12 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import app.lonecloud.prism.AppScope
import app.lonecloud.prism.PrismPreferences 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 java.io.IOException import java.io.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
@ -46,7 +45,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
} }
val pendingResult = goAsync() val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch { AppScope.launch {
try { try {
executeAction(context, actionEndpoint, actionMethod, data) executeAction(context, actionEndpoint, actionMethod, data)
} catch (e: IOException) { } catch (e: IOException) {

View file

@ -9,6 +9,7 @@
package app.lonecloud.prism.services package app.lonecloud.prism.services
import android.content.Context import android.content.Context
import android.content.Intent
import android.util.Log import android.util.Log
import app.lonecloud.prism.api.MessageSender import app.lonecloud.prism.api.MessageSender
import app.lonecloud.prism.api.ServerConnection import app.lonecloud.prism.api.ServerConnection
@ -36,6 +37,16 @@ class FgService : ForegroundService() {
override fun shouldAbortNewSync(): Boolean = SourceManager.isRunningWithoutFailure override fun shouldAbortNewSync(): Boolean = SourceManager.isRunningWithoutFailure
// Force a fresh DB read before the base class checks oneOrMore() to avoid stale
// in-memory count from a previous start where the DB was empty (e.g. fresh install).
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
registrationCounter.refresh(this)
return super.onStartCommand(intent, flags, startId)
}
// The actual connection check is delegated to shouldAbortNewSync() via SourceManager.
// The base class splits these concerns: isConnected() gates reconnect scheduling,
// while shouldAbortNewSync() gates whether a new sync attempt should proceed.
override fun isConnected(): Boolean = true override fun isConnected(): Boolean = true
override fun sync(releaseLock: () -> Unit) { override fun sync(releaseLock: () -> Unit) {

View file

@ -46,7 +46,7 @@ class RestartWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params
} }
companion object : WorkerCompanion(RestartWorker::class.java) { companion object : WorkerCompanion(RestartWorker::class.java) {
private val lock = Object() private val lock = Any()
override fun canRun(context: Context): Boolean = !PrismPreferences(context).migrated override fun canRun(context: Context): Boolean = !PrismPreferences(context).migrated

View file

@ -3,6 +3,8 @@ package app.lonecloud.prism.utils
object DescriptionParser { object DescriptionParser {
private const val DELIMITER = "|" private const val DELIMITER = "|"
const val VAPID_PRIVATE_KEY_PREFIX = "vp:"
fun extractValue(description: String?, prefix: String): String? = description fun extractValue(description: String?, prefix: String): String? = description
?.split(DELIMITER) ?.split(DELIMITER)
?.firstOrNull { it.startsWith(prefix) } ?.firstOrNull { it.startsWith(prefix) }

View file

@ -19,6 +19,8 @@ import app.lonecloud.prism.api.data.NotificationPayload
import app.lonecloud.prism.receivers.NotificationActionReceiver import app.lonecloud.prism.receivers.NotificationActionReceiver
import app.lonecloud.prism.receivers.NotificationDismissReceiver import app.lonecloud.prism.receivers.NotificationDismissReceiver
import app.lonecloud.prism.services.MainRegistrationCounter import app.lonecloud.prism.services.MainRegistrationCounter
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import org.unifiedpush.android.distributor.Database import org.unifiedpush.android.distributor.Database
private const val NOTIFICATION_BASE_ID = 52000 private const val NOTIFICATION_BASE_ID = 52000
@ -26,10 +28,10 @@ private const val MANUAL_CHANNEL_PREFIX = "manual_app_"
object ManualAppNotifications { object ManualAppNotifications {
private val notificationIds = mutableMapOf<String, Int>() private val notificationIds = ConcurrentHashMap<String, Int>()
private val notificationConnectorTokens = mutableMapOf<String, String>() private val notificationConnectorTokens = ConcurrentHashMap<String, String>()
private val summaryNotificationIds = mutableMapOf<String, Int>() private val summaryNotificationIds = ConcurrentHashMap<String, Int>()
private var nextNotificationId = NOTIFICATION_BASE_ID private val nextNotificationId = AtomicInteger(NOTIFICATION_BASE_ID)
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
fun showNotification( fun showNotification(
@ -224,7 +226,7 @@ object ManualAppNotifications {
private fun resolveNotificationTag(payloadTag: String, channelID: String): String { private fun resolveNotificationTag(payloadTag: String, channelID: String): String {
if (payloadTag.isNotBlank()) return payloadTag if (payloadTag.isNotBlank()) return payloadTag
return "auto-$channelID-${System.currentTimeMillis()}-${++nextNotificationId}" return "auto-$channelID-${System.currentTimeMillis()}-${nextNotificationId.incrementAndGet()}"
} }
private fun createNotificationChannel( private fun createNotificationChannel(
@ -270,11 +272,11 @@ object ManualAppNotifications {
private fun channelIdForToken(connectorToken: String): String = "$MANUAL_CHANNEL_PREFIX$connectorToken" private fun channelIdForToken(connectorToken: String): String = "$MANUAL_CHANNEL_PREFIX$connectorToken"
private fun getNotificationId(tag: String): Int = notificationIds.getOrPut(tag) { private fun getNotificationId(tag: String): Int = notificationIds.getOrPut(tag) {
nextNotificationId++ nextNotificationId.incrementAndGet()
} }
private fun getSummaryNotificationId(connectorToken: String): Int = summaryNotificationIds.getOrPut(connectorToken) { private fun getSummaryNotificationId(connectorToken: String): Int = summaryNotificationIds.getOrPut(connectorToken) {
nextNotificationId++ nextNotificationId.incrementAndGet()
} }
private fun postGroupSummary( private fun postGroupSummary(

View file

@ -41,3 +41,6 @@ object WebPushEncryptionKeys {
private fun base64UrlEncode(data: ByteArray): String = private fun base64UrlEncode(data: ByteArray): String =
Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
} }
fun ByteArray.toBase64Url(): String =
Base64.encodeToString(this, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)

View file

@ -1,9 +1,12 @@
<network-security-config> <network-security-config>
<base-config <base-config cleartextTrafficPermitted="true">
cleartextTrafficPermitted="true">
<trust-anchors> <trust-anchors>
<certificates src="system" /> <certificates src="system" />
<certificates src="user" />
</trust-anchors> </trust-anchors>
</base-config> </base-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config> </network-security-config>

View file

@ -1,5 +1,5 @@
build: build:
maxIssues: 1000 maxIssues: 0
style: style:
MagicNumber: MagicNumber:
@ -16,6 +16,8 @@ style:
active: false active: false
WildcardImport: WildcardImport:
active: false active: false
ReturnCount:
active: false
naming: naming:
FunctionNaming: FunctionNaming:
@ -27,6 +29,11 @@ complexity:
active: false active: false
TooManyFunctions: TooManyFunctions:
active: false active: false
CyclomaticComplexMethod:
ignoreAnnotated: ['Composable']
LongParameterList:
functionThreshold: 9
ignoreAnnotated: ['Composable']
potential-bugs: potential-bugs:
UnsafeCast: UnsafeCast:

View file

@ -17,6 +17,7 @@ materialIconsCore = "1.7.8"
okhttp = "5.3.2" okhttp = "5.3.2"
uiTooling = "1.10.4" uiTooling = "1.10.4"
navigationCompose = "2.9.7" navigationCompose = "2.9.7"
security-crypto = "1.1.0"
[libraries] [libraries]
@ -38,6 +39,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
detekt-gradle = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt"} detekt-gradle = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt"}
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } 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" }