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