Merge pull request #43 from dmnyc/feat/remote-signer-keys-screen

feat(keys): show signer card for REMOTE accounts, fix missing npub
This commit is contained in:
Barry Deen
2026-06-30 17:37:32 -04:00
committed by GitHub
@@ -46,7 +46,9 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.darkwisp.app.R
import com.darkwisp.app.nostr.Nip19
import com.darkwisp.app.nostr.hexToByteArray
import com.darkwisp.app.repo.KeyRepository
import com.darkwisp.app.repo.SigningMode
private fun android.content.Context.findFragmentActivity(): FragmentActivity? {
var ctx = this
@@ -63,8 +65,12 @@ fun KeysScreen(
keyRepository: KeyRepository,
onBack: () -> Unit
) {
val pubkeyHex = remember { keyRepository.getPubkeyHex() }
val keypair = remember { keyRepository.getKeypair() }
val npub = remember { keypair?.let { Nip19.npubEncode(it.pubkey) } }
val npub = remember {
pubkeyHex?.let { Nip19.npubEncode(it.hexToByteArray()) }
?: keypair?.let { Nip19.npubEncode(it.pubkey) }
}
var nsec by remember { mutableStateOf<String?>(null) }
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
@@ -136,106 +142,137 @@ fun KeysScreen(
Spacer(modifier = Modifier.height(24.dp))
// Private key section
Text(
text = stringResource(R.string.settings_private_key),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
if (nsec != null) {
OutlinedCard(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
when (keyRepository.getSigningMode()) {
SigningMode.REMOTE -> {
Text(
text = stringResource(R.string.settings_private_key),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = nsec ?: "",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
text = "Signing is handled by a connected signer app. No private key is stored on this device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(12.dp)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = {
nsec?.let { key ->
val clip = ClipData.newPlainText("", key)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
clip.description.extras = PersistableBundle().apply {
putBoolean("android.content.extra.IS_SENSITIVE", true)
}
}
val cm = context.getSystemService(ClipboardManager::class.java)
cm.setPrimaryClip(clip)
Toast.makeText(context, context.getString(R.string.settings_private_key_copied), Toast.LENGTH_SHORT).show()
}
}) {
Icon(
Icons.Outlined.ContentCopy,
contentDescription = stringResource(R.string.btn_copy),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
} else {
Button(
onClick = {
val activity = context.findFragmentActivity()
?: return@Button
SigningMode.READ_ONLY -> {
Text(
text = stringResource(R.string.settings_private_key),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No private key is stored on this device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
SigningMode.LOCAL -> {
Text(
text = stringResource(R.string.settings_private_key),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
val biometricManager = BiometricManager.from(context)
val canAuth = biometricManager.canAuthenticate(
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) {
// No device lock set — reveal directly
keypair?.let { nsec = Nip19.nsecEncode(it.privkey) }
return@Button
}
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
keypair?.let { nsec = Nip19.nsecEncode(it.privkey) }
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON
) {
Toast.makeText(context, context.getString(R.string.settings_auth_failed, errString), Toast.LENGTH_SHORT).show()
if (nsec != null) {
OutlinedCard(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = nsec ?: "",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = {
nsec?.let { key ->
val clip = ClipData.newPlainText("", key)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
clip.description.extras = PersistableBundle().apply {
putBoolean("android.content.extra.IS_SENSITIVE", true)
}
}
val cm = context.getSystemService(ClipboardManager::class.java)
cm.setPrimaryClip(clip)
Toast.makeText(context, context.getString(R.string.settings_private_key_copied), Toast.LENGTH_SHORT).show()
}
}) {
Icon(
Icons.Outlined.ContentCopy,
contentDescription = stringResource(R.string.btn_copy),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
} else {
Button(
onClick = {
val activity = context.findFragmentActivity()
?: return@Button
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(revealPrivateKeyTitle)
.setDescription(revealPrivateKeyDescription)
.setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL)
.build()
val biometricManager = BiometricManager.from(context)
val canAuth = biometricManager.canAuthenticate(
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) {
keypair?.let { nsec = Nip19.nsecEncode(it.privkey) }
return@Button
}
BiometricPrompt(activity, executor, callback).authenticate(promptInfo)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Outlined.Visibility, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.btn_reveal_private_key))
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
keypair?.let { nsec = Nip19.nsecEncode(it.privkey) }
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON
) {
Toast.makeText(context, context.getString(R.string.settings_auth_failed, errString), Toast.LENGTH_SHORT).show()
}
}
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(revealPrivateKeyTitle)
.setDescription(revealPrivateKeyDescription)
.setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL)
.build()
BiometricPrompt(activity, executor, callback).authenticate(promptInfo)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Outlined.Visibility, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.btn_reveal_private_key))
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.private_key_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.private_key_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}