Add key package management

This commit is contained in:
Kgothatso Ngako
2026-07-02 03:13:20 +02:00
parent daba18ecc6
commit c443444d5e
10 changed files with 378 additions and 4 deletions

View File

@@ -4,9 +4,13 @@ import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import at.torch.compose.database.model.MarmotKeyPackageBundle
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Dao
interface MarmotKeyPackageBundleDao {
@Query("SELECT * FROM MarmotKeyPackageBundle WHERE publicKey = :publicKey ORDER BY createdAt DESC")
fun getAllMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle>
@Query("SELECT * FROM MarmotKeyPackageBundle WHERE id = :id ORDER BY createdAt DESC")
fun getMarmotKeyPackageBundleById(id: String): MarmotKeyPackageBundle?

View File

@@ -36,7 +36,7 @@ class DatabaseMarmotRepository(
): MarmotRepository {
private val logger = Logger.withTag(TAG)
override suspend fun saveMarmotKeyPackageBundle(
override suspend fun publishMarmotKeyPackageBundle(
publicKey: HexKey,
nsecPassword: String
) {
@@ -88,6 +88,9 @@ class DatabaseMarmotRepository(
)
}
override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle> {
return database.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey)
}
private fun generateKeyPackage(
identity: ByteArray,

View File

@@ -1,23 +1,30 @@
package at.torch.compose.repository
import at.torch.compose.database.model.MarmotKeyPackageBundle
import com.vitorpamplona.quartz.nip01Core.core.HexKey
interface MarmotRepository {
suspend fun saveMarmotKeyPackageBundle(
suspend fun publishMarmotKeyPackageBundle(
publicKey: HexKey,
nsecPassword: String
)
suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle>
companion object {
val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository {
override suspend fun saveMarmotKeyPackageBundle(
override suspend fun publishMarmotKeyPackageBundle(
publicKey: HexKey,
nsecPassword: String
) {
TODO("Not yet implemented")
}
override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle> {
return emptyList()
}
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bento
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.ImportExport
@@ -19,6 +20,7 @@ import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.SwitchVideo
import androidx.compose.material.icons.filled.Widgets
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -42,6 +44,7 @@ import at.torch.compose.database.model.intermdiate.LocalNostrEvent
import at.torch.compose.extensions.hexToNpubHrp
import at.torch.compose.repository.NostrRepository
import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import at.torch.compose.ui.composable.navigation.routes.KeyPackageManagementRoute
import at.torch.compose.ui.composable.navigation.routes.Route
import at.torch.compose.ui.composable.navigation.routes.ShareProfileRoute
import at.torch.compose.ui.composable.widgets.LoadingDataIndicator
@@ -212,6 +215,33 @@ fun ActiveProfileScreen(
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
KeyPackageManagementRoute(
activeUserPublicKey = activeUserPublicKey,
nostrEventId = nostrEventId
)
)
}
) {
Icon(
Icons.Default.Bento,
contentDescription = "Key Packages"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text(
"Key Package Management"
)
}
}
item {
TextButton(

View File

@@ -0,0 +1,216 @@
package at.torch.compose.ui.composable
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bento
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.ImportExport
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.LockReset
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.PrivateConnectivity
import androidx.compose.material.icons.filled.Publish
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import at.torch.compose.extensions.hexToNpubHrp
import at.torch.compose.extensions.toFormattedTimeAndDateString
import at.torch.compose.repository.MarmotRepository
import at.torch.compose.repository.NostrRepository
import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import at.torch.compose.ui.composable.navigation.routes.Route
import at.torch.compose.ui.composable.navigation.routes.ShareProfileRoute
import at.torch.compose.ui.composable.widgets.LoadingDataIndicator
import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar
import at.torch.compose.ui.view.model.KeyPackageManagementViewModel
import at.torch.compose.ui.view.state.KeyPackageManagementUIState
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun KeyPackageManagementScreen(
initialActiveProfileUIState: KeyPackageManagementUIState = KeyPackageManagementUIState.Loading,
activeUserPublicKey: HexKey,
nostrEventId: HexKey,
onNavigateBack: () -> Unit,
onNavigateToRoute: (Route) -> Unit,
marmotRepository: MarmotRepository
) {
val keyPackageManagementViewModel: KeyPackageManagementViewModel = viewModel(
factory = KeyPackageManagementViewModel.factory(
nostrEventId = nostrEventId,
initialActiveProfileUIState = initialActiveProfileUIState,
marmotRepository = marmotRepository,
activeUserPublicKey = activeUserPublicKey
),
)
when(val keyPackageManagementUIState = keyPackageManagementViewModel.keyPackageManagementUIState) {
KeyPackageManagementUIState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Something went wrong")
}
}
is KeyPackageManagementUIState.Loaded -> {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Key Package Management"
)
},
navigationIcon = {
IconButton(
onClick = {
onNavigateBack.invoke()
}
) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Button(
onClick = {
keyPackageManagementViewModel.publishNewKeyPackage()
}
) {
Icon(
Icons.Default.LockReset,
contentDescription = "Publish new key package"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text(
text = "Publish New Key Package"
)
}
}
item {
Text(
"${keyPackageManagementUIState.keyPackageBundles.size} Key Packages"
)
}
items(
items = keyPackageManagementUIState.keyPackageBundles
) { marmotKeyPackageBundle ->
Row {
Text(
text = marmotKeyPackageBundle.id,
style = MaterialTheme.typography.bodySmall
)
Text(
text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall
)
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Delete Key Package")
)
}
) {
Text(
"Delete"
)
}
}
}
}
}
}
}
KeyPackageManagementUIState.Loading -> {
LoadingDataIndicator()
LaunchedEffect(true) {
keyPackageManagementViewModel.loadNostrFeed()
}
}
KeyPackageManagementUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("We couldn't find the local profile. Please try again later.")
}
}
}
}
@Preview
@Composable
private fun UnannouncedProfileScreenPreview() {
_root_ide_package_.at.torch.compose.ui.theme.TorchTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
KeyPackageManagementScreen(
initialActiveProfileUIState = KeyPackageManagementUIState.Loaded(
keyPackageBundles = emptyList()
),
activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
nostrEventId = "",
marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE,
onNavigateToRoute = {},
onNavigateBack = {}
)
}
}
}

