mirror of
https://github.com/lone-cloud/prism-android
synced 2026-06-03 11:03:10 -07:00
code cleanup and minor improvements, fix more linting issues
This commit is contained in:
parent
69009b7447
commit
aaeb6b76a8
21 changed files with 300 additions and 232 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.0
|
0.4.0
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
app/src/main/java/app/lonecloud/prism/AppScope.kt
Normal file
7
app/src/main/java/app/lonecloud/prism/AppScope.kt
Normal 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)
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package app.lonecloud.prism.activities.ui
|
||||||
|
|
||||||
|
data class PrismServerApp(val name: String, val matchedInstalledApp: InstalledApp? = null)
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue