new intro screen for prism server, distributor dep update to latest, better adopt the material patterns instead of crappy modals

This commit is contained in:
Egor 2026-02-14 19:42:28 -08:00
parent 5fcc504121
commit 8b41645f82
25 changed files with 1120 additions and 720 deletions

View file

@ -108,6 +108,7 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.androidx.material3.android)
implementation(libs.androidx.material.icons.core)
implementation(libs.androidx.material.icons.extended)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.ui.tooling.preview.android)
implementation(libs.androidx.navigation.compose)

View file

@ -97,6 +97,14 @@ class PrismPreferences(context: Context) :
putOrRemove(PREF_PRISM_API_KEY, value)
}
var introCompleted: Boolean
get() = sharedPreferences
.getBoolean(PREF_INTRO_COMPLETED, false)
set(value) = sharedPreferences
.edit {
putBoolean(PREF_INTRO_COMPLETED, value)
}
override fun wipe() {
uaid = null
}
@ -113,5 +121,6 @@ class PrismPreferences(context: Context) :
private const val PREF_SHOW_TOASTS = "show_toasts"
private const val PREF_PRISM_SERVER_URL = "prism_server_url"
private const val PREF_PRISM_API_KEY = "prism_api_key"
private const val PREF_INTRO_COMPLETED = "intro_completed"
}
}

View file

@ -9,6 +9,7 @@ import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
@ -54,7 +55,7 @@ object PrismServerClient {
}
val request = Request.Builder()
.url("$serverUrl/webpush/app")
.url("$serverUrl/api/v1/webpush/app")
.addHeader("Authorization", getAuthHeader(apiKey))
.addHeader("Content-Type", "application/json")
.post(json.toString().toRequestBody("application/json".toMediaType()))
@ -65,17 +66,17 @@ object PrismServerClient {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Log.d(TAG, "Successfully registered app: $appName")
onSuccess()
withContext(Dispatchers.Main) { onSuccess() }
} else {
val error = "Failed to register app: ${response.code} ${response.message}"
Log.e(TAG, error)
onError(error)
withContext(Dispatchers.Main) { onError(error) }
}
}
} catch (e: IOException) {
val error = "Error registering app: ${e.message}"
Log.e(TAG, error, e)
onError(error)
withContext(Dispatchers.Main) { onError(error) }
}
}
}
@ -135,7 +136,7 @@ object PrismServerClient {
CoroutineScope(Dispatchers.IO).launch {
try {
val request = Request.Builder()
.url("$url/webpush/app/$appName")
.url("$url/api/v1/webpush/app/$appName")
.addHeader("Authorization", getAuthHeader(key))
.delete()
.build()
@ -145,17 +146,17 @@ object PrismServerClient {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Log.d(TAG, "Successfully deleted app: $appName")
onSuccess()
withContext(Dispatchers.Main) { onSuccess() }
} else {
val error = "Failed to delete app: ${response.code} ${response.message}"
Log.e(TAG, error)
onError(error)
withContext(Dispatchers.Main) { onError(error) }
}
}
} catch (e: IOException) {
val error = "Error deleting app: ${e.message}"
Log.e(TAG, error, e)
onError(error)
withContext(Dispatchers.Main) { onError(error) }
}
}
}
@ -201,7 +202,7 @@ object PrismServerClient {
) {
CoroutineScope(Dispatchers.IO).launch {
try {
val healthUrl = "$serverUrl/api/health"
val healthUrl = "$serverUrl/api/v1/health"
val request = Request.Builder()
.url(healthUrl)
.addHeader("Authorization", getAuthHeader(apiKey))
@ -210,13 +211,17 @@ object PrismServerClient {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
onSuccess()
withContext(Dispatchers.Main) { onSuccess() }
} else {
onError("Connection failed: ${response.code} ${response.message}")
withContext(Dispatchers.Main) {
onError("Connection failed: ${response.code} ${response.message}")
}
}
}
} catch (e: IOException) {
onError("Connection error: ${e.message}")
withContext(Dispatchers.Main) {
onError("Connection error: ${e.message}")
}
}
}
}

View file

@ -9,12 +9,20 @@
package app.lonecloud.prism.activities
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.viewmodel.compose.viewModel
import app.lonecloud.prism.activities.ui.App
import app.lonecloud.prism.activities.ui.theme.AppTheme
import app.lonecloud.prism.utils.TAG
import kotlin.system.exitProcess
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.unifiedpush.android.distributor.ipc.InternalMessenger
class MainActivity : ComponentActivity() {
@ -24,6 +32,11 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
messenger = InternalMessenger(this)
jobs.removeAll {
Log.d(TAG, "Cancelling exitProcess job")
it.cancel()
true
}
enableEdgeToEdge()
@ -39,6 +52,15 @@ class MainActivity : ComponentActivity() {
}
override fun onDestroy() {
Log.d(TAG, "Destroy")
super.onDestroy()
jobs += CoroutineScope(Dispatchers.Main + Job()).launch {
delay(10_000)
exitProcess(0)
}
}
companion object {
val jobs = emptyList<Job>().toMutableList()
}
}

View file

