mirror of
https://github.com/lone-cloud/prism-android
synced 2026-06-03 19:54:44 -07:00
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:
parent
48ba01de18
commit
d5fd3d2564
17 changed files with 239 additions and 133 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.0.0
|
1.0.1
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
app/src/main/java/app/lonecloud/prism/SecureStringPreferences.kt
Normal file
100
app/src/main/java/app/lonecloud/prism/SecureStringPreferences.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ fun AppTheme(
|
||||||
}
|
}
|
||||||
|
|
||||||
darkTheme -> darkScheme
|
darkTheme -> darkScheme
|
||||||
|
|
||||||
else -> lightScheme
|
else -> lightScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue