improve error handling

This commit is contained in:
2026-05-06 14:25:04 +07:00
parent eb2f543f53
commit 8b5a8b0e48
3 changed files with 156 additions and 101 deletions

View File

@@ -3,6 +3,7 @@ package su.reya.coop
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
@@ -32,6 +33,10 @@ val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun App(dbPath: String) {
@@ -53,24 +58,32 @@ fun App(dbPath: String) {
else -> expressiveLightColorScheme()
}
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.initAndConnect(dbPath)
viewModel.startNotificationHandler()
viewModel.getChatRooms()
viewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
MaterialExpressiveTheme(
colorScheme = colorScheme,
) {
CompositionLocalProvider(LocalNostrViewModel provides viewModel) {
CompositionLocalProvider(
LocalNostrViewModel provides viewModel,
LocalSnackbarHostState provides snackbarHostState,
) {
rememberCoroutineScope()
val navController = rememberNavController()
val hasSecret by viewModel.hasSecret.collectAsState(initial = null)
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
LaunchedEffect(hasSecret) {
LaunchedEffect(emptySecret) {
// Navigate to the home screen if the secret is already set
if (hasSecret == true) {
// Start a background notification handler
viewModel.startNotificationHandler()
if (emptySecret == false) {
// Get chat rooms
viewModel.getChatRooms()
// Navigate to the home screen
@@ -81,11 +94,11 @@ fun App(dbPath: String) {
}
// Show loading screen while initializing
if (hasSecret == null) return@CompositionLocalProvider
if (emptySecret == null) return@CompositionLocalProvider
NavHost(
navController = navController,
startDestination = if (hasSecret == true) Screen.Home else Screen.Onboarding
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
) {
composable<Screen.Onboarding> { backStackEntry ->
OnboardingScreen(

View File

@@ -20,6 +20,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -33,6 +35,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -40,6 +43,7 @@ fun NewIdentityScreen(
isLoading: Boolean,
onSave: (name: String, bio: String, picture: Uri?) -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) }
@@ -49,6 +53,16 @@ fun NewIdentityScreen(
picture = uri
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -57,10 +71,6 @@ fun NewIdentityScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "New Identity",
style = MaterialTheme.typography.headlineMediumEmphasized
)
Box(
modifier = Modifier
.size(120.dp)
@@ -116,3 +126,6 @@ fun NewIdentityScreen(
}
}
}
}
)
}

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@@ -23,8 +24,8 @@ class NostrViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage
) : ViewModel() {
private val _hasSecret = MutableStateFlow<Boolean?>(null)
val hasSecret = _hasSecret.asStateFlow()
private val _emptySecret = MutableStateFlow<Boolean?>(null)
val emptySecret = _emptySecret.asStateFlow()
private val _isCreating = MutableStateFlow(false)
val isCreating = _isCreating.asStateFlow()
@@ -32,6 +33,9 @@ class NostrViewModel(
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED)
val errorEvents = _errorEvents.receiveAsFlow()
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
@@ -40,6 +44,12 @@ class NostrViewModel(
startMetadataBatchProcessor()
}
private fun showError(message: String) {
viewModelScope.launch {
_errorEvents.send(message)
}
}
private fun startMetadataBatchProcessor() {
viewModelScope.launch {
val batch = mutableSetOf<PublicKey>()
@@ -91,11 +101,7 @@ class NostrViewModel(
}
fun getUserProfile(): StateFlow<Metadata?> {
return try {
getMetadata(nostr.userPubkey!!)
} catch (e: Exception) {
MutableStateFlow(null)
}
return getMetadata(nostr.userPubkey!!)
}
fun initAndConnect(dbPath: String) {
@@ -108,7 +114,7 @@ class NostrViewModel(
// Get user's secret
getUserSecret()
} catch (e: Exception) {
println("Failed to connect: ${e.message}")
showError("Failed to initialize Nostr: ${e.message}")
}
}
}
@@ -121,31 +127,43 @@ class NostrViewModel(
}
}
fun logout() {
viewModelScope.launch {
_emptySecret.value = true
_chatRooms.value = emptySet()
secretStore.clear("user_signer")
nostr.exit()
}
}
suspend fun getUserSecret() {
// Get user's signer secret
val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen
if (secret == null) {
_hasSecret.value = false
when (secret) {
null -> {
_emptySecret.value = true
return
}
_hasSecret.value = true
else -> _emptySecret.value = false
}
// Handle different signer types
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setKeySigner(keys)
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val remote = NostrConnect(
uri = bunker,
appKeys = appKeys,
timeout = Duration.parse("5"),
opts = null
)
val timeout = Duration.parse("50") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setRemoteSigner(remote)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
throw IllegalArgumentException("Invalid secret format: $secret")
}
@@ -178,21 +196,32 @@ class NostrViewModel(
// Save secret to the secret storage
secretStore.set("user_signer", secret)
} catch (e: Exception) {
println("Create identity failed: $e")
showError("Error: ${e.message}")
}
}
}
fun importIdentity(secret: String) {
// TODO: Implement import
}
fun logout() {
viewModelScope.launch {
_hasSecret.value = false
_chatRooms.value = emptySet()
secretStore.clear("user_signer")
nostr.exit()
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setKeySigner(keys)
secretStore.set("user_signer", secret)
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50") // 50 seconds timeout
val remote =
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setRemoteSigner(remote)
secretStore.set("user_signer", secret)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
showError("Please enter a valid Secret or Bunker URI.")
}
}
}
@@ -201,7 +230,7 @@ class NostrViewModel(
try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
} catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}")
showError("Error: ${e.message}")
}
}
}