@ -6,8 +6,6 @@ import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
@ -55,14 +53,13 @@ class MainViewModel(
var mainUiState by mutableStateOf(mainUiState)
var selectedApp by mutableStateOf<InstalledApp?>(null)
private set
fun updatePrismServerConfigured(configured: Boolean) {
mainUiState = mainUiState.copy(prismServerConfigured = configured)
}
private var lastDebugClickTime by mutableLongStateOf(0L)
private var debugClickCount by mutableIntStateOf(0)
init {
loadInstalledApps()
}
@ -123,21 +120,20 @@ class MainViewModel(
mainUiState = mainUiState.copy(showAppDetails = false)
}
fun addDebugClick() {
val currentTime = System.currentTimeMillis()
if (currentTime - lastDebugClickTime < 500) {
debugClickCount++
if (debugClickCount == 5) {
mainUiState = mainUiState.copy(showDebugInfo = true)
}
} else {
debugClickCount = 1
}
lastDebugClickTime = currentTime
fun selectApp(app: InstalledApp) {
selectedApp = app
}
fun dismissDebugInfo() {
mainUiState = mainUiState.copy(showDebugInfo = false)
fun clearSelectedApp() {
selectedApp = null
}
fun addManualApp(
name: String,
packageName: String,
description: String?
) {
addApp(name, packageName, description)
}
private fun hasUnifiedPushSupport(pm: PackageManager, packageName: String): Boolean {
@ -174,14 +170,6 @@ class MainViewModel(
}
}
fun showAddAppDialog() {
mainUiState = mainUiState.copy(showAddAppDialog = true)
}
fun hideAddAppDialog() {
mainUiState = mainUiState.copy(showAddAppDialog = false)
}
fun addApp(
name: String,
targetPackageName: String,
@ -220,7 +208,6 @@ class MainViewModel(
)
refreshRegistrations()
hideAddAppDialog()
var endpoint: String?
var attempts = 0

View file

@ -38,7 +38,7 @@ class SettingsViewModel(
}
}
fun updatePrismServerUrl(url: String) {
fun updatePrismServerUrl(url: String, sendAction: Boolean = true) {
viewModelScope.launch {
val trimmedUrl = url.trim()
state = state.copy(prismServerUrl = trimmedUrl)
@ -55,12 +55,14 @@ class SettingsViewModel(
PrismServerClient.registerAllApps(it)
}
sendUiAction(it, "UpdatePrismServerConfigured")
if (sendAction) {
sendUiAction(it, "UpdatePrismServerConfigured")
}
}
}
}
fun updatePrismApiKey(apiKey: String) {
fun updatePrismApiKey(apiKey: String, sendAction: Boolean = true) {
viewModelScope.launch {
val trimmedKey = apiKey.trim()
state = state.copy(prismApiKey = trimmedKey)
@ -77,7 +79,46 @@ class SettingsViewModel(
PrismServerClient.registerAllApps(it)
}
sendUiAction(it, "UpdatePrismServerConfigured")
if (sendAction) {
sendUiAction(it, "UpdatePrismServerConfigured")
}
}
}
}
fun savePrismConfig(url: String, apiKey: String) {
viewModelScope.launch {
val trimmedUrl = url.trim()
val trimmedKey = apiKey.trim()
state = state.copy(
prismServerUrl = trimmedUrl,
prismApiKey = trimmedKey
)
application?.let { app ->
PrismPreferences(app).apply {
prismServerUrl = trimmedUrl.ifBlank { null }
prismApiKey = trimmedKey.ifBlank { null }
}
val urlIntent = Intent(PrismConfigReceiver.ACTION_SET_PRISM_SERVER_URL).apply {
putExtra(PrismConfigReceiver.EXTRA_URL, trimmedUrl)
setPackage(app.packageName)
}
app.sendBroadcast(urlIntent)
val keyIntent = Intent(PrismConfigReceiver.ACTION_SET_PRISM_API_KEY).apply {
putExtra(PrismConfigReceiver.EXTRA_API_KEY, trimmedKey)
setPackage(app.packageName)
}
app.sendBroadcast(keyIntent)
if (trimmedUrl.isNotBlank() && trimmedKey.isNotBlank()) {
PrismServerClient.registerAllApps(app)
}
sendUiAction(app, "UpdatePrismServerConfigured")
}
}
}

View file

@ -1,127 +0,0 @@
package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import app.lonecloud.prism.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddAppDialog(
installedApps: List<InstalledApp>,
onDismiss: () -> Unit,
onConfirm: (name: String, packageName: String, description: String?) -> Unit
) {
var name by remember { mutableStateOf("") }
var selectedApp by remember { mutableStateOf<InstalledApp?>(null) }
var showAppPicker by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.add_custom_app_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.app_name_label)) },
placeholder = { Text(stringResource(R.string.app_name_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedCard(
onClick = { showAppPicker = true },
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = stringResource(R.string.target_app_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = selectedApp?.appName ?: stringResource(R.string.select_an_app),
style = MaterialTheme.typography.bodyLarge
)
}
selectedApp?.icon?.let { icon ->
val bitmap = icon.toBitmap(48, 48)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = {
if (name.isNotBlank()) {
onConfirm(
name.trim(),
selectedApp?.packageName ?: "",
null
)
}
},
enabled = name.isNotBlank()
) {
Text(stringResource(R.string.add_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel_button))
}
}
)
if (showAppPicker) {
AppPickerDialog(
apps = installedApps,
onDismiss = { showAppPicker = false },
onSelect = { app ->
selectedApp = app
if (name.isBlank()) {
name = app.appName
}
showAppPicker = false
}
)
}
}

View file

@ -0,0 +1,113 @@
package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import app.lonecloud.prism.R
@Composable
fun AddAppScreen(
selectedApp: InstalledApp?,
onNavigateBack: () -> Unit,
onNavigateToAppPicker: () -> Unit,
onConfirm: (name: String, packageName: String, description: String?) -> Unit
) {
var name by remember { mutableStateOf("") }
// Auto-fill name when app is selected
androidx.compose.runtime.LaunchedEffect(selectedApp) {
if (name.isBlank() && selectedApp != null) {
name = selectedApp.appName
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.app_name_label)) },
placeholder = { Text(stringResource(R.string.app_name_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedCard(
onClick = onNavigateToAppPicker,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = stringResource(R.string.target_app_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = selectedApp?.appName ?: stringResource(R.string.select_an_app),
style = MaterialTheme.typography.bodyLarge
)
}
selectedApp?.icon?.let { icon ->
val bitmap = icon.toBitmap(48, 48)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
}
}
}
Button(
onClick = {
if (name.isNotBlank()) {
onConfirm(
name.trim(),
selectedApp?.packageName ?: "",
null
)
onNavigateBack()
}
},
enabled = name.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.add_button))
}
}
}