View File

@@ -17,11 +17,13 @@ import at.torch.compose.ui.composable.ActiveProfileScreen
import at.torch.compose.ui.composable.ChatRoomCreationScreen
import at.torch.compose.ui.composable.ChatRoomDetailScreen
import at.torch.compose.ui.composable.HomeScreen
import at.torch.compose.ui.composable.KeyPackageManagementScreen
import at.torch.compose.ui.composable.NostrEventDetailScreen
import at.torch.compose.ui.composable.ShareProfileScreen
import at.torch.compose.ui.composable.UnsyncedProfileScreen
import at.torch.compose.ui.composable.navigation.routes.ActiveProfileRoute
import at.torch.compose.ui.composable.navigation.routes.ChatRoomCreationRoute
import at.torch.compose.ui.composable.navigation.routes.KeyPackageManagementRoute
import at.torch.compose.ui.composable.navigation.routes.LoadingRoute
import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import at.torch.compose.ui.composable.navigation.routes.ShareProfileRoute
@@ -490,6 +492,23 @@ fun TorchNavHost(
}
)
}
composable<KeyPackageManagementRoute> { backStackEntry ->
val route = backStackEntry.toRoute<KeyPackageManagementRoute>()
KeyPackageManagementScreen(
activeUserPublicKey = route.activeUserPublicKey,
nostrEventId = route.nostrEventId,
marmotRepository = databaseMarmotRepository,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToRoute = { route ->
navController.navigate(
route = route
)
}
)
}
composable<at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute>()

View File

@@ -0,0 +1,9 @@
package at.torch.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class KeyPackageManagementRoute(
val activeUserPublicKey: String,
val nostrEventId: String,
): Route()

View File

@@ -112,7 +112,7 @@ class CreateProfileViewModel(
biography = createProfileFormState.biographyField.textFieldState.text.toString()
)
marmotRepository.saveMarmotKeyPackageBundle(
marmotRepository.publishMarmotKeyPackageBundle(
publicKey = pubkey,
nsecPassword = localKeyManager.nsecPassword()
)

View File

@@ -0,0 +1,72 @@
package at.torch.compose.ui.view.model
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import at.torch.compose.repository.MarmotRepository
import at.torch.compose.ui.view.state.KeyPackageManagementUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
class KeyPackageManagementViewModel(
val activeUserPublicKey: HexKey,
val nostrEventId: HexKey,
initialActiveProfileUIState: KeyPackageManagementUIState,
val marmotRepository: MarmotRepository
): ViewModel() {
var keyPackageManagementUIState: KeyPackageManagementUIState by mutableStateOf(initialActiveProfileUIState)
private set
fun retryLoad() {
keyPackageManagementUIState = KeyPackageManagementUIState.Loading
loadNostrFeed()
}
fun loadNostrFeed() {
viewModelScope.launch(Dispatchers.IO) {
keyPackageManagementUIState = KeyPackageManagementUIState.Loaded(
keyPackageBundles = marmotRepository.getMarmotKeyPackageBundles(activeUserPublicKey)
)
}
}
fun publishNewKeyPackage() {
viewModelScope.launch(Dispatchers.IO) {
marmotRepository.publishMarmotKeyPackageBundle(
publicKey = activeUserPublicKey,
nsecPassword = "" // TODO: Implement nsecPassword logic...
)
keyPackageManagementUIState = KeyPackageManagementUIState.Loaded(
keyPackageBundles = marmotRepository.getMarmotKeyPackageBundles(activeUserPublicKey)
)
}
}
companion object {
const val TAG = "KeyPackageManagementViewModel"
fun factory(
nostrEventId: HexKey,
activeUserPublicKey: HexKey,
initialActiveProfileUIState: KeyPackageManagementUIState = KeyPackageManagementUIState.Loading,
marmotRepository: MarmotRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
KeyPackageManagementViewModel(
nostrEventId = nostrEventId,
initialActiveProfileUIState = initialActiveProfileUIState,
marmotRepository = marmotRepository,
activeUserPublicKey = activeUserPublicKey
)
}
}
}
}

View File

@@ -0,0 +1,14 @@
package at.torch.compose.ui.view.state
import at.torch.compose.database.model.MarmotKeyPackageBundle
sealed interface KeyPackageManagementUIState {
data class Loaded(
val keyPackageBundles: List<MarmotKeyPackageBundle>
): KeyPackageManagementUIState
data object Error: KeyPackageManagementUIState
data object NotFound: KeyPackageManagementUIState
data object Loading: KeyPackageManagementUIState
}