Refactor bunker and login request screens to support account selection and improved permission management.

- Add tabbed navigation to `BunkerConnectRequestScreen` and `LoginWithPubKey` to separate login and permission settings.
- Implement account selection in the login tab, allowing users to choose which account to connect.
- Move permission toggle list to a modal bottom sheet for a cleaner UI.
- Update `AmberTopAppBar` to display application icons and improved titles for incoming requests.
- Add `SignerConnectAppTab` component for consistent tab styling.
- Update `LocalPreferences` to use non-suspending versions of account loading methods.
- Refactor `AmberBunkerRequest` to include `isNostrConnectUri` flag.
- Improve `EnabledPermissions` by using a `LazyColumn` for better performance.
- Update string resources for signing policy explainers.
This commit is contained in:
greenart7c3
2026-01-09 07:11:32 -03:00
parent fe5facf6de
commit 628aa0ef69
14 changed files with 748 additions and 331 deletions
@@ -186,7 +186,7 @@ object LocalPreferences {
}
}
suspend fun reloadApp() {
fun reloadApp() {
val context = Amber.instance
currentAccount = null
savedAccounts = null
@@ -429,14 +429,14 @@ object LocalPreferences {
accountCache.put(account.npub, account)
}
suspend fun loadFromEncryptedStorage(context: Context): Account? {
fun loadFromEncryptedStorage(context: Context): Account? {
currentAccount(context)?.let {
return loadFromEncryptedStorage(context, it)
}
return null
}
suspend fun allAccounts(context: Context): List<Account> {
fun allAccounts(context: Context): List<Account> {
val accountInfos = allSavedAccounts(context)
return accountInfos.mapNotNull {
loadFromEncryptedStorage(context, it.npub)
@@ -493,7 +493,7 @@ object LocalPreferences {
HttpClientManager.setDefaultProxyOnPort(port)
}
suspend fun loadFromEncryptedStorage(context: Context, npub: String): Account? {
fun loadFromEncryptedStorage(context: Context, npub: String): Account? {
if (accountCache.get(npub) != null) {
return accountCache.get(npub)
}
@@ -25,4 +25,5 @@ data class AmberBunkerRequest(
val checked: MutableState<Boolean> = mutableStateOf(true),
val rememberType: MutableState<RememberType> = mutableStateOf(RememberType.NEVER),
val encryptionType: EncryptionType,
val isNostrConnectUri: Boolean,
)
@@ -207,6 +207,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
signedEvent = signedEvent,
encryptedData = encryptedDataKind,
encryptionType = encryptionType,
isNostrConnectUri = false,
)
var amberEvent: AmberEvent? = null
@@ -13,6 +13,7 @@ import com.greenart7c3.nostrsigner.models.containsNip
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import java.util.UUID
import kotlinx.coroutines.launch
object NostrConnectUtils {
@@ -22,6 +23,10 @@ object NostrConnectUtils {
intent: Intent,
account: Account,
) {
if (intent.extras?.getString("id") == null) {
intent.putExtra("id", UUID.randomUUID().toString().substring(0, 6))
}
try {
val data = intent.dataString.toString().replace("nostrconnect://", "")
val split = data.split("?")
@@ -107,6 +112,7 @@ object NostrConnectUtils {
BunkerRequestUtils.addRequest(
AmberBunkerRequest(
BunkerRequestConnect(
id = intent.extras?.getString("id") ?: UUID.randomUUID().toString().substring(0, 6),
remoteKey = pubKey,
secret = "",
permissions = if (permissions.isNotEmpty()) {
@@ -124,6 +130,7 @@ object NostrConnectUtils {
signedEvent = null,
encryptedData = null,
encryptionType = EncryptionType.NIP44,
isNostrConnectUri = true,
),
)
} catch (e: Exception) {
@@ -340,14 +340,24 @@ fun MainScreen(
Route.IncomingRequest.route,
content = {
val scrollState = rememberScrollState()
IncomingRequestScreen(
modifier = Modifier
val modifier = if (intents.isEmpty() || packageName == null || destinationRoute != Route.IncomingRequest.route) {
Modifier
.fillMaxSize()
.padding(padding)
.verticalScrollbar(scrollState)
.verticalScroll(scrollState)
.padding(horizontal = verticalPadding)
.padding(top = verticalPadding * 1.5f),
.padding(top = verticalPadding * 1.5f)
} else {
Modifier
.fillMaxSize()
.padding(padding)
.verticalScrollbar(scrollState)
.verticalScroll(scrollState)
.padding(horizontal = verticalPadding)
}
IncomingRequestScreen(
modifier = modifier,
intents = intents,
bunkerRequests = bunkerRequests,
packageName = packageName,
@@ -1,9 +1,11 @@
package com.greenart7c3.nostrsigner.ui.components
import android.content.Context
import android.graphics.drawable.Drawable
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.CenterAlignedTopAppBar
@@ -26,6 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
@@ -41,6 +44,7 @@ import com.greenart7c3.nostrsigner.models.IntentData
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.navigation.Route
import com.greenart7c3.nostrsigner.ui.navigation.routes
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import java.util.Base64
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -59,154 +63,183 @@ fun AmberTopAppBar(
bunkerRequests: List<AmberBunkerRequest>,
packageName: String?,
) {
if (destinationRoute != "login" && destinationRoute != "create" && destinationRoute != "loginPage") {
CenterAlignedTopAppBar(
actions = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(text = context.getString(R.string.reconnect))
}
},
state = rememberTooltipState(),
) {
IconButton(
onClick = {
Amber.instance.applicationIOScope.launch {
Amber.instance.reconnect()
}
},
content = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val relayStats = Amber.instance.client.relayStatusFlow().collectAsStateWithLifecycle()
Text("${relayStats.value.connected.size}/${relayStats.value.available.size}")
Icon(
imageVector = ImageVector.vectorResource(R.drawable.relays),
contentDescription = context.getString(R.string.reconnect),
tint = Color.Unspecified,
)
}
},
)
}
if (Amber.instance.settings.useProxy) {
var isProxyEnabled by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
Amber.instance.applicationIOScope.launch {
isProxyEnabled = Amber.instance.isSocksProxyAlive("127.0.0.1", Amber.instance.settings.proxyPort)
}
}
Lifecycle.Event.ON_RESUME -> {
Amber.instance.applicationIOScope.launch {
isProxyEnabled = Amber.instance.isSocksProxyAlive("127.0.0.1", Amber.instance.settings.proxyPort)
}
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(text = if (isProxyEnabled) context.getString(R.string.proxy_is_connected) else context.getString(R.string.proxy_is_not_working))
}
},
state = rememberTooltipState(),
if (intents.isEmpty() || packageName == null || destinationRoute != Route.IncomingRequest.route) {
if (destinationRoute != "login" && destinationRoute != "create" && destinationRoute != "loginPage") {
CenterAlignedTopAppBar(
actions = {
if (intents.isEmpty() || packageName == null || destinationRoute != Route.IncomingRequest.route) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(
onClick = {
scope.launch {
if (isProxyEnabled) {
Toast.makeText(context, context.getString(R.string.proxy_is_connected), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, context.getString(R.string.proxy_is_not_working), Toast.LENGTH_SHORT).show()
}
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(text = context.getString(R.string.reconnect))
}
},
content = {
Icon(
Icons.Outlined.Shield,
context.getString(R.string.proxy),
tint = if (isProxyEnabled) Color.Green else Color.Red,
state = rememberTooltipState(),
) {
IconButton(
onClick = {
Amber.instance.applicationIOScope.launch {
Amber.instance.reconnect()
}
},
content = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val relayStats = Amber.instance.client.relayStatusFlow().collectAsStateWithLifecycle()
Text("${relayStats.value.connected.size}/${relayStats.value.available.size}")
Icon(
imageVector = ImageVector.vectorResource(R.drawable.relays),
contentDescription = context.getString(R.string.reconnect),
tint = Color.Unspecified,
)
}
},
)
}
if (Amber.instance.settings.useProxy) {
var isProxyEnabled by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
Amber.instance.applicationIOScope.launch {
isProxyEnabled = Amber.instance.isSocksProxyAlive("127.0.0.1", Amber.instance.settings.proxyPort)
}
}
Lifecycle.Event.ON_RESUME -> {
Amber.instance.applicationIOScope.launch {
isProxyEnabled = Amber.instance.isSocksProxyAlive("127.0.0.1", Amber.instance.settings.proxyPort)
}
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(text = if (isProxyEnabled) context.getString(R.string.proxy_is_connected) else context.getString(R.string.proxy_is_not_working))
}
},
state = rememberTooltipState(),
) {
IconButton(
onClick = {
scope.launch {
if (isProxyEnabled) {
Toast.makeText(context, context.getString(R.string.proxy_is_connected), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, context.getString(R.string.proxy_is_not_working), Toast.LENGTH_SHORT).show()
}
}
},
content = {
Icon(
Icons.Outlined.Shield,
context.getString(R.string.proxy),
tint = if (isProxyEnabled) Color.Green else Color.Red,
)
},
)
},
)
}
}
}
},
title = {
var title by remember { mutableStateOf(routes.find { it.route.startsWith(destinationRoute) }?.title ?: "") }
LaunchedEffect(destinationRoute) {
if (destinationRoute.startsWith("Permission/") || destinationRoute.startsWith("Activity/") || destinationRoute.startsWith("RelayLogScreen/") || destinationRoute.startsWith("qrcode/")) {
launch(Dispatchers.IO) {
navBackStackEntry?.arguments?.getString("content")?.let {
title = Route.QrCode.title
}
navBackStackEntry?.arguments?.getString("packageName")?.let { packageName ->
val application = Amber.instance.getDatabase(account.npub).dao().getByKey(packageName)?.application
title = if (destinationRoute.startsWith("Activity/")) {
"${application?.name?.ifBlank { application.key.toShortenHex() } ?: packageName} - ${routes.find { it.route.startsWith(destinationRoute) }?.title}"
} else {
application?.name?.ifBlank { application.key.toShortenHex() } ?: packageName
}
}
navBackStackEntry?.arguments?.getString("key")?.let { packageName ->
val application = Amber.instance.getDatabase(account.npub).dao().getByKey(packageName)?.application
title = if (destinationRoute.startsWith("Activity/")) {
"${application?.name?.ifBlank { application.key.toShortenHex() } ?: packageName} - ${routes.find { it.route.startsWith(destinationRoute) }?.title}"
} else {
application?.name?.ifBlank { application.key.toShortenHex() } ?: packageName
}
}
navBackStackEntry?.arguments?.getString("url")?.let { url ->
val localUrl = Base64.getDecoder().decode(url).toString(Charsets.UTF_8)
title = localUrl
}
}
} else {
launch(Dispatchers.IO) {
if (destinationRoute == Route.IncomingRequest.route && (intents.isNotEmpty() || bunkerRequests.isNotEmpty())) {
val key = if (bunkerRequests.isNotEmpty()) {
bunkerRequests.first().localKey
} else {
packageName
}
val application = Amber.instance.getDatabase(account.npub).dao().getByKey(key ?: "")?.application
val titleTemp = application?.name?.ifBlank { application.key.toShortenHex() } ?: packageName ?: ""
title = if (titleTemp.isBlank()) {
routes.find { it.route == destinationRoute }?.title ?: ""
} else {
"$titleTemp - Request"
}
} else {
title = routes.find { it.route == destinationRoute }?.title ?: ""
}
}
}
}
},
title = {
var title by remember { mutableStateOf(routes.find { it.route.startsWith(destinationRoute) }?.title ?: "") }
LaunchedEffect(destinationRoute) {
if (destinationRoute.startsWith("Permission/") || destinationRoute.startsWith("Activity/") || destinationRoute.startsWith("RelayLogScreen/") || destinationRoute.startsWith("qrcode/")) {
launch(Dispatchers.IO) {
navBackStackEntry?.arguments?.getString("content")?.let {
title = Route.QrCode.title
}
navBackStackEntry?.arguments?.getString("packageName")?.let { packageName ->
val application = Amber.instance.getDatabase(account.npub).dao().getByKey(packageName)?.application
title = if (destinationRoute.startsWith("Activity/")) {
"${application?.name?.ifBlank { application.key.toShortenHex() } ?: packageName} - ${routes.find { it.route.startsWith(destinationRoute) }?.title}"
} else {
application?.name?.ifBlank { application.key.toShortenHex() } ?: packageName
}
}
navBackStackEntry?.arguments?.getString("key")?.let { packageName ->
val application = Amber.instance.getDatabase(account.npub).dao().getByKey(packageName)?.application
title = if (destinationRoute.startsWith("Activity/")) {
"${application?.name?.ifBlank { application.key.toShortenHex() } ?: packageName} - ${routes.find { it.route.startsWith(destinationRoute) }?.title}"
} else {
application?.name?.ifBlank { application.key.toShortenHex() } ?: packageName
}
}
navBackStackEntry?.arguments?.getString("url")?.let { url ->
val localUrl = Base64.getDecoder().decode(url).toString(Charsets.UTF_8)
title = localUrl
}
}
} else {
launch(Dispatchers.IO) {
if (destinationRoute == Route.IncomingRequest.route && (bunkerRequests.isNotEmpty())) {
val request = bunkerRequests.first()
val key = bunkerRequests.first().localKey
Text(title)
},
)
var application = Amber.instance.getDatabase(account.npub).dao().getByKey(key)?.application
if (application == null && request.request is BunkerRequestConnect) {
val secret = request.request.secret
if (secret != null) {
application = Amber.instance.getDatabase(account.npub).dao().getByKey(secret)?.application
}
}
val titleTemp = application?.name?.ifBlank { request.name.ifBlank { application.key.toShortenHex() } } ?: request.name
title = titleTemp.ifBlank {
routes.find { it.route == destinationRoute }?.title ?: ""
}
} else {
title = routes.find { it.route == destinationRoute }?.title ?: ""
}
}
}
}
Text(title)
},
)
}
}
}
@Composable
fun rememberAppDisplayInfo(packageName: String): AppDisplayInfo {
val context = LocalContext.current
return remember(packageName) {
val appInfo = runCatching {
context.packageManager.getApplicationInfo(packageName, 0)
}.getOrNull()
if (appInfo != null) {
AppDisplayInfo(
name = context.packageManager.getApplicationLabel(appInfo).toString(),
icon = context.packageManager.getApplicationIcon(appInfo),
)
} else {
AppDisplayInfo(name = packageName, icon = null)
}
}
}
data class AppDisplayInfo(
val name: String,
val icon: Drawable? = null,
)
@@ -1,5 +1,7 @@
package com.greenart7c3.nostrsigner.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -7,11 +9,26 @@ 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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -21,15 +38,23 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.AmberBunkerRequest
import com.greenart7c3.nostrsigner.models.Permission
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.DeleteAfterType
import com.greenart7c3.nostrsigner.ui.RememberType
import com.greenart7c3.nostrsigner.ui.SettingsRow
@@ -38,14 +63,15 @@ import com.greenart7c3.nostrsigner.ui.parseDeleteAfterType
import kotlin.collections.forEach
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BunkerConnectRequestScreen(
modifier: Modifier,
shouldCloseApp: Boolean,
account: Account,
appName: String,
bunkerRequest: AmberBunkerRequest,
permissions: List<Permission>?,
onAccept: (List<Permission>?, Int, Boolean?, RememberType, Long) -> Unit,
onAccept: (List<Permission>?, Int, Boolean?, RememberType, Long, Account) -> Unit,
onReject: (RememberType) -> Unit,
) {
val localPermissions = remember {
@@ -55,117 +81,245 @@ fun BunkerConnectRequestScreen(
}
snapshot
}
var selectedTabIndex by remember { mutableIntStateOf(0) }
var selectedOption by remember { mutableIntStateOf(account.signPolicy) }
val accounts = remember {
val snapshot = mutableStateListOf<Account>()
if (bunkerRequest.isNostrConnectUri) {
LocalPreferences.allAccounts(Amber.instance).forEach {
snapshot.add(it)
}
} else {
snapshot.add(account)
}
snapshot
}
var selectedAccountIndex by remember { mutableIntStateOf(accounts.indexOf(account)) }
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
val deleteAfterItems =
persistentListOf(
TitleExplainer(stringResource(DeleteAfterType.NEVER.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.FIVE_MINUTES.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.TEN_MINUTES.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.ONE_HOUR.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.ONE_DAY.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.ONE_WEEK.resourceId)),
)
var deleteAfterIndex by remember { mutableIntStateOf(DeleteAfterType.NEVER.screenCode) }
var closeApp by remember { mutableStateOf(shouldCloseApp) }
Column(
modifier,
) {
ProfilePicture(account)
Text(
modifier = Modifier.fillMaxWidth(),
text = appName,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.would_like_your_permission_to_read_your_public_key_and_sign_events_on_your_behalf),
)
var selectedOption by remember { mutableIntStateOf(account.signPolicy) }
val deleteAfterItems =
persistentListOf(
TitleExplainer(stringResource(DeleteAfterType.NEVER.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.FIVE_MINUTES.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.TEN_MINUTES.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.ONE_HOUR.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.ONE_DAY.resourceId)),
TitleExplainer(stringResource(DeleteAfterType.ONE_WEEK.resourceId)),
PrimaryTabRow(
selectedTabIndex = selectedTabIndex,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
indicator = {
Box(
modifier = Modifier
.tabIndicatorOffset(selectedTabIndex)
.height(6.dp)
.padding(horizontal = 16.dp)
.padding(bottom = 2.dp)
.clip(CircleShape)
.background(color = MaterialTheme.colorScheme.primary),
)
},
divider = {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline,
)
},
) {
SignerConnectAppTab(
text = stringResource(id = R.string.login).uppercase(),
selected = selectedTabIndex == 0,
onClick = {
selectedTabIndex = 0
},
)
var deleteAfterIndex by remember { mutableIntStateOf(DeleteAfterType.NEVER.screenCode) }
Text(
text = stringResource(R.string.handle_application_permissions),
)
SignerConnectAppTab(
text = stringResource(id = R.string.permissions).uppercase(),
selected = selectedTabIndex == 0,
onClick = {
selectedTabIndex = 1
},
)
}
Spacer(modifier = Modifier.size(8.dp))
var closeApp by remember { mutableStateOf(shouldCloseApp) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 8.dp)
.clickable {
closeApp = !closeApp
},
) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(R.string.close_application),
)
Switch(
checked = closeApp,
onCheckedChange = {
closeApp = it
},
)
}
if (selectedTabIndex == 0) {
accounts.forEachIndexed { index, acc ->
ListItem(
modifier = Modifier
.border(
width = 1.dp,
color = if (selectedAccountIndex == index) {
MaterialTheme.colorScheme.primary
} else {
Color.Transparent
},
shape = RoundedCornerShape(8.dp),
)
.padding(4.dp)
.selectable(
selected = selectedAccountIndex == index,
onClick = {
selectedAccountIndex = index
},
),
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.background,
Box(
Modifier.padding(4.dp),
) {
SettingsRow(
R.string.delete_after,
null,
deleteAfterItems,
deleteAfterIndex,
),
leadingContent = {
ProfilePictureIcon(
account = acc,
)
},
headlineContent = {
val name by acc.name.collectAsStateWithLifecycle()
Text(
name.ifBlank { acc.npub.toShortenHex() },
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
textAlign = TextAlign.Center,
)
},
)
}
} else {
var showModal by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 8.dp)
.clickable {
closeApp = !closeApp
},
) {
deleteAfterIndex = it
Text(
modifier = Modifier.weight(1f),
text = stringResource(R.string.close_application),
)
Switch(
checked = closeApp,
onCheckedChange = {
closeApp = it
},
)
}
Box(
Modifier.padding(4.dp),
) {
SettingsRow(
R.string.delete_after,
null,
deleteAfterItems,
deleteAfterIndex,
) {
deleteAfterIndex = it
}
}
ChooseSignPolicy(
selectedOption = selectedOption,
onSelected = {
selectedOption = it
},
)
if (selectedOption == 1 && localPermissions.isNotEmpty()) {
Box(
Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
ElevatedButton(
colors = ButtonDefaults.buttonColors().copy(
contentColor = Color.Black,
),
shape = RoundedCornerShape(20),
content = {
Text(stringResource(R.string.permissions))
},
onClick = {
showModal = true
},
)
}
if (showModal) {
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = {
showModal = false
},
) {
Scaffold(
bottomBar = {
BottomAppBar {
IconRow(
center = true,
title = stringResource(R.string.go_back),
icon = ImageVector.vectorResource(R.drawable.back),
onClick = {
showModal = false
},
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
) {
EnabledPermissions(
Modifier.padding(it),
localPermissions,
)
}
}
}
}
}
ChooseSignPolicy(
selectedOption = selectedOption,
onSelected = {
selectedOption = it
},
)
Spacer(modifier = Modifier.weight(1f))
Column(
Row(
Modifier
.fillMaxWidth()
.padding(10.dp),
Arrangement.Center,
Alignment.CenterHorizontally,
Arrangement.spacedBy(8.dp),
Alignment.CenterVertically,
) {
if (selectedOption == 1) {
EnabledPermissions(localPermissions)
}
AmberButton(
modifier = Modifier.padding(vertical = 20.dp),
onClick = {
val deleteAfter = deleteAfterToSeconds(parseDeleteAfterType(deleteAfterIndex))
onAccept(localPermissions, selectedOption, closeApp, rememberType, deleteAfter)
},
text = stringResource(R.string.grant_permissions),
)
AmberButton(
modifier = Modifier.padding(vertical = 20.dp),
modifier = Modifier
.padding(vertical = 20.dp)
.weight(1f),
onClick = {
onReject(rememberType)
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF6B00),
),
text = stringResource(R.string.reject),
text = stringResource(R.string.cancel),
)
AmberButton(
modifier = Modifier
.padding(vertical = 20.dp)
.weight(1f),
onClick = {
val deleteAfter = deleteAfterToSeconds(parseDeleteAfterType(deleteAfterIndex))
onAccept(localPermissions, selectedOption, closeApp, rememberType, deleteAfter, accounts[selectedAccountIndex])
},
text = stringResource(R.string.connect),
)
}
}
@@ -148,6 +148,7 @@ fun BunkerSingleEventHomeScreen(
var localRememberType by remember { mutableStateOf(RememberType.NEVER) }
var existingAppKey by remember { mutableStateOf("") }
var localDeleteAfter by remember { mutableLongStateOf(0L) }
var selectedAccount by remember { mutableStateOf<Account>(account) }
if (showExistingAppDialog) {
AlertDialog(
@@ -169,7 +170,7 @@ fun BunkerSingleEventHomeScreen(
BunkerRequestUtils.sendResult(
oldKey = existingAppKey,
context = context,
account = account,
account = selectedAccount,
key = key,
response = result,
bunkerRequest = bunkerRequest,
@@ -192,7 +193,7 @@ fun BunkerSingleEventHomeScreen(
BunkerRequestUtils.sendResult(
context = context,
account = account,
account = selectedAccount,
key = key,
response = result,
bunkerRequest = bunkerRequest,
@@ -222,7 +223,7 @@ fun BunkerSingleEventHomeScreen(
modifier = modifier,
shouldCloseApp = applicationEntity?.application?.closeApplication ?: bunkerRequest.closeApplication,
account = account,
appName = appName,
bunkerRequest = bunkerRequest,
permissions = bunkerRequest.request.permissions?.split(",")?.map {
val split = it.split(":")
if (split.size > 1) {
@@ -231,21 +232,22 @@ fun BunkerSingleEventHomeScreen(
Permission(split[0].trim(), null)
}
},
onAccept = { permissions, signPolicy, closeApplication, rememberType, deleteAfter ->
onAccept = { permissions, signPolicy, closeApplication, rememberType, deleteAfter, acc ->
val result = bunkerRequest.nostrConnectSecret.ifBlank { "ack" }
localPermissions = permissions
localSignPolicy = signPolicy
localCloseApplication = closeApplication
localRememberType = rememberType
selectedAccount = acc
if (bunkerRequest.name.isNotBlank()) {
Amber.instance.applicationIOScope.launch {
val existingApp = Amber.instance.getDatabase(account.npub).dao().getByName(bunkerRequest.name)
val existingApp = Amber.instance.getDatabase(acc.npub).dao().getByName(bunkerRequest.name)
if (existingApp == null) {
BunkerRequestUtils.sendResult(
context = context,
account = account,
account = acc,
key = key,
response = result,
bunkerRequest = bunkerRequest,
@@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
@@ -23,13 +25,18 @@ import com.greenart7c3.nostrsigner.models.Permission
@Composable
fun EnabledPermissions(
modifier: Modifier = Modifier,
localPermissions: List<Permission>,
) {
val enabledPermissions = localPermissions.map {
remember { mutableStateOf(it.checked) }
}
if (localPermissions.isNotEmpty()) {
localPermissions.forEachIndexed { index, permission ->
LazyColumn(
modifier
.fillMaxWidth()
.padding(8.dp),
) {
itemsIndexed(localPermissions) { index, item ->
Card(
modifier = Modifier
.fillMaxWidth()
@@ -44,20 +51,20 @@ fun EnabledPermissions(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable {
permission.checked = !permission.checked
enabledPermissions[index].value = permission.checked
item.checked = !item.checked
enabledPermissions[index].value = item.checked
},
) {
Checkbox(
checked = enabledPermissions[index].value,
onCheckedChange = { _ ->
permission.checked = !permission.checked
enabledPermissions[index].value = permission.checked
item.checked = !item.checked
enabledPermissions[index].value = item.checked
},
)
Text(
modifier = Modifier.weight(1f),
text = permission.toLocalizedString(LocalContext.current),
text = item.toLocalizedString(LocalContext.current),
)
}
}
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.sp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun IconRow(
modifier: Modifier = Modifier,
center: Boolean = false,
title: String,
icon: ImageVector,
@@ -31,7 +32,7 @@ fun IconRow(
onLongClick: (() -> Unit)? = null,
) {
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
@@ -70,24 +70,21 @@ fun IntentSingleEventHomeScreen(
when (intentData.type) {
SignerType.GET_PUBLIC_KEY -> {
LoginWithPubKey(
shouldCloseApp = applicationEntity?.application?.closeApplication != false,
packageName = packageName,
modifier = modifier,
account = account,
packageName = packageName,
appName = appName,
applicationName = applicationName,
permissions = intentData.permissions,
onAccept = { permissions, signPolicy, closeApplication, rememberType ->
onAccept = { permissions, signPolicy, closeApplication, rememberType, acc ->
val result = if (packageName == null) {
account.hexKey
acc.hexKey
} else {
account.npub
acc.npub
}
IntentUtils.sendResult(
context,
packageName,
account,
acc,
key,
clipboardManager,
result,
@@ -1,8 +1,10 @@
package com.greenart7c3.nostrsigner.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -11,15 +13,26 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -31,15 +44,22 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import coil3.compose.AsyncImage
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.BuildFlavorChecker
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.Permission
@@ -94,16 +114,44 @@ fun ProfilePicture(account: Account) {
}
}
@Composable
fun ProfilePictureIcon(account: Account) {
val profileUrl by account.picture.collectAsStateWithLifecycle()
if (profileUrl.isNotBlank() && !BuildFlavorChecker.isOfflineFlavor()) {
AsyncImage(
profileUrl,
Route.Accounts.route,
Modifier
.clip(
RoundedCornerShape(50),
)
.height(40.dp)
.width(40.dp),
)
} else {
Icon(
Icons.Outlined.Person,
Route.Accounts.route,
modifier = Modifier
.border(
2.dp,
Color.fromHex(account.hexKey.slice(0..5)),
CircleShape,
)
.height(40.dp)
.width(40.dp),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginWithPubKey(
shouldCloseApp: Boolean,
modifier: Modifier,
account: Account,
packageName: String?,
appName: String,
applicationName: String?,
permissions: List<Permission>?,
onAccept: (List<Permission>?, Int, Boolean?, RememberType) -> Unit,
onAccept: (List<Permission>?, Int, Boolean?, RememberType, Account) -> Unit,
onReject: (RememberType) -> Unit,
) {
val localPermissions = remember {
@@ -115,105 +163,232 @@ fun LoginWithPubKey(
}
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
var selectedTabIndex by remember { mutableIntStateOf(0) }
var selectedOption by remember { mutableIntStateOf(account.signPolicy) }
val accounts = remember {
val snapshot = mutableStateListOf<Account>()
LocalPreferences.allAccounts(Amber.instance).forEach {
snapshot.add(it)
}
snapshot
}
var selectedAccountIndex by remember { mutableIntStateOf(accounts.indexOf(account)) }
Column(
modifier,
) {
ProfilePicture(account)
packageName?.let {
Text(
modifier = Modifier
.fillMaxWidth(),
text = it,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.size(4.dp))
val appDisplayInfo = rememberAppDisplayInfo(packageName)
Column(
modifier = Modifier.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (appDisplayInfo.icon != null) {
Image(
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.small),
bitmap = appDisplayInfo.icon.toBitmap().asImageBitmap(),
contentDescription = appDisplayInfo.name,
contentScale = ContentScale.Crop,
)
}
Text(
text = appDisplayInfo.name,
style = MaterialTheme.typography.titleLarge.copy(
fontSize = 18.sp,
lineHeight = 24.sp,
),
fontWeight = FontWeight.Bold,
)
Text(
modifier = Modifier
.fillMaxWidth(),
text = packageName,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
textAlign = TextAlign.Center,
)
}
}
Text(
modifier = Modifier.fillMaxWidth(),
text = applicationName ?: appName,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
PrimaryTabRow(
selectedTabIndex = selectedTabIndex,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
indicator = {
Box(
modifier = Modifier
.tabIndicatorOffset(selectedTabIndex)
.height(6.dp)
.padding(horizontal = 16.dp)
.padding(bottom = 2.dp)
.clip(CircleShape)
.background(color = MaterialTheme.colorScheme.primary),
)
},
divider = {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline,
)
},
) {
SignerConnectAppTab(
text = stringResource(id = R.string.login).uppercase(),
selected = selectedTabIndex == 0,
onClick = {
selectedTabIndex = 0
},
)
Text(
stringResource(R.string.would_like_your_permission_to_read_your_public_key_and_sign_events_on_your_behalf),
)
var selectedOption by remember { mutableIntStateOf(account.signPolicy) }
Text(
text = stringResource(R.string.handle_application_permissions),
)
SignerConnectAppTab(
text = stringResource(id = R.string.permissions).uppercase(),
selected = selectedTabIndex == 0,
onClick = {
selectedTabIndex = 1
},
)
}
Spacer(modifier = Modifier.size(8.dp))
var closeApp by remember { mutableStateOf(shouldCloseApp) }
if (packageName == null) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 8.dp)
.clickable {
closeApp = !closeApp
if (selectedTabIndex == 0) {
accounts.forEachIndexed { index, acc ->
ListItem(
modifier = Modifier
.border(
width = 1.dp,
color = if (selectedAccountIndex == index) {
MaterialTheme.colorScheme.primary
} else {
Color.Transparent
},
shape = RoundedCornerShape(8.dp),
)
.padding(4.dp)
.selectable(
selected = selectedAccountIndex == index,
onClick = {
selectedAccountIndex = index
},
),
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.background,
),
leadingContent = {
ProfilePictureIcon(
account = acc,
)
},
) {
Text(
modifier = Modifier.weight(1f),
text = stringResource(R.string.close_application),
)
Switch(
checked = closeApp,
onCheckedChange = {
closeApp = it
headlineContent = {
val name by acc.name.collectAsStateWithLifecycle()
Text(
name.ifBlank { acc.npub.toShortenHex() },
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
textAlign = TextAlign.Center,
)
},
)
}
} else {
var showModal by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
)
ChooseSignPolicy(
selectedOption = selectedOption,
onSelected = {
selectedOption = it
},
)
if (selectedOption == 1 && localPermissions.isNotEmpty()) {
Box(
Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
ElevatedButton(
colors = ButtonDefaults.buttonColors().copy(
contentColor = Color.Black,
),
shape = RoundedCornerShape(20),
content = {
Text(stringResource(R.string.permissions))
},
onClick = {
showModal = true
},
)
}
if (showModal) {
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = {
showModal = false
},
) {
Scaffold(
bottomBar = {
BottomAppBar {
IconRow(
center = true,
title = stringResource(R.string.go_back),
icon = ImageVector.vectorResource(R.drawable.back),
onClick = {
showModal = false
},
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
) {
EnabledPermissions(
Modifier.padding(it),
localPermissions,
)
}
}
}
}
}
ChooseSignPolicy(
selectedOption = selectedOption,
onSelected = {
selectedOption = it
},
)
Spacer(modifier = Modifier.weight(1f))
Column(
Row(
Modifier
.fillMaxWidth()
.padding(10.dp),
Arrangement.Center,
Alignment.CenterHorizontally,
Arrangement.spacedBy(8.dp),
Alignment.CenterVertically,
) {
if (selectedOption == 1) {
EnabledPermissions(localPermissions)
}
AmberButton(
modifier = Modifier.padding(vertical = 20.dp),
onClick = {
onAccept(localPermissions, selectedOption, closeApp, rememberType)
},
text = stringResource(R.string.grant_permissions),
)
AmberButton(
modifier = Modifier.padding(vertical = 20.dp),
modifier = Modifier
.padding(vertical = 20.dp)
.weight(1f),
onClick = {
onReject(rememberType)
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF6B00),
),
text = stringResource(R.string.reject),
text = stringResource(R.string.cancel),
)
AmberButton(
modifier = Modifier
.padding(vertical = 20.dp)
.weight(1f),
onClick = {
onAccept(localPermissions, selectedOption, true, rememberType, accounts[selectedAccountIndex])
},
text = stringResource(R.string.connect),
)
}
}
@@ -0,0 +1,29 @@
package com.greenart7c3.nostrsigner.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun SignerConnectAppTab(
text: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Tab(
modifier = modifier.padding(horizontal = 16.dp),
selected = selected,
onClick = onClick,
text = {
Text(
text = text,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
)
},
)
}
+2 -2
View File
@@ -347,7 +347,7 @@
<string name="next">Next</string>
<string name="add_your_key">Add your key</string>
<string name="sign_policy_basic">Approve basic actions</string>
<string name="sign_policy_basic_explainer">Recommended for most people. This will minimize the number of interruptions during your app usage.</string>
<string name="sign_policy_basic_explainer">Auto-approve most common requests.</string>
<string name="sign_policy_manual">Manually approve each app</string>
<string name="sign_policy_manual_explainer">Recommended for privacy-minded people who would like control over each app. Choosing this will prompt you set a preference every time you try a new app.</string>
<string name="sign_policy_title">Select a signing policy</string>
@@ -359,7 +359,7 @@
<string name="sign_policy">Sign policy</string>
<string name="handle_application_permissions">How to handle this application permissions?</string>
<string name="sign_policy_manual_new_app">Manually approve each permission</string>
<string name="sign_policy_manual_new_app_explainer">Recommended for privacy-minded people who would like control over each permission. Choosing this will prompt you set a preference every time you receive a new permission.</string>
<string name="sign_policy_manual_new_app_explainer">Ask me to approve each request.</string>
<string name="sign_policy_fully">I fully trust this application</string>
<string name="sign_policy_fully_explainer">Sign automatically every request</string>
<string name="event_kind_10050">DM relays</string>