diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 217e5c51..fdf8d994 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 363eddc8..7c78bfb2 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -107,4 +107,12 @@ dependencies {
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
+
+ // Markdown (With fix for full-image bleeds)
+ implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
+ implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:a0954aba63')
+ implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
+
+ // Biometrics
+ implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
}
\ No newline at end of file
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
index ed10d9a6..3e6b48a0 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
@@ -1,35 +1,99 @@
package com.greenart7c3.nostrsigner.ui
import android.annotation.SuppressLint
+import android.app.Activity
+import android.app.KeyguardManager
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.os.Build
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
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.material.AlertDialog
import androidx.compose.material.Button
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.Divider
+import androidx.compose.material.DrawerValue
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
+import androidx.compose.material.Surface
import androidx.compose.material.Text
+import androidx.compose.material.TextButton
+import androidx.compose.material.TopAppBar
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Key
+import androidx.compose.material.icons.filled.ManageAccounts
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.rememberDrawerState
+import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
import com.greenart7c3.nostrsigner.MainActivity
+import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.IntentData
+import com.greenart7c3.nostrsigner.models.toHexKey
import com.greenart7c3.nostrsigner.service.model.Event
import com.greenart7c3.nostrsigner.service.relays.Relay
+import com.greenart7c3.nostrsigner.service.toNpub
+import com.greenart7c3.nostrsigner.service.toNsec
import com.greenart7c3.nostrsigner.ui.actions.ButtonBorder
+import com.greenart7c3.nostrsigner.ui.actions.CloseButton
import com.greenart7c3.nostrsigner.ui.actions.RelaySelectionDialog
+import com.halilibo.richtext.markdown.Markdown
+import com.halilibo.richtext.ui.RichTextStyle
+import com.halilibo.richtext.ui.material.MaterialRichText
+import com.halilibo.richtext.ui.resolveDefaults
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
import org.json.JSONObject
@Composable
@@ -50,16 +114,17 @@ fun AccountScreen(
LoginPage(accountStateViewModel)
}
is AccountState.LoggedIn -> {
- MainScreen(state.account, event, mainActivity)
+ MainScreen(state.account, accountStateViewModel, event, mainActivity)
}
}
}
}
}
+@OptIn(ExperimentalMaterialApi::class)
@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
@Composable
-fun MainScreen(account: Account, json: IntentData?, mainActivity: MainActivity) {
+fun MainScreen(account: Account, accountStateViewModel: AccountStateViewModel, json: IntentData?, mainActivity: MainActivity) {
val event = remember {
mutableStateOf(null)
}
@@ -70,8 +135,109 @@ fun MainScreen(account: Account, json: IntentData?, mainActivity: MainActivity)
}
val relaysToPost = remember { mutableListOf() }
val clipboardManager = LocalClipboardManager.current
+ val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
+ val coroutineScope = rememberCoroutineScope()
+ val context = LocalContext.current
Scaffold(
+ scaffoldState = scaffoldState,
+ drawerContent = {
+ var logoutDialog by remember { mutableStateOf(false) }
+ var backupDialogOpen by remember { mutableStateOf(false) }
+
+ if (logoutDialog) {
+ AlertDialog(
+ title = {
+ Text(text = "Logout")
+ },
+ text = {
+ Text(text = "Logging out deletes all your local information. Make sure to have your private keys backed up to avoid losing your account. Do you want to continue?")
+ },
+ onDismissRequest = {
+ logoutDialog = false
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ logoutDialog = false
+ accountStateViewModel.logOff(account.keyPair.pubKey.toNpub())
+ }
+ ) {
+ Text(text = "Logout")
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ logoutDialog = false
+ }
+ ) {
+ Text(text = "Cancel")
+ }
+ }
+ )
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colors.background
+ ) {
+ Column {
+ Spacer(modifier = Modifier.weight(1f))
+ IconRow(
+ title = "Backup Keys",
+ icon = Icons.Default.Key,
+ tint = MaterialTheme.colors.onBackground,
+ onClick = {
+ coroutineScope.launch {
+ scaffoldState.drawerState.close()
+ }
+ backupDialogOpen = true
+ }
+ )
+ Divider(
+ thickness = 0.25.dp,
+ modifier = Modifier.padding(top = 20.dp)
+ )
+ IconRow(
+ title = "Logout",
+ icon = Icons.Default.ManageAccounts,
+ tint = MaterialTheme.colors.onBackground,
+ onClick = {
+ logoutDialog = true
+ }
+ )
+ }
+ }
+ if (backupDialogOpen) {
+ AccountBackupDialog(account, onClose = { backupDialogOpen = false })
+ }
+ BackHandler(enabled = scaffoldState.drawerState.isOpen) {
+ coroutineScope.launch { scaffoldState.drawerState.close() }
+ }
+ },
+ topBar = {
+ TopAppBar(
+ title = { },
+ navigationIcon = {
+ IconButton(
+ onClick = {
+ coroutineScope.launch {
+ scaffoldState.drawerState.open()
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Menu,
+ contentDescription = null,
+ tint = MaterialTheme.colors.onSurface
+ )
+ }
+ },
+ backgroundColor = Color.Transparent,
+ elevation = 0.dp
+ )
+ },
bottomBar = {
Row(
Modifier
@@ -95,6 +261,17 @@ fun MainScreen(account: Account, json: IntentData?, mainActivity: MainActivity)
return@Button
}
+ if (event.value!!.pubKey != account.keyPair.pubKey.toHexKey()) {
+ coroutineScope.launch {
+ Toast.makeText(
+ context,
+ context.getString(R.string.event_pubkey_is_not_equal_to_current_logged_in_user),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ return@Button
+ }
+
val signedEvent = Event.create(
account.keyPair.privKey!!,
event.value!!.kind,
@@ -115,6 +292,18 @@ fun MainScreen(account: Account, json: IntentData?, mainActivity: MainActivity)
if (event.value == null) {
return@Button
}
+
+ if (event.value!!.pubKey != account.keyPair.pubKey.toHexKey()) {
+ coroutineScope.launch {
+ Toast.makeText(
+ context,
+ context.getString(R.string.event_pubkey_is_not_equal_to_current_logged_in_user),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ return@Button
+ }
+
val signedEvent = Event.create(
account.keyPair.privKey!!,
event.value!!.kind,
@@ -188,3 +377,308 @@ fun MainScreen(account: Account, json: IntentData?, mainActivity: MainActivity)
}
}
}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun IconRow(title: String, icon: ImageVector, tint: Color, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 15.dp, horizontal = 25.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ icon,
+ null,
+ modifier = Modifier.size(22.dp),
+ tint = tint
+ )
+ Text(
+ modifier = Modifier.padding(start = 16.dp),
+ text = title,
+ fontSize = 18.sp
+ )
+ }
+ }
+}
+
+@SuppressWarnings("MissingJvmstatic")
+@Composable
+fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) {
+ // Safely update the current `onBack` lambda when a new one is provided
+ val currentOnBack by rememberUpdatedState(onBack)
+ // Remember in Composition a back callback that calls the `onBack` lambda
+ val backCallback = remember {
+ object : OnBackPressedCallback(enabled) {
+ override fun handleOnBackPressed() {
+ currentOnBack()
+ }
+ }
+ }
+ // On every successful composition, update the callback with the `enabled` value
+ SideEffect {
+ backCallback.isEnabled = enabled
+ }
+ val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
+ "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
+ }.onBackPressedDispatcher
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, backDispatcher) {
+ // Add callback to the backDispatcher
+ backDispatcher.addCallback(lifecycleOwner, backCallback)
+ // When the effect leaves the Composition, remove the callback
+ onDispose {
+ backCallback.remove()
+ }
+ }
+}
+
+@Composable
+fun AccountBackupDialog(account: Account, onClose: () -> Unit) {
+ Dialog(
+ onDismissRequest = onClose,
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .background(MaterialTheme.colors.background)
+ .fillMaxSize()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(10.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CloseButton(onCancel = onClose)
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 30.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ MaterialRichText(
+ style = RichTextStyle().resolveDefaults()
+ ) {
+ Markdown(
+ content = stringResource(R.string.account_backup_tips_md)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(30.dp))
+
+ NSecCopyButton(account)
+ NPubCopyButton(account)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun NSecCopyButton(
+ account: Account
+) {
+ val clipboardManager = LocalClipboardManager.current
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ val keyguardLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ copyNSec(context, scope, account, clipboardManager)
+ }
+ }
+
+ Button(
+ modifier = Modifier.padding(horizontal = 3.dp),
+ onClick = {
+ authenticate(
+ title = context.getString(R.string.copy_my_secret_key),
+ context = context,
+ scope = scope,
+ keyguardLauncher = keyguardLauncher
+ ) {
+ copyNSec(context, scope, account, clipboardManager)
+ }
+ },
+ shape = ButtonBorder,
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = MaterialTheme.colors.primary
+ ),
+ contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
+ ) {
+ Icon(
+ tint = MaterialTheme.colors.onPrimary,
+ imageVector = Icons.Default.Key,
+ contentDescription = stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup),
+ modifier = Modifier.padding(end = 5.dp)
+ )
+ Text(stringResource(id = R.string.copy_my_secret_key), color = MaterialTheme.colors.onPrimary)
+ }
+}
+
+@Composable
+private fun NPubCopyButton(
+ account: Account
+) {
+ val clipboardManager = LocalClipboardManager.current
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ Button(
+ modifier = Modifier.padding(horizontal = 3.dp),
+ onClick = {
+ clipboardManager.setText(AnnotatedString(account.keyPair.pubKey.toNpub()))
+ scope.launch {
+ Toast.makeText(
+ context,
+ context.getString(R.string.public_key_npub_copied_to_clipboard),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ },
+ shape = ButtonBorder,
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = MaterialTheme.colors.primary
+ ),
+ contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
+ ) {
+ Icon(
+ tint = MaterialTheme.colors.onPrimary,
+ imageVector = Icons.Default.Key,
+ contentDescription = stringResource(R.string.copies_the_npub_id_your_user_to_the_clipboard_for_backup),
+ modifier = Modifier.padding(end = 5.dp)
+ )
+ Text(
+ stringResource(R.string.copy_my_public_key),
+ color = MaterialTheme.colors.onPrimary
+ )
+ }
+}
+
+private fun copyNSec(
+ context: Context,
+ scope: CoroutineScope,
+ account: Account,
+ clipboardManager: ClipboardManager
+) {
+ account.keyPair.privKey?.let {
+ clipboardManager.setText(AnnotatedString(it.toNsec()))
+ scope.launch {
+ Toast.makeText(
+ context,
+ context.getString(R.string.secret_key_copied_to_clipboard),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+}
+
+fun Context.getAppCompatActivity(): AppCompatActivity? {
+ var currentContext = this
+ while (currentContext is ContextWrapper) {
+ if (currentContext is AppCompatActivity) {
+ return currentContext
+ }
+ currentContext = currentContext.baseContext
+ }
+ return null
+}
+
+fun authenticate(
+ title: String,
+ context: Context,
+ scope: CoroutineScope,
+ keyguardLauncher: ManagedActivityResultLauncher,
+ onApproved: () -> Unit
+) {
+ val fragmentContext = context.getAppCompatActivity()!!
+ val keyguardManager =
+ fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+
+ if (!keyguardManager.isDeviceSecure) {
+ onApproved()
+ return
+ }
+
+ @Suppress("DEPRECATION")
+ fun keyguardPrompt() {
+ val intent = keyguardManager.createConfirmDeviceCredentialIntent(
+ context.getString(R.string.app_name),
+ title
+ )
+
+ keyguardLauncher.launch(intent)
+ }
+
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
+ keyguardPrompt()
+ return
+ }
+
+ val biometricManager = BiometricManager.from(context)
+ val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
+
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(context.getString(R.string.app_name))
+ .setSubtitle(title)
+ .setAllowedAuthenticators(authenticators)
+ .build()
+
+ val biometricPrompt = BiometricPrompt(
+ fragmentContext,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ super.onAuthenticationError(errorCode, errString)
+
+ when (errorCode) {
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON -> keyguardPrompt()
+ BiometricPrompt.ERROR_LOCKOUT -> keyguardPrompt()
+ else ->
+ scope.launch {
+ Toast.makeText(
+ context,
+ "${context.getString(R.string.biometric_error)}: $errString",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+
+ override fun onAuthenticationFailed() {
+ super.onAuthenticationFailed()
+ scope.launch {
+ Toast.makeText(
+ context,
+ context.getString(R.string.biometric_authentication_failed),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+ onApproved()
+ }
+ }
+ )
+
+ when (biometricManager.canAuthenticate(authenticators)) {
+ BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo)
+ else -> keyguardPrompt()
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt
index 874cbdf0..ce2ad177 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt
@@ -29,6 +29,26 @@ class AccountStateViewModel : ViewModel() {
}
}
+ @OptIn(DelicateCoroutinesApi::class)
+ private fun prepareLogoutOrSwitch() {
+ when (val state = accountContent.value) {
+ is AccountState.LoggedIn -> {
+ GlobalScope.launch(Dispatchers.Main) {
+ state.account.saveable.removeObserver(saveListener)
+ }
+ }
+ else -> {}
+ }
+
+ _accountContent.update { AccountState.LoggedOff }
+ }
+
+ fun logOff(npub: String) {
+ prepareLogoutOrSwitch()
+ LocalPreferences.updatePrefsForLogout(npub)
+ tryLoginExistingAccount()
+ }
+
fun startUI(key: String) {
if (!key.startsWith("nsec")) {
throw Exception("Not nsec key")
diff --git a/app/src/main/res/drawable/manage_accounts.xml b/app/src/main/res/drawable/manage_accounts.xml
new file mode 100644
index 00000000..734c7055
--- /dev/null
+++ b/app/src/main/res/drawable/manage_accounts.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0b9e23f4..9c9e32cd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,19 @@
-
+
Amber
+
+ ## Key Backup and Safety Tips
+ \n\nYour account is secured by a secret key. The key is long random string starting with **nsec1**. Anyone who has access to your secret key can publish content using your identity.
+ \n\n- Do **not** put your secret key in any website or software you do not trust.
+ \n- Amber developers will **never** ask you for your secret key.
+ \n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager.
+
+ Secret key (nsec) copied to clipboard
+ Copy my secret key
+ Authentication failed
+ Error
+ Copies the Nsec ID (your password) to the clipboard for backup
+ Public key (npub) copied to clipboard
+ Copies the Npub ID (your user) to the clipboard for backup
+ Copy my public key
+ Event pubkey is not equal to current logged in user
\ No newline at end of file