improving copy, better prism registration, allow clearing prism registration

This commit is contained in:
Egor 2026-02-13 23:09:28 -08:00
parent 05607cb9f1
commit e767e89ee9
18 changed files with 194 additions and 68 deletions

26
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,26 @@
# GitHub Copilot Instructions
## Code Style
- Do NOT add obvious comments that just describe what the code does
- Only add comments for complex logic, non-obvious behavior, or important context
- Prefer self-documenting code with clear variable and function names over comments
- Avoid redundant comments like "// Create button" or "// Set text color"
## Examples of BAD comments to avoid:
```kotlin
// Description with clickable link
val description = stringResource(R.string.prism_server_description)
// Get the URI handler
val uriHandler = LocalUriHandler.current
```
## Examples of GOOD comments:
```kotlin
// Retry with exponential backoff, max 5 attempts
for (attempt in 1..5) { ... }
// WorkAround: Android 12+ requires explicit mutation flag
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_MUTABLE)
```

View file

@ -6,4 +6,4 @@
</div>
A notification provider to use with [Prism](https://github.com/lone-cloud/prism)
A notification provider for [Prism](https://github.com/lone-cloud/prism) and [UnifiedPush](https://unifiedpush.org/) applications.

View file

@ -5,7 +5,7 @@ import androidx.core.content.edit
import org.unifiedpush.android.distributor.MigrationManager
import org.unifiedpush.android.distributor.Store
class AppStore(context: Context) :
class PrismPreferences(context: Context) :
Store(context, PREF_NAME),
MigrationManager.MigrationStore {
var uaid: String?

View file

@ -3,7 +3,7 @@ package app.lonecloud.prism
import android.content.Context
import android.util.Base64
import android.util.Log
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.DatabaseFactory
import java.io.IOException
import java.util.concurrent.TimeUnit
@ -23,11 +23,7 @@ object PrismServerClient {
.readTimeout(10, TimeUnit.SECONDS)
.build()
private fun getAuthHeader(apiKey: String): String {
val credentials = ":$apiKey"
val encoded = Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP)
return "Basic $encoded"
}
private fun getAuthHeader(apiKey: String): String = "Bearer $apiKey"
fun registerApp(
context: Context,
@ -39,7 +35,7 @@ object PrismServerClient {
onSuccess: () -> Unit = {},
onError: (String) -> Unit = {}
) {
val store = AppStore(context)
val store = PrismPreferences(context)
val serverUrl = store.prismServerUrl
val apiKey = store.prismApiKey
@ -86,7 +82,7 @@ object PrismServerClient {
}
fun registerAllApps(context: Context) {
val store = AppStore(context)
val store = PrismPreferences(context)
val serverUrl = store.prismServerUrl
val apiKey = store.prismApiKey
@ -128,7 +124,7 @@ object PrismServerClient {
onSuccess: () -> Unit = {},
onError: (String) -> Unit = {}
) {
val store = AppStore(context)
val store = PrismPreferences(context)
val url = serverUrl ?: store.prismServerUrl
val key = apiKey ?: store.prismApiKey
@ -170,7 +166,7 @@ object PrismServerClient {
serverUrl: String? = null,
apiKey: String? = null
) {
val store = AppStore(context)
val store = PrismPreferences(context)
val url = serverUrl ?: store.prismServerUrl
val key = apiKey ?: store.prismApiKey
@ -206,8 +202,9 @@ object PrismServerClient {
) {
CoroutineScope(Dispatchers.IO).launch {
try {
val healthUrl = "$serverUrl/api/health"
val request = Request.Builder()
.url(serverUrl)
.url(healthUrl)
.addHeader("Authorization", getAuthHeader(apiKey))
.get()
.build()

View file

@ -12,8 +12,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.EncryptionKeyStore
import app.lonecloud.prism.PrismServerClient
import app.lonecloud.prism.activities.ui.InstalledApp
@ -41,8 +41,8 @@ class MainViewModel(
) : ViewModel() {
constructor(requireBatteryOpt: Boolean, messenger: InternalMessenger?, application: Application) : this(
mainUiState = MainUiState(
prismServerConfigured = !AppStore(application).prismServerUrl.isNullOrBlank() &&
!AppStore(application).prismApiKey.isNullOrBlank()
prismServerConfigured = !PrismPreferences(application).prismServerUrl.isNullOrBlank() &&
!PrismPreferences(application).prismApiKey.isNullOrBlank()
),
batteryOptimisationViewModel = BatteryOptimisationViewModel(requireBatteryOpt, messenger),
registrationsViewModel = RegistrationsViewModel(

View file

@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.PrismServerClient
import app.lonecloud.prism.activities.ui.SettingsState
import app.lonecloud.prism.receivers.PrismConfigReceiver
@ -33,7 +33,7 @@ class SettingsViewModel(
fun toggleShowToasts() {
viewModelScope.launch {
state = state.copy(showToasts = !state.showToasts)
application?.let { AppStore(it).showToasts = state.showToasts }
application?.let { PrismPreferences(it).showToasts = state.showToasts }
messenger?.sendIMessage(InternalOpcode.SHOW_TOASTS_SET, if (state.showToasts) 1 else 0)
}
}
@ -43,7 +43,7 @@ class SettingsViewModel(
val trimmedUrl = url.trim()
state = state.copy(prismServerUrl = trimmedUrl)
application?.let {
AppStore(it).prismServerUrl = trimmedUrl.ifBlank { null }
PrismPreferences(it).prismServerUrl = trimmedUrl.ifBlank { null }
val intent = Intent(PrismConfigReceiver.ACTION_SET_PRISM_SERVER_URL).apply {
putExtra(PrismConfigReceiver.EXTRA_URL, trimmedUrl)
@ -65,7 +65,7 @@ class SettingsViewModel(
val trimmedKey = apiKey.trim()
state = state.copy(prismApiKey = trimmedKey)
application?.let {
AppStore(it).prismApiKey = trimmedKey.ifBlank { null }
PrismPreferences(it).prismApiKey = trimmedKey.ifBlank { null }
val intent = Intent(PrismConfigReceiver.ACTION_SET_PRISM_API_KEY).apply {
putExtra(PrismConfigReceiver.EXTRA_API_KEY, trimmedKey)

View file

@ -6,18 +6,18 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
import kotlinx.coroutines.launch
import org.unifiedpush.android.distributor.ipc.InternalMessenger
import org.unifiedpush.android.distributor.ipc.InternalOpcode
class ThemeViewModel(val messenger: InternalMessenger?, val application: Application?) : ViewModel() {
var dynamicColors by mutableStateOf(application?.let { AppStore(it).dynamicColors } ?: false)
var dynamicColors by mutableStateOf(application?.let { PrismPreferences(it).dynamicColors } ?: false)
fun toggleDynamicColors() {
viewModelScope.launch {
dynamicColors = !dynamicColors
application?.let { AppStore(it).dynamicColors = dynamicColors }
application?.let { PrismPreferences(it).dynamicColors = dynamicColors }
messenger?.sendIMessage(InternalOpcode.THEME_DYN_SET, if (dynamicColors) 1 else 0)
}
}

View file

@ -24,7 +24,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.R
import app.lonecloud.prism.activities.MainViewModel
import app.lonecloud.prism.activities.PreviewFactory
@ -89,7 +89,7 @@ fun MainScreen(
"RefreshRegistrations" -> viewModel.refreshRegistrations()
"UpdatePrismServerConfigured" -> {
viewModel.application?.let { app ->
val store = AppStore(app)
val store = PrismPreferences(app)
viewModel.updatePrismServerConfigured(
!store.prismServerUrl.isNullOrBlank() &&
!store.prismApiKey.isNullOrBlank()

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@ -24,7 +25,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import app.lonecloud.prism.R
@ -52,7 +58,7 @@ fun PrismServerConfigButton(
)
Text(
text = if (currentUrl.isNotBlank()) {
stringResource(R.string.prism_server_configured, currentUrl)
currentUrl
} else {
stringResource(R.string.prism_server_not_configured)
},
@ -87,7 +93,9 @@ fun PrismServerConfigDialog(
var isTesting by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<String?>(null) }
var showServerChangeWarning by remember { mutableStateOf(false) }
var showClearConfirmation by remember { mutableStateOf(false) }
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
AlertDialog(
onDismissRequest = onDismiss,
@ -97,6 +105,46 @@ fun PrismServerConfigDialog(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
val description = stringResource(R.string.prism_server_description)
val repoUrl = stringResource(R.string.prism_server_repo_link)
val fullText = "$description\n\n$repoUrl"
val annotatedString = buildAnnotatedString {
append(description)
append("\n\n")
val linkStart = length
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
)
) {
append(repoUrl)
}
addStringAnnotation(
tag = "URL",
annotation = repoUrl,
start = linkStart,
end = length
)
}
ClickableText(
text = annotatedString,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
onClick = { offset ->
annotatedString.getStringAnnotations(
tag = "URL",
start = offset,
end = offset
).firstOrNull()?.let { annotation ->
uriHandler.openUri(annotation.item)
}
}
)
OutlinedTextField(
value = url,
onValueChange = {
@ -163,7 +211,7 @@ fun PrismServerConfigDialog(
.count { it.description?.startsWith("target:") == true }
if (manualAppsCount > 0) {
val oldUrl = initialUrl
val oldKey = app.lonecloud.prism.AppStore(context).prismApiKey
val oldKey = app.lonecloud.prism.PrismPreferences(context).prismApiKey
if (!oldUrl.isNullOrBlank() && !oldKey.isNullOrBlank()) {
app.lonecloud.prism.PrismServerClient.deleteAllApps(
context,
@ -197,12 +245,76 @@ fun PrismServerConfigDialog(
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !isTesting) {
Text(stringResource(R.string.cancel_button))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (initialUrl.isNotBlank()) {
TextButton(
onClick = { showClearConfirmation = true },
enabled = !isTesting
) {
Text(
text = stringResource(R.string.clear_server_button),
color = MaterialTheme.colorScheme.error
)
}
}
TextButton(onClick = onDismiss, enabled = !isTesting) {
Text(stringResource(R.string.cancel_button))
}
}
}
)
if (showClearConfirmation) {
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val manualAppsCount = db.listApps()
.count { it.description?.startsWith("target:") == true }
AlertDialog(
onDismissRequest = { showClearConfirmation = false },
title = { Text(stringResource(R.string.clear_server_confirm_title)) },
text = {
Text(
if (manualAppsCount > 0) {
stringResource(R.string.clear_server_confirm_message_with_apps, manualAppsCount)
} else {
stringResource(R.string.clear_server_confirm_message_no_apps)
}
)
},
confirmButton = {
Button(
onClick = {
if (initialUrl.isNotBlank()) {
val oldKey = app.lonecloud.prism.PrismPreferences(context).prismApiKey
if (!oldKey.isNullOrBlank() && manualAppsCount > 0) {
app.lonecloud.prism.PrismServerClient.deleteAllApps(
context,
serverUrl = initialUrl,
apiKey = oldKey
)
}
}
onSave("", "")
showClearConfirmation = false
onDismiss()
},
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text(stringResource(R.string.clear_server_button))
}
},
dismissButton = {
TextButton(onClick = { showClearConfirmation = false }) {
Text(stringResource(R.string.cancel_button))
}
}
)
}
if (showServerChangeWarning) {
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val manualAppsCount = db.listApps()

View file

@ -28,7 +28,7 @@ fun RestartServicesPreference(onClick: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(R.string.restart_service_button),
text = stringResource(R.string.reset_connection_button),
style = MaterialTheme.typography.bodyLarge
)
}

View file

@ -1,7 +1,7 @@
package app.lonecloud.prism.activities.ui
import android.content.Context
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
data class SettingsState(
val showToasts: Boolean,
@ -10,7 +10,7 @@ data class SettingsState(
) {
companion object {
fun from(context: Context): SettingsState {
val store = AppStore(context)
val store = PrismPreferences(context)
return SettingsState(
showToasts = store.showToasts,
prismServerUrl = store.prismServerUrl ?: "",

View file

@ -6,7 +6,7 @@ import android.os.Looper
import android.util.Base64
import android.util.Log
import android.widget.Toast
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.Distributor
import app.lonecloud.prism.Distributor.sendMessage
@ -32,7 +32,7 @@ import org.unifiedpush.android.distributor.ChannelCreationStatus
class ServerConnection(private val context: Context, private val releaseLock: () -> Unit) : WebSocketListener() {
private val store = AppStore(context)
private val store = PrismPreferences(context)
fun start(): WebSocket {
val client = OkHttpClient.Builder()
@ -107,8 +107,6 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
}
db.deleteDisabledApps()
} else {
// We remove pending unregistrations
// and register pending registrations
db.listDisabledChannelIds().forEach {
Log.d(TAG, "Hello, unregistering $it")
ClientMessage.Unregister(channelID = it).send(webSocket)
@ -226,7 +224,6 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
if (failToUseUrlCandidate(context)) return
if (!shouldRestart()) return
if (SourceManager.addFail(context, webSocket)) {
// If null, we keep the worker with its 16min
val delay = SourceManager.getTimeout() ?: return
Log.d(TAG, "Retrying in $delay ms")
RestartWorker.run(context, delay = delay)
@ -262,7 +259,6 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
}
if (!NetworkCallbackFactory.hasInternet()) {
Log.d(TAG, "No Internet: do not restart")
// It will be restarted when Internet is back
return false
}
return true

View file

@ -3,12 +3,12 @@ package app.lonecloud.prism.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
class PrismConfigReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val store = AppStore(context)
val store = PrismPreferences(context)
when (intent.action) {
ACTION_SET_PRISM_SERVER_URL -> {

View file

@ -3,7 +3,7 @@
package app.lonecloud.prism.receivers
import android.content.Context
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.Distributor
import app.lonecloud.prism.callback.NetworkCallbackFactory
import org.unifiedpush.android.distributor.receiver.DistributorReceiver
@ -16,5 +16,5 @@ class RegisterBroadcastReceiver : DistributorReceiver() {
override fun hasInternet(context: Context): Boolean = NetworkCallbackFactory.hasInternet()
override fun showToasts(context: Context): Boolean = AppStore(context).showToasts
override fun showToasts(context: Context): Boolean = PrismPreferences(context).showToasts
}

View file

@ -1,11 +1,11 @@
package app.lonecloud.prism.services
import android.content.Context
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.Distributor
import org.unifiedpush.android.distributor.MigrationManager as MManager
class MigrationManager : MManager() {
override val distrib = Distributor
override fun getStore(context: Context): MigrationStore = AppStore(context)
override fun getStore(context: Context): MigrationStore = PrismPreferences(context)
}

View file

@ -3,7 +3,7 @@ package app.lonecloud.prism.services
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.core.graphics.drawable.toBitmap
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.Distributor
import org.unifiedpush.android.distributor.Database
@ -28,7 +28,7 @@ class PrismInternalService : InternalService() {
override val distributor: UnifiedPushDistributor = Distributor
override val db: Database by lazy { DatabaseFactory.getDb(this) }
private val appStore by lazy { AppStore(this) }
private val appStore by lazy { PrismPreferences(this) }
override var themeDynamicColors: Boolean
get() = appStore.dynamicColors
@ -44,9 +44,7 @@ class PrismInternalService : InternalService() {
override fun getDebugInfo(): String = "Prism Distributor"
override fun runAppMigration() {
// No app migration needed for Prism currently
}
override fun runAppMigration() {}
override fun account(): IAccount = object : IAccount {
override fun get(): String? = null
@ -55,22 +53,20 @@ class PrismInternalService : InternalService() {
}
override fun api(): IApi = object : IApi {
override fun newPushServer(url: String?) {
// Prism uses fixed Mozilla server, but can be customized
}
override fun newPushServer(url: String?) {}
override fun getUrl(): String = appStore.apiUrl
}
override fun registrations() = object : IRegistrations {
override fun delete(registrations: List<String>) {
registrations.forEach { token ->
distributor.deleteApp(context, token)
distributor.deleteApp(this@PrismInternalService, token)
}
}
override fun list(): List<App> = db
.listApps().map {
val pm = context.packageManager
val pm = this@PrismInternalService.packageManager
val isManualApp = it.description?.startsWith("target:") == true
val targetPackage = if (isManualApp) {
@ -96,22 +92,20 @@ class PrismInternalService : InternalService() {
vapidKey = it.vapidKey,
title = displayTitle,
msgCount = it.msgCount,
description = if (it.packageName == context.packageName) {
description = if (it.packageName == this@PrismInternalService.packageName) {
Description.LocalChannel
} else {
Description.StringDescription(packageToResolve)
},
icon = getApplicationIcon(packageToResolve)?.toBitmap(),
isLocal = it.packageName == context.packageName
isLocal = it.packageName == this@PrismInternalService.packageName
)
}
override fun copyEndpoint(token: String?) {
super@PrismInternalService.registrations().copyEndpoint(token)
}
override fun addLocal(title: String) {
super@PrismInternalService.registrations().addLocal(title)
}
}
}

View file

@ -5,7 +5,7 @@ package app.lonecloud.prism.services
import android.content.Context
import android.util.Log
import androidx.work.*
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.Distributor
import app.lonecloud.prism.api.MessageSender
import app.lonecloud.prism.callback.NetworkCallbackFactory
@ -20,7 +20,6 @@ class RestartWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params
*/
@Suppress("ReturnCount")
override fun doWork(): Result {
// We avoid running twice at the same time
synchronized(lock) {
Log.d(TAG, "Working [$id]")
if (!NetworkCallbackFactory.hasInternet()) {
@ -29,8 +28,6 @@ class RestartWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params
}
if (SourceManager.isRunningWithoutFailure) {
Log.d(TAG, "Running without failure")
// We send a ping, if it fails it will restart this worker, and wont
// pass this check
MessageSender.ping(applicationContext)
return Result.success()
}
@ -44,10 +41,7 @@ class RestartWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params
private val lock = Object()
override fun canRun(context: Context): Boolean {
// We don't have any credential requirement, if we don't have
// a uaid yet, it will be created during the initial sync
// So, as soon as the user hasn't migrated, we can run
return !AppStore(context).migrated
return !PrismPreferences(context).migrated
}
override fun isServiceStarted(context: Context): Boolean = FgService.isServiceStarted()

View file

@ -47,7 +47,10 @@
<string name="add_manual_app_content_description">Add manual app</string>
<string name="debug_title">Debug Information</string>
<string name="configure_server">Configure Prism Server</string>
<string name="prism_server_description">Self-host a Prism server to unlock manual app registrations. Otherwise, this distributor works normally with UnifiedPush apps.</string>
<string name="prism_server_repo_link">https://github.com/lone-cloud/prism</string>
<string name="prism_server_configured">Prism server configured</string>
<string name="prism_server_configured_with_version">%1$s (v%2$s)</string>
<string name="prism_server_not_configured">Prism server not configured</string>
<string name="prism_server_url_label">Server URL</string>
<string name="prism_server_url_placeholder">https://prism.example.com</string>
@ -57,7 +60,11 @@
<string name="connection_successful">Connection successful</string>
<string name="connection_failed">Connection failed</string>
<string name="test_and_save_button">Test and Save</string>
<string name="restart_service_button">Restart Service</string>
<string name="clear_server_button">Clear</string>
<string name="clear_server_confirm_title">Remove Prism Server?</string>
<string name="clear_server_confirm_message_no_apps">This will remove your Prism server configuration.</string>
<string name="clear_server_confirm_message_with_apps">You have %d manual app registration(s). Clearing the server will delete them from the server and remove the configuration.</string>
<string name="reset_connection_button">Reset Connection</string>
<string name="app_dropdown_show_toasts">Notify when apps register</string>
<string name="dynamic_colors_title">Dynamic Colors</string>
</resources>