View file

@ -1,106 +0,0 @@
package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import app.lonecloud.prism.R
@Composable
fun AppPickerDialog(
apps: List<InstalledApp>,
onDismiss: () -> Unit,
onSelect: (InstalledApp) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
val filteredApps = remember(apps, searchQuery) {
if (searchQuery.isBlank()) {
apps
} else {
apps.filter { app ->
app.appName.contains(searchQuery, ignoreCase = true) ||
app.packageName.contains(searchQuery, ignoreCase = true)
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.fillMaxWidth(),
title = { Text(stringResource(R.string.select_target_app_title)) },
text = {
Column {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text(stringResource(R.string.search_apps_label)) },
placeholder = { Text(stringResource(R.string.search_apps_placeholder)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
LazyColumn {
items(filteredApps) { app ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(app) }
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
app.icon?.let { icon ->
val bitmap = icon.toBitmap(48, 48)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
}
Column {
Text(
text = app.appName,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = app.packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel_button))
}
}
)
}

View file

@ -0,0 +1,102 @@
package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import app.lonecloud.prism.R
@Composable
fun AppPickerScreen(
apps: List<InstalledApp>,
onNavigateBack: () -> Unit,
onSelect: (InstalledApp) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
val filteredApps = remember(apps, searchQuery) {
if (searchQuery.isBlank()) {
apps
} else {
apps.filter { app ->
app.appName.contains(searchQuery, ignoreCase = true) ||
app.packageName.contains(searchQuery, ignoreCase = true)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text(stringResource(R.string.search_apps_label)) },
placeholder = { Text(stringResource(R.string.search_apps_placeholder)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(filteredApps) { app ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onSelect(app)
onNavigateBack()
}
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
app.icon?.let { icon ->
val bitmap = icon.toBitmap(48, 48)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
}
Column {
Text(
text = app.appName,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = app.packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}

View file

@ -21,6 +21,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -32,6 +33,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import app.lonecloud.prism.PrismPreferences
import app.lonecloud.prism.R
import app.lonecloud.prism.activities.MainViewModel
import app.lonecloud.prism.activities.PreviewFactory
@ -42,8 +44,12 @@ import org.unifiedpush.android.distributor.ui.compose.AppBar
import org.unifiedpush.android.distributor.ui.vm.DistribMigrationViewModel
enum class AppScreen(@param:StringRes val title: Int) {
Intro(R.string.app_name),
Main(R.string.app_name),
Settings(R.string.settings)
Settings(R.string.settings),
ServerConfig(R.string.configure_server),
AddApp(R.string.add_custom_app_title),
AppPicker(R.string.select_target_app_title)
}
@OptIn(ExperimentalMaterial3Api::class)
@ -86,6 +92,8 @@ fun App(
) {
val context = LocalContext.current
val uiActionsFlow = subscribeUiActions(context)
val prefs = remember { PrismPreferences(context) }
val startDestination = if (prefs.introCompleted) AppScreen.Main.name else AppScreen.Intro.name
val backStackEntry by navController.currentBackStackEntryAsState()
val currentScreen = AppScreen.valueOf(
@ -96,26 +104,34 @@ fun App(
Scaffold(
topBar = {
when (currentScreen) {
AppScreen.Main -> {
MainAppBarOrSelection(
mainViewModel,
onGoToSettings = {
navController.navigate(AppScreen.Settings.name)
}
)
}
else -> null
} ?: DefaultTopBar(
currentScreen,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() }
)
if (currentScreen == AppScreen.Intro) {
null
} else {
when (currentScreen) {
AppScreen.Main -> {
MainAppBarOrSelection(
mainViewModel,
onGoToSettings = {
navController.navigate(AppScreen.Settings.name)
}
)
}
else -> null
} ?: DefaultTopBar(
currentScreen,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() }
)
}
},
floatingActionButton = {
if (currentScreen == AppScreen.Main && mainViewModel.mainUiState.prismServerConfigured) {
val prefs = PrismPreferences(context)
if (currentScreen == AppScreen.Main && !prefs.prismServerUrl.isNullOrBlank() && !prefs.prismApiKey.isNullOrBlank()) {
FloatingActionButton(
onClick = { mainViewModel.showAddAppDialog() }
onClick = {
mainViewModel.clearSelectedApp()
navController.navigate(AppScreen.AddApp.name)
}
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_manual_app_content_description))
}
@ -125,24 +141,42 @@ fun App(
) { innerPadding ->
NavHost(
navController = navController,
startDestination = AppScreen.Main.name,
startDestination = startDestination,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
composable(route = AppScreen.Intro.name) {
val settingsViewModel = viewModel<SettingsViewModel>(factory = factory)
IntroScreen(
onComplete = { url, apiKey ->
PrismPreferences(context).introCompleted = true
settingsViewModel.savePrismConfig(url, apiKey)
navController.navigate(AppScreen.Main.name) {
popUpTo(AppScreen.Intro.name) { inclusive = true }
}
},
onSkip = {
PrismPreferences(context).introCompleted = true
navController.navigate(AppScreen.Main.name) {
popUpTo(AppScreen.Intro.name) { inclusive = true }
}
}
)
}
composable(
route = AppScreen.Main.name,
exitTransition = {
when (targetState.destination.route) {
AppScreen.Settings.name -> slideOutFrom(
Dir.Right
)
AppScreen.Settings.name -> slideOutFrom(Dir.Right)
AppScreen.AddApp.name -> slideOutFrom(Dir.Right)
else -> fadeOut()
}
},
popEnterTransition = {
when (initialState.destination.route) {
AppScreen.Settings.name -> slideInTo(Dir.Right)
AppScreen.AddApp.name -> slideInTo(Dir.Right)
else -> fadeIn()
}
}
@ -156,10 +190,91 @@ fun App(
composable(
route = AppScreen.Settings.name,
enterTransition = { slideInTo(Dir.Left) },
exitTransition = {
when (targetState.destination.route) {
AppScreen.ServerConfig.name -> slideOutFrom(Dir.Right)
else -> fadeOut()
}
},
popEnterTransition = {
when (initialState.destination.route) {
AppScreen.ServerConfig.name -> slideInTo(Dir.Right)
else -> fadeIn()
}
},
popExitTransition = { slideOutFrom(Dir.Left) }
) {
val vm = viewModel<SettingsViewModel>(factory = factory)
SettingsScreen(vm, themeViewModel, migrationViewModel)
SettingsScreen(
vm,
themeViewModel,
migrationViewModel,
onNavigateToServerConfig = {
navController.navigate(AppScreen.ServerConfig.name)
}
)
}
composable(
route = AppScreen.ServerConfig.name,
enterTransition = { slideInTo(Dir.Left) },
popEnterTransition = { slideInTo(Dir.Right) },
popExitTransition = { slideOutFrom(Dir.Left) }
) {
val settingsEntry = remember(it) {
navController.getBackStackEntry(AppScreen.Settings.name)
}
val vm = viewModel<SettingsViewModel>(
viewModelStoreOwner = settingsEntry,
factory = factory
)
ServerConfigScreen(
initialUrl = vm.state.prismServerUrl,
initialApiKey = vm.state.prismApiKey,
onNavigateBack = { navController.navigateUp() },
onSave = { url, apiKey -> vm.savePrismConfig(url, apiKey) }
)
}
composable(
route = AppScreen.AddApp.name,
enterTransition = { slideInTo(Dir.Left) },
exitTransition = {
when (targetState.destination.route) {
AppScreen.AppPicker.name -> slideOutFrom(Dir.Right)
else -> fadeOut()
}
},
popEnterTransition = {
when (initialState.destination.route) {
AppScreen.AppPicker.name -> slideInTo(Dir.Right)
else -> fadeIn()
}
},
popExitTransition = { slideOutFrom(Dir.Left) }
) {
AddAppScreen(
selectedApp = mainViewModel.selectedApp,
onNavigateBack = { navController.navigateUp() },
onNavigateToAppPicker = {
navController.navigate(AppScreen.AppPicker.name)
},
onConfirm = { name, packageName, description ->
mainViewModel.addManualApp(name, packageName, description)
}
)
}
composable(
route = AppScreen.AppPicker.name,
enterTransition = { slideInTo(Dir.Left) },
popEnterTransition = { slideInTo(Dir.Right) },
popExitTransition = { slideOutFrom(Dir.Left) }
) {
AppPickerScreen(
apps = mainViewModel.mainUiState.installedApps,
onNavigateBack = { navController.navigateUp() },
onSelect = { app ->
mainViewModel.selectApp(app)
}
)
}
}
}

View file

@ -1,31 +0,0 @@
package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import app.lonecloud.prism.R
import app.lonecloud.prism.utils.getDebugInfo
@Composable
fun DebugDialog(onDismissRequest: () -> Unit) {
val text = getDebugInfo()
AlertDialog(
title = { Text(stringResource(R.string.debug_title)) },
text = {
SelectionContainer {
Text(text)
}
},
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = onDismissRequest
) {
Text(stringResource(android.R.string.ok))
}
}
)
}

View file

@ -0,0 +1,171 @@
package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.lonecloud.prism.R
import app.lonecloud.prism.activities.ui.components.PasswordTextField
import app.lonecloud.prism.utils.normalizeUrl
import app.lonecloud.prism.utils.testServerConnection
@Composable
fun IntroScreen(onComplete: (url: String, apiKey: String) -> Unit, onSkip: () -> Unit) {
var url by remember { mutableStateOf("") }
var apiKey by remember { mutableStateOf("") }
var isTesting by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val successMessage = stringResource(R.string.connection_successful)
val failedMessageTemplate = stringResource(R.string.connection_failed)
fun testAndSave(normalizedUrl: String) {
isTesting = true
testServerConnection(
normalizedUrl,
apiKey,
onSuccess = {
isTesting = false
testResult = successMessage
onComplete(normalizedUrl, apiKey)
},
onError = { error ->
isTesting = false
testResult = failedMessageTemplate.replace("%s", error)
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = painterResource(R.drawable.app_logo),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier.size(120.dp)
)
Text(
text = stringResource(R.string.intro_welcome_message),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
PrismInfoWithLink(uriHandler = uriHandler)
OutlinedTextField(
value = url,
onValueChange = {
url = it
testResult = null
},
label = { Text(stringResource(R.string.prism_server_url_label)) },
placeholder = { Text(stringResource(R.string.prism_server_url_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isTesting
)
PasswordTextField(
value = apiKey,
onValueChange = {
apiKey = it
testResult = null
},
label = stringResource(R.string.prism_api_key_label),
placeholder = stringResource(R.string.prism_api_key_placeholder),
modifier = Modifier.fillMaxWidth(),
enabled = !isTesting
)
if (isTesting) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.testing_connection))
}
}
testResult?.let { result ->
Text(
text = result,
color = if (result.contains("successful")) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
if (url.isBlank() || apiKey.isBlank()) {
onSkip()
return@Button
}
val normalizedUrl = normalizeUrl(url)
testAndSave(normalizedUrl)
},
enabled = !isTesting,
modifier = Modifier.fillMaxWidth()
) {
Text(
if (url.isNotBlank() && apiKey.isNotBlank()) {
stringResource(R.string.test_and_save_button)
} else {
stringResource(R.string.intro_continue_button)
}
)
}
TextButton(
onClick = onSkip,
enabled = !isTesting,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.intro_skip_button))
}
}
}

View file

@ -1,11 +1,10 @@
package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
@ -13,7 +12,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -31,7 +29,7 @@ import app.lonecloud.prism.activities.PreviewFactory
import org.unifiedpush.android.distributor.ui.compose.AppBar
import org.unifiedpush.android.distributor.ui.compose.CardDisableBatteryOptimisation
import org.unifiedpush.android.distributor.ui.compose.CardDisabledForMigration
import org.unifiedpush.android.distributor.ui.compose.DistribMigrationUi
import org.unifiedpush.android.distributor.ui.compose.DistribMigrationDialogs
import org.unifiedpush.android.distributor.ui.compose.PermissionsUi
import org.unifiedpush.android.distributor.ui.compose.RegistrationList
import org.unifiedpush.android.distributor.ui.compose.RegistrationListHeading
@ -125,17 +123,14 @@ fun MainScreen(
CardDisableBatteryOptimisation(viewModel.batteryOptimisationViewModel)
RegistrationListHeading(
modifier = Modifier.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
viewModel.addDebugClick()
}
)
RegistrationListHeading()
}
RegistrationList(viewModel.registrationsViewModel)
LazyColumn(
modifier = Modifier.padding(horizontal = 8.dp)
) {
RegistrationList(viewModel.registrationsViewModel)
}
}
if (viewModel.mainUiState.showPermissionDialog) {
PermissionsUi {
@ -143,22 +138,8 @@ fun MainScreen(
migrationViewModel.mayShowFallbackIntro()
}
}
if (viewModel.mainUiState.showDebugInfo) {
DebugDialog {
viewModel.dismissDebugInfo()
}
}
if (viewModel.mainUiState.showAddAppDialog) {
AddAppDialog(
installedApps = viewModel.mainUiState.installedApps,
onDismiss = { viewModel.hideAddAppDialog() },
onConfirm = { name, packageName, description ->
viewModel.addApp(name, packageName, description)
}
)
}
if (migrationViewModel.state.canMigrate) {
DistribMigrationUi(migrationViewModel)
DistribMigrationDialogs(migrationViewModel)
}
}

View file

@ -7,12 +7,10 @@ data class InstalledApp(
)
data class MainUiState(
val showDebugInfo: Boolean = false,
val showPermissionDialog: Boolean = true,
val showAppDetails: Boolean = false,
val isLoadingEndpoint: Boolean = false,
val currentEndpoint: String = "",
val showAddAppDialog: Boolean = false,
val installedApps: List<InstalledApp> = emptyList(),
val prismServerConfigured: Boolean = false
)

View file

@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@ -13,12 +15,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun PrismTogglePreference(
title: String,
description: String? = null,
icon: ImageVector? = null,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
@ -29,9 +33,17 @@ fun PrismTogglePreference(
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
icon?.let {
Icon(
imageVector = it,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)

View file

@ -1,328 +0,0 @@
package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import app.lonecloud.prism.R
@Composable
fun PrismServerConfigButton(
currentUrl: String,
currentApiKey: String,
onConfigure: (url: String, apiKey: String) -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
Surface(
onClick = { showDialog = true },
modifier = Modifier.fillMaxWidth(),
shape = RectangleShape
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(R.string.configure_server),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = if (currentUrl.isNotBlank()) {
currentUrl
} else {
stringResource(R.string.prism_server_not_configured)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showDialog) {
PrismServerConfigDialog(
initialUrl = currentUrl,
initialApiKey = currentApiKey,
onDismiss = { showDialog = false },
onSave = { url, apiKey ->
onConfigure(url, apiKey)
showDialog = false
}
)
}
}
@Composable
fun PrismServerConfigDialog(
initialUrl: String,
initialApiKey: String,
onDismiss: () -> Unit,
onSave: (url: String, apiKey: String) -> Unit
) {
var url by remember { mutableStateOf(initialUrl) }
var apiKey by remember { mutableStateOf(initialApiKey) }
var isTesting by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<String?>(null) }
var showServerChangeWarning by remember { mutableStateOf(false) }
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
fun normalizeUrl(rawUrl: String): String = rawUrl.trim().let {
if (!it.startsWith("http://") && !it.startsWith("https://")) {
"https://$it"
} else {
it
}
}.trimEnd('/')
fun testAndSave(normalizedUrl: String) {
val successMessage = context.getString(R.string.connection_successful)
val failedMessageTemplate = context.getString(R.string.connection_failed)
isTesting = true
app.lonecloud.prism.PrismServerClient.testConnection(
normalizedUrl,
apiKey,
onSuccess = {
isTesting = false
testResult = successMessage
onSave(normalizedUrl, apiKey)
},
onError = { error ->
isTesting = false
testResult = failedMessageTemplate.replace("%s", error)
}
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.configure_server)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
ServerDescriptionLink(uriHandler = uriHandler)
OutlinedTextField(
value = url,
onValueChange = {
url = it
testResult = null
},
label = { Text(stringResource(R.string.prism_server_url_label)) },
placeholder = { Text(stringResource(R.string.prism_server_url_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isTesting
)
OutlinedTextField(
value = apiKey,
onValueChange = {
apiKey = it
testResult = null
},
label = { Text(stringResource(R.string.prism_api_key_label)) },
placeholder = { Text(stringResource(R.string.prism_api_key_placeholder)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isTesting
)
if (isTesting) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.testing_connection))
}
}
testResult?.let { result ->
Text(
text = result,
color = if (result.contains("successful")) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
}
}
},
confirmButton = {
Button(
onClick = {
if (url.isBlank() || apiKey.isBlank()) {
onDismiss()
return@Button
}
val normalizedUrl = normalizeUrl(url)
val isServerChanging = initialUrl.isNotBlank() && normalizedUrl != initialUrl
if (isServerChanging) {
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val manualAppsCount = db.listApps()
.count { it.description?.startsWith("target:") == true }
if (manualAppsCount > 0) {
showServerChangeWarning = true
return@Button
}
}
testAndSave(normalizedUrl)
},
enabled = !isTesting && url.isNotBlank() && apiKey.isNotBlank()
) {
Text(stringResource(R.string.test_and_save_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !isTesting) {
Text(stringResource(R.string.cancel_button))
}
}
)
if (showServerChangeWarning) {
ServerChangeWarningDialog(
newUrl = normalizeUrl(url),
newApiKey = apiKey,
initialUrl = initialUrl,
onConfirm = { normalizedUrl ->
showServerChangeWarning = false
testAndSave(normalizedUrl)
},
onDismiss = { showServerChangeWarning = false }
)
}
}
@Composable
private fun ServerDescriptionLink(uriHandler: androidx.compose.ui.platform.UriHandler) {
val description = stringResource(R.string.prism_server_description)
val repoUrl = stringResource(R.string.prism_server_repo_link)
val displayUrl = repoUrl.removePrefix("https://")
val annotatedString = buildAnnotatedString {
append(description)
append("\n\n")
val linkStart = length
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
)
) {
append(displayUrl)
}
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)
}
}
)
}
@Composable
private fun ServerChangeWarningDialog(
newUrl: String,
newApiKey: String,
initialUrl: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val manualAppsCount = db.listApps()
.count { it.description?.startsWith("target:") == true }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Change Prism Server?") },
text = {
Text(
"You have $manualAppsCount manual app${if (manualAppsCount == 1) "" else "s"}" +
" registered with the current server.\n\n" +
"Changing to $newUrl will delete registrations from the old server" +
" and re-register with the new one."
)
},
confirmButton = {
Button(
onClick = {
val oldKey = app.lonecloud.prism.PrismPreferences(context).prismApiKey
if (initialUrl.isNotBlank() && !oldKey.isNullOrBlank()) {
app.lonecloud.prism.PrismServerClient.deleteAllApps(
context,
serverUrl = initialUrl,
apiKey = oldKey
)
}
onConfirm(newUrl)
}
) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View file

@ -0,0 +1,321 @@
package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.lonecloud.prism.R
import app.lonecloud.prism.activities.ui.components.PasswordTextField
import app.lonecloud.prism.utils.normalizeUrl
import app.lonecloud.prism.utils.testServerConnection
@Composable
fun ServerConfigScreen(
initialUrl: String,
initialApiKey: String,
onNavigateBack: () -> Unit,
onSave: (url: String, apiKey: String) -> Unit
) {
var url by remember { mutableStateOf(initialUrl) }
var apiKey by remember { mutableStateOf("") }
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
val successMessage = stringResource(R.string.connection_successful)
val failedMessageTemplate = stringResource(R.string.connection_failed)
fun testAndSave(normalizedUrl: String) {
isTesting = true
testServerConnection(
normalizedUrl,
apiKey,
onSuccess = {
isTesting = false
testResult = successMessage
onSave(normalizedUrl, apiKey)
onNavigateBack()
},
onError = { error ->
isTesting = false
testResult = failedMessageTemplate.replace("%s", error)
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.prism_server_info),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
PrismInfoWithLink(uriHandler = uriHandler)
OutlinedTextField(
value = url,
onValueChange = {
url = it
testResult = null
},
label = { Text(stringResource(R.string.prism_server_url_label)) },
placeholder = { Text(stringResource(R.string.prism_server_url_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isTesting
)
PasswordTextField(
value = apiKey,
onValueChange = {
apiKey = it
testResult = null
},
label = stringResource(R.string.prism_api_key_label),
placeholder = stringResource(R.string.prism_api_key_placeholder),
modifier = Modifier.fillMaxWidth(),
enabled = !isTesting
)
if (isTesting) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.testing_connection))
}
}
testResult?.let { result ->
Text(
text = result,
color = if (result.contains("successful")) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
}
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (initialUrl.isNotBlank()) {
OutlinedButton(
onClick = { showClearConfirmation = true },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.clear_server_button))
}
}
Button(
onClick = {
if (url.isBlank() || (apiKey.isBlank() && initialApiKey.isBlank())) {
onNavigateBack()
return@Button
}
val normalizedUrl = normalizeUrl(url)
val finalApiKey = apiKey.ifBlank { initialApiKey }
val isServerChanging = initialUrl.isNotBlank() && normalizedUrl != initialUrl
if (isServerChanging) {
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val manualAppsCount = db.listApps()
.count { it.description?.startsWith("target:") == true }
if (manualAppsCount > 0) {
showServerChangeWarning = true
return@Button
}
}
testAndSave(normalizedUrl)
},
enabled = !isTesting && url.isNotBlank() && apiKey.isNotBlank(),
modifier = if (initialUrl.isNotBlank()) Modifier.weight(1f) else Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.test_and_save_button))
}
}
}
if (showServerChangeWarning) {
ServerChangeWarningDialog(
newUrl = normalizeUrl(url),
newApiKey = apiKey,
initialUrl = initialUrl,
onConfirm = { normalizedUrl ->
showServerChangeWarning = false
testAndSave(normalizedUrl)
},
onDismiss = { showServerChangeWarning = false }
)
}
if (showClearConfirmation) {
ClearServerConfirmationDialog(
initialUrl = initialUrl,
onConfirm = {
showClearConfirmation = false
onSave("", "")
onNavigateBack()
},
onDismiss = { showClearConfirmation = false }
)
}
}
@Composable
internal fun PrismInfoWithLink(uriHandler: androidx.compose.ui.platform.UriHandler) {
val learnMore = stringResource(R.string.prism_server_learn_more)
Text(
text = learnMore,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
uriHandler.openUri("https://github.com/lone-cloud/prism")
}
)
}
@Composable
private fun ServerChangeWarningDialog(
newUrl: String,
newApiKey: String,
initialUrl: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val manualAppsCount = db.listApps()
.count { it.description?.startsWith("target:") == true }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Change Prism Server?") },
text = {
Text(
"You have $manualAppsCount manual app${if (manualAppsCount == 1) "" else "s"}" +
" registered with the current server.\n\n" +
"Changing to $newUrl will delete registrations from the old server" +
" and re-register with the new one."
)
},
confirmButton = {
Button(
onClick = {
val oldKey = app.lonecloud.prism.PrismPreferences(context).prismApiKey
if (initialUrl.isNotBlank() && !oldKey.isNullOrBlank()) {
app.lonecloud.prism.PrismServerClient.deleteAllApps(
context,
serverUrl = initialUrl,
apiKey = oldKey
)
}
onConfirm(newUrl)
}
) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun ClearServerConfirmationDialog(
initialUrl: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val manualAppsCount = db.listApps()
.count { it.description?.startsWith("target:") == true }
AlertDialog(
onDismissRequest = onDismiss,
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 = {
TextButton(
onClick = {
val oldKey = app.lonecloud.prism.PrismPreferences(context).prismApiKey
if (initialUrl.isNotBlank() && !oldKey.isNullOrBlank()) {
app.lonecloud.prism.PrismServerClient.deleteAllApps(
context,
serverUrl = initialUrl,
apiKey = oldKey
)
}
onConfirm()
}
) {
Text("Remove")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View file

@ -2,9 +2,23 @@ package app.lonecloud.prism.activities.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@ -17,14 +31,15 @@ import app.lonecloud.prism.R
import app.lonecloud.prism.activities.PreviewFactory
import app.lonecloud.prism.activities.SettingsViewModel
import app.lonecloud.prism.activities.ThemeViewModel
import org.unifiedpush.android.distributor.ui.compose.DistribMigrationUi
import org.unifiedpush.android.distributor.ui.compose.DistribMigrationDialogs
import org.unifiedpush.android.distributor.ui.vm.DistribMigrationViewModel
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel,
themeViewModel: ThemeViewModel,
migrationViewModel: DistribMigrationViewModel
migrationViewModel: DistribMigrationViewModel,
onNavigateToServerConfig: () -> Unit = {}
) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
@ -34,31 +49,64 @@ fun SettingsScreen(
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(20.dp)
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
PrismServerConfigButton(
currentUrl = viewModel.state.prismServerUrl,
currentApiKey = viewModel.state.prismApiKey,
onConfigure = { url, apiKey ->
viewModel.updatePrismServerUrl(url)
viewModel.updatePrismApiKey(apiKey)
Surface(
onClick = onNavigateToServerConfig,
modifier = Modifier.fillMaxWidth(),
shape = RectangleShape
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Cloud,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.configure_server),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = if (viewModel.state.prismServerUrl.isNotBlank()) {
viewModel.state.prismServerUrl
} else {
stringResource(R.string.prism_server_not_configured)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
)
}
PrismTogglePreference(
title = stringResource(R.string.app_dropdown_show_toasts),
description = stringResource(R.string.show_toasts_description),
icon = Icons.Filled.Notifications,
checked = viewModel.state.showToasts,
onCheckedChange = { viewModel.toggleShowToasts() }
)
PrismTogglePreference(
title = stringResource(R.string.dynamic_colors_title),
description = stringResource(R.string.dynamic_colors_description),
icon = Icons.Filled.Palette,
checked = themeViewModel.dynamicColors,
onCheckedChange = { themeViewModel.toggleDynamicColors() }
)
}
if (migrationViewModel.state.canMigrate) {
DistribMigrationUi(migrationViewModel)
DistribMigrationDialogs(migrationViewModel)
}
}

View file

@ -0,0 +1,49 @@
package app.lonecloud.prism.activities.ui.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@Composable
fun PasswordTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
placeholder: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
singleLine: Boolean = true
) {
var passwordVisible by remember { mutableStateOf(false) }
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = { Text(placeholder) },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = if (passwordVisible) "Hide password" else "Show password"
)
}
},
modifier = modifier,
singleLine = singleLine,
enabled = enabled
)
}

View file

@ -1,15 +0,0 @@
package app.lonecloud.prism.utils
import app.lonecloud.prism.api.ServerConnection
import app.lonecloud.prism.services.FgService
import app.lonecloud.prism.services.SourceManager
import java.text.SimpleDateFormat
fun getDebugInfo(): String {
val date = ServerConnection.lastEventDate?.let {
SimpleDateFormat.getDateTimeInstance().format(it.time)
} ?: "None"
return "ServiceStarted: ${FgService.isServiceStarted()}\n" +
"Last Event: $date\n" +
SourceManager.getDebugInfo()
}

View file

@ -0,0 +1,23 @@
package app.lonecloud.prism.utils
fun normalizeUrl(rawUrl: String): String = rawUrl.trim().let {
if (!it.startsWith("http://") && !it.startsWith("https://")) {
"https://$it"
} else {
it
}
}.trimEnd('/')
fun testServerConnection(
url: String,
apiKey: String,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
app.lonecloud.prism.PrismServerClient.testConnection(
url,
apiKey,
onSuccess,
onError
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -47,8 +47,8 @@
<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_info">Prism server enables manual app registrations and multi-device sync. Self-host your own or use a public instance.</string>
<string name="prism_server_learn_more">Learn more about 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>
@ -60,10 +60,19 @@
<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="clear_server_button">Clear</string>
<string name="clear_server_button">Remove</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="app_dropdown_show_toasts">Notify when apps register</string>
<string name="show_toasts_description">Show a notification when apps register or unregister</string>
<string name="dynamic_colors_title">Dynamic Colors</string>
<string name="dynamic_colors_description">Use colors from your wallpaper</string>
<!-- Intro screen strings -->
<string name="intro_welcome_title">Welcome to Prism</string>
<string name="intro_welcome_message">Prism is a UnifiedPush distributor that supports manual app registrations through an optional Prism server.</string>
<string name="intro_server_optional">Configure a Prism server now or skip to set it up later in Settings.</string>
<string name="intro_continue_button">Continue</string>
<string name="intro_skip_button">Skip for now</string>
</resources>

View file

@ -4,7 +4,7 @@ androidx-activityCompose = "1.12.1"
androidx-lifecycle = "2.10.0"
androidx-work = "2.11.0"
appcompat = "1.7.1"
unifiedpush_distributor = "0.7.1"
unifiedpush_distributor = "0.7.2"
unifiedpush_distributor_base = "0.7.0"
accompanist_permissions = "0.37.3"
tink = "1.15.0"