Rewrites NIP-55 to start offering better error handling.

This commit is contained in:
Vitor Pamplona
2025-07-14 11:17:59 -04:00
parent f93b08a272
commit 510583e72e
78 changed files with 3809 additions and 1555 deletions
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.sha256.sha256
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
@@ -55,9 +56,12 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
companion object {
val accountSettings = AccountSettings(KeyPair())
val account =
Account(
AccountSettings(KeyPair()),
settings = accountSettings,
signer = NostrSignerInternal(accountSettings.keyPair),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedFi
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.verify
import junit.framework.TestCase
@@ -138,7 +139,13 @@ class ThreadDualAxisChartAssemblerTest {
null,
)
val account = Account(AccountSettings(KeyPair()), scope = CoroutineScope(Dispatchers.IO + SupervisorJob()))
val keyPair = KeyPair()
val account =
Account(
settings = AccountSettings(keyPair = keyPair),
signer = NostrSignerInternal(keyPair),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
withContext(Dispatchers.Main) {
val user = account.userProfile().flow()
}
@@ -222,7 +222,7 @@ import kotlin.coroutines.resume
@Stable
class Account(
val settings: AccountSettings = AccountSettings(KeyPair()),
val signer: NostrSigner = settings.createSigner(),
val signer: NostrSigner,
geolocationFlow: StateFlow<LocationState.LocationResult>,
val cache: LocalCache,
val client: NostrClient,
@@ -20,9 +20,8 @@
*/
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.content.ContentResolver
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -39,6 +38,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent
@@ -46,8 +47,9 @@ import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher
import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
@@ -61,14 +63,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import java.util.Locale
val DefaultChannelSet =
setOf(
// Anigma's Nostr
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
// Amethyst's Group
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
)
val DefaultChannels =
listOf(
// Anigma's Nostr
@@ -90,6 +84,17 @@ val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
val DefaultSearchRelayList = setOf(Constants.band, Constants.wine, Constants.where, Constants.nostoday)
val DefaultSignerPermissions =
listOf(
Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND),
Permission(CommandType.SIGN_EVENT, DraftEvent.KIND),
Permission(CommandType.NIP04_ENCRYPT),
Permission(CommandType.NIP04_DECRYPT),
Permission(CommandType.NIP44_DECRYPT),
Permission(CommandType.NIP44_DECRYPT),
Permission(CommandType.DECRYPT_ZAP_EVENT),
)
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val GLOBAL_FOLLOWS = " Global "
@@ -147,25 +152,18 @@ class AccountSettings(
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
fun createSigner() =
fun createSigner(contentResolver: ContentResolver) =
if (keyPair.privKey != null) {
NostrSignerInternal(keyPair)
} else {
when (val packageName = externalSignerPackageName) {
null -> NostrSignerInternal(keyPair)
else -> {
val externalSignerLauncher = ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName)
// TODO: How to handle the launcher here?
try {
externalSignerLauncher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
} catch (e: Exception) {
Log.d("AccountSettings", "Failed to initialize external signer", e)
}
NostrSignerExternal(keyPair.pubKey.toHexKey(), externalSignerLauncher)
}
else ->
NostrSignerExternal(
pubKey = keyPair.pubKey.toHexKey(),
packageName = packageName,
contentResolver = contentResolver,
)
}
}
@@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.service
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
object PackageUtils {
@SuppressLint("QueryPermissionsNeeded")
@@ -36,14 +34,4 @@ object PackageUtils {
} != null
fun isOrbotInstalled(context: Context): Boolean = isPackageInstalled(context, "org.torproject.android")
fun isExternalSignerInstalled(context: Context): Boolean {
val intent =
Intent().apply {
action = Intent.ACTION_VIEW
data = "nostrsigner:".toUri()
}
val infos = context.packageManager.queryIntentActivities(intent, 0)
return infos.size > 0
}
}
@@ -24,7 +24,6 @@ import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -44,7 +43,6 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
@@ -86,15 +84,7 @@ class EventNotificationConsumer(
pushWrappedEvent: GiftWrapEvent,
account: AccountSettings,
) {
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
val signer = account.createSigner()
if (signer is NostrSignerExternal) {
signer.launcher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
}
val signer = account.createSigner(applicationContext.contentResolver)
pushWrappedEvent.unwrapThrowing(signer) { notificationEvent ->
consumeNotificationEvent(notificationEvent, signer, account)
@@ -144,16 +134,7 @@ class EventNotificationConsumer(
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
Log.d(TAG, "New Notification Testing if for ${it.npub}")
try {
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
val signer = acc.createSigner()
if (signer is NostrSignerExternal) {
signer.launcher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
}
val signer = acc.createSigner(applicationContext.contentResolver)
consumeNotificationEvent(event, signer, acc)
matchAccount = true
} catch (e: Exception) {
@@ -29,7 +29,6 @@ import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.Dispatchers
@@ -63,16 +62,7 @@ class RegisterAccounts(
return mapNotNullAsync(remainingTos) { info ->
tryAndWait { continuation ->
val signer = info.accountSettings.createSigner()
// TODO: Modify the external launcher to launch as different users.
// Right now it only registers if Amber has already approved this signature
if (signer is NostrSignerExternal) {
signer.launcher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
}
val signer = info.accountSettings.createSigner(Amethyst.instance.contentResolver)
RelayAuthEvent.create(info.relays, notificationToken, signer) { result ->
continuation.resume(result)
}
@@ -160,7 +160,15 @@ class AccountViewModel(
val app: Amethyst,
) : ViewModel(),
Dao {
val account = Account(accountSettings, accountSettings.createSigner(), app.locationManager.geohashStateFlow, LocalCache, app.client, viewModelScope)
val account =
Account(
accountSettings,
accountSettings.createSigner(app.contentResolver),
app.locationManager.geohashStateFlow,
LocalCache,
app.client,
viewModelScope,
)
val newNotesPreProcessor = PrecacheNewNotesProcessor(account, LocalCache)
@@ -31,10 +31,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
@@ -44,8 +41,6 @@ import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -54,7 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.Chat
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -156,7 +151,10 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
job?.cancel()
job =
scope.launch {
PushNotificationUtils.checkAndInit(LocalPreferences.allSavedAccounts(), accountViewModel::okHttpClientForTrustedRelays)
PushNotificationUtils.checkAndInit(
LocalPreferences.allSavedAccounts(),
accountViewModel::okHttpClientForTrustedRelays,
)
}
onPauseOrDispose {
@@ -167,55 +165,25 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
@Composable
private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
if (accountViewModel.account.signer is NostrSignerExternal) {
val activity = getActivity() as MainActivity
val lifeCycleOwner = LocalLifecycleOwner.current
if (accountViewModel.account.signer is IActivityLauncher) {
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = { result ->
if (result.resultCode != Activity.RESULT_OK) {
accountViewModel.toastManager.toast(
R.string.sign_request_rejected,
R.string.sign_request_rejected_description,
)
} else {
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let {
accountViewModel.runOnIO {
accountViewModel.account.signer.launcher
.newResult(it)
accountViewModel.account.signer.newResponse(it)
}
}
}
},
)
DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity, lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
accountViewModel.account.signer.launcher.registerLauncher(
launcher = {
try {
launcher.launch(it)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Signer", "Error opening Signer app", e)
accountViewModel.toastManager.toast(
R.string.error_opening_external_signer,
R.string.error_opening_external_signer_description,
)
}
},
contentResolver = Amethyst.instance::contentResolverFn,
)
}
}
val launcher: (Intent) -> Unit = {
DisposableEffect(accountViewModel, accountViewModel.account, launcher) {
val launcher: (Intent) -> Unit = { intent ->
try {
launcher.launch(it)
launcher.launch(intent)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Signer", "Error opening Signer app", e)
@@ -226,15 +194,9 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
accountViewModel.account.signer.launcher.registerLauncher(
launcher = launcher,
contentResolver = Amethyst.instance::contentResolverFn,
)
accountViewModel.account.signer.registerForegroundLauncher(launcher)
onDispose {
accountViewModel.account.signer.launcher
.clearLauncherIf(launcher)
lifeCycleOwner.lifecycle.removeObserver(observer)
accountViewModel.account.signer.unregisterForegroundLauncher(launcher)
}
}
}
@@ -27,6 +27,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginPage
import com.vitorpamplona.amethyst.ui.screen.loggedOff.signup.SignUpPage
@Composable
fun LoginOrSignupScreen(
@@ -1,814 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff
import android.app.Activity
import android.content.Intent
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.service.PackageUtils
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size50dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher
import com.vitorpamplona.quartz.nip55AndroidSigner.SignerType
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
fun LoginPage() {
val accountViewModel: AccountStateViewModel = viewModel()
ThemeComparisonRow(
toPreview = {
LoginPage(accountViewModel, true) {}
},
)
}
@Composable
fun LoginPage(
accountStateViewModel: AccountStateViewModel,
isFirstLogin: Boolean,
newAccountKey: String? = null,
onWantsToLogin: () -> Unit,
) {
val key = remember { mutableStateOf(TextFieldValue(newAccountKey ?: "")) }
var errorMessage by remember { mutableStateOf("") }
val acceptedTerms = remember { mutableStateOf(!isFirstLogin) }
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
val context = LocalContext.current
val torSettings = remember { mutableStateOf(TorSettings()) }
val isNFCOrQR = remember { mutableStateOf(false) }
val isTemporary = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var loginWithExternalSigner by remember { mutableStateOf(false) }
var processingLogin by remember { mutableStateOf(false) }
val password = remember { mutableStateOf(TextFieldValue("")) }
val needsPassword =
remember {
derivedStateOf {
key.value.text.startsWith("ncryptsec1")
}
}
val passwordFocusRequester = remember { FocusRequester() }
if (loginWithExternalSigner) {
PrepareExternalSignerReceiver { pubkey, packageName ->
key.value = TextFieldValue(pubkey)
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required)
}
if (key.value.text.isBlank()) {
errorMessage = stringRes(context, R.string.key_is_required)
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
accountStateViewModel.login(
key = key.value.text,
torSettings = torSettings.value,
transientAccount = isTemporary.value,
loginWithExternalSigner = true,
packageName = packageName,
) {
errorMessage = stringRes(context, R.string.invalid_key)
}
}
}
}
Column(
modifier =
Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(Size20dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
imageVector = CustomHashTagIcons.Amethyst,
contentDescription = stringRes(R.string.app_logo),
modifier = Modifier.size(150.dp),
contentScale = ContentScale.Inside,
)
Spacer(modifier = Modifier.height(Size40dp))
KeyTextField(
value = key.value,
onValueChange = { value, isQr ->
key.value = value
if (isQr) {
isNFCOrQR.value = true
isTemporary.value = true
}
if (errorMessage.isNotEmpty()) {
errorMessage = ""
}
},
) {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required)
}
if (key.value.text.isBlank()) {
errorMessage = stringRes(context, R.string.key_is_required)
}
if (needsPassword.value && password.value.text.isBlank()) {
errorMessage = stringRes(context, R.string.password_is_required)
}
if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) {
processingLogin = true
accountStateViewModel.login(
key = key.value.text,
password = password.value.text,
torSettings = torSettings.value,
transientAccount = isTemporary.value,
) {
processingLogin = false
errorMessage =
if (it != null) {
stringRes(context, R.string.invalid_key_with_message, it)
} else {
stringRes(context, R.string.invalid_key)
}
}
}
}
if (errorMessage.isNotBlank()) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(10.dp))
if (needsPassword.value) {
PasswordField(
value = password.value,
onValueChange = {
password.value = it
if (errorMessage.isNotEmpty()) {
errorMessage = ""
}
},
passwordFocusRequester,
) {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required)
}
if (key.value.text.isBlank()) {
errorMessage = stringRes(context, R.string.key_is_required)
}
if (needsPassword.value && password.value.text.isBlank()) {
errorMessage = stringRes(context, R.string.password_is_required)
}
if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) {
processingLogin = true
accountStateViewModel.login(key.value.text, password.value.text, torSettings.value, isTemporary.value) {
processingLogin = false
errorMessage =
if (it != null) {
stringRes(context, R.string.invalid_key_with_message, it)
} else {
stringRes(context, R.string.invalid_key)
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
TorSettingsSetup(
torSettings = torSettings.value,
onCheckedChange = {
torSettings.value = it
},
onError = {
scope.launch {
Toast
.makeText(
context,
it,
Toast.LENGTH_LONG,
).show()
}
},
)
if (isNFCOrQR.value) {
OfferTemporaryAccount(
checked = isTemporary.value,
onCheckedChange = { isTemporary.value = it },
)
}
if (isFirstLogin) {
AcceptTerms(
checked = acceptedTerms.value,
onCheckedChange = { acceptedTerms.value = it },
)
if (termsAcceptanceIsRequired.isNotBlank()) {
Text(
text = termsAcceptanceIsRequired,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(modifier = Modifier.height(Size10dp))
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
LoginButton(
enabled = acceptedTerms.value,
processingLogin = processingLogin,
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired =
stringRes(context, R.string.acceptance_of_terms_is_required)
}
if (key.value.text.isBlank()) {
errorMessage = stringRes(context, R.string.key_is_required)
}
if (needsPassword.value && password.value.text.isBlank()) {
errorMessage = stringRes(context, R.string.password_is_required)
}
if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) {
processingLogin = true
accountStateViewModel.login(key.value.text, password.value.text, torSettings.value, isTemporary.value) {
processingLogin = false
errorMessage =
if (it != null) {
stringRes(context, R.string.invalid_key_with_message, it)
} else {
stringRes(context, R.string.invalid_key)
}
}
}
},
)
}
if (PackageUtils.isExternalSignerInstalled(context)) {
Box(modifier = Modifier.padding(Size40dp, Size20dp, Size40dp, Size0dp)) {
LoginWithAmberButton(
enabled = acceptedTerms.value,
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required)
} else {
loginWithExternalSigner = true
}
},
)
}
}
Spacer(modifier = Modifier.height(Size40dp))
Text(text = stringRes(R.string.don_t_have_an_account))
Spacer(modifier = Modifier.height(Size20dp))
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
SignUpButton(onWantsToLogin)
}
}
OpenURIIfNotLoggedIn {
key.value = TextFieldValue(it)
acceptedTerms.value = true
isNFCOrQR.value = true
isTemporary.value = true
if (it.startsWith("ncryptsec1")) {
delay(300)
passwordFocusRequester.requestFocus()
}
}
}
@Composable
fun OfferTemporaryAccount(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
)
Text(stringRes(R.string.temporary_account))
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun PasswordField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
passwordFocusRequester: FocusRequester,
onGo: () -> Unit,
) {
val autofillNodeKey =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { onValueChange(TextFieldValue(it)) },
)
val autofillNodePassword =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { onValueChange(TextFieldValue(it)) },
)
val autofill = LocalAutofill.current
LocalAutofillTree.current += autofillNodeKey
LocalAutofillTree.current += autofillNodePassword
var showCharsPassword by remember { mutableStateOf(false) }
OutlinedTextField(
modifier =
Modifier
.focusRequester(passwordFocusRequester)
.onGloballyPositioned { coordinates ->
autofillNodePassword.boundingBox = coordinates.boundsInWindow()
}.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNodePassword)
} else {
cancelAutofillForNode(autofillNodePassword)
}
}
},
value = value,
onValueChange = onValueChange,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
placeholder = {
Text(
text = stringRes(R.string.ncryptsec_password),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
Row {
IconButton(onClick = { showCharsPassword = !showCharsPassword }) {
Icon(
imageVector =
if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription =
if (showCharsPassword) {
stringRes(R.string.show_password)
} else {
stringRes(
R.string.hide_password,
)
},
)
}
}
},
visualTransformation =
if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardActions =
KeyboardActions(
onGo = {
onGo()
},
),
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun KeyTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue, throughQR: Boolean) -> Unit,
onLogin: () -> Unit,
) {
var dialogOpen by remember { mutableStateOf(false) }
var showCharsKey by remember { mutableStateOf(false) }
val autofillNodeKey =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { onValueChange(TextFieldValue(it), false) },
)
val autofillNodePassword =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { onValueChange(TextFieldValue(it), false) },
)
val autofill = LocalAutofill.current
LocalAutofillTree.current += autofillNodeKey
LocalAutofillTree.current += autofillNodePassword
OutlinedTextField(
modifier =
Modifier
.onGloballyPositioned { coordinates ->
autofillNodeKey.boundingBox = coordinates.boundsInWindow()
}.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNodeKey)
} else {
cancelAutofillForNode(autofillNodeKey)
}
}
},
value = value,
onValueChange = { onValueChange(it, false) },
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
placeholder = {
Text(
text = stringRes(R.string.nsec_npub_hex_private_key),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
Row {
IconButton(onClick = { showCharsKey = !showCharsKey }) {
Icon(
imageVector =
if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription =
if (showCharsKey) {
stringRes(R.string.show_password)
} else {
stringRes(
R.string.hide_password,
)
},
)
}
}
},
leadingIcon = {
if (dialogOpen) {
SimpleQrCodeScanner {
dialogOpen = false
if (!it.isNullOrEmpty()) {
onValueChange(TextFieldValue(it), true)
}
}
}
IconButton(onClick = { dialogOpen = true }) {
Icon(
painter = painterRes(R.drawable.ic_qrcode, 5),
contentDescription =
stringRes(
R.string.login_with_qr_code,
),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
},
visualTransformation =
if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(),
keyboardActions =
KeyboardActions(
onGo = {
onLogin()
},
),
)
}
@Composable
private fun PrepareExternalSignerReceiver(onLogin: (pubkey: String, packageName: String) -> Unit) {
val scope = rememberCoroutineScope()
val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") }
val id = remember { UUID.randomUUID().toString() }
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = { result ->
if (result.resultCode != Activity.RESULT_OK) {
scope.launch(Dispatchers.Main) {
Toast
.makeText(
Amethyst.instance,
"Sign request rejected",
Toast.LENGTH_SHORT,
).show()
}
} else {
result.data?.let { externalSignerLauncher.newResult(it) }
}
},
)
val activity = getActivity() as MainActivity
DisposableEffect(launcher, activity, externalSignerLauncher) {
val launcher: (Intent) -> Unit = {
try {
launcher.launch(it)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Signer", "Error opening Signer app", e)
scope.launch(Dispatchers.Main) {
Toast
.makeText(
Amethyst.instance,
R.string.error_opening_external_signer,
Toast.LENGTH_SHORT,
).show()
}
}
}
externalSignerLauncher.registerLauncher(
launcher = launcher,
contentResolver = Amethyst.instance::contentResolverFn,
)
onDispose {
externalSignerLauncher.clearLauncherIf(launcher)
}
}
LaunchedEffect(externalSignerLauncher) {
externalSignerLauncher.openSignerApp(
"",
SignerType.GET_PUBLIC_KEY,
"",
id,
) { result ->
val split = result.split("-")
val pubkey = split.first()
val packageName = if (split.size > 1) split[1] else ""
onLogin(pubkey, packageName)
}
}
}
@Composable
private fun OpenURIIfNotLoggedIn(onNewNIP19: suspend (String) -> Unit) {
val context = LocalContext.current
val activity = context.getActivity()
val scope = rememberCoroutineScope()
var currentIntentNextPage by remember {
val uri =
activity.intent
?.data
?.toString()
?.ifBlank { null }
activity.intent.data = null
mutableStateOf(uri)
}
currentIntentNextPage?.let { intentNextPage ->
var nip19 by remember {
mutableStateOf(
Nip19Parser.tryParseAndClean(currentIntentNextPage),
)
}
LaunchedEffect(intentNextPage) {
if (nip19 != null) {
nip19?.let {
scope.launch {
onNewNIP19(it)
}
nip19 = null
}
} else {
scope.launch {
Toast
.makeText(
context,
stringRes(context, R.string.invalid_nip19_uri_description, intentNextPage),
Toast.LENGTH_SHORT,
).show()
}
}
currentIntentNextPage = null
}
}
DisposableEffect(activity) {
val consumer =
Consumer<Intent> { intent ->
val uri = intent.data?.toString()
if (!uri.isNullOrBlank()) {
val newNip19 = Nip19Parser.tryParseAndClean(uri)
if (newNip19 != null) {
scope.launch {
onNewNIP19(newNip19)
}
} else {
scope.launch {
delay(1000)
Toast
.makeText(
context,
stringRes(context, R.string.invalid_nip19_uri_description, uri),
Toast.LENGTH_SHORT,
).show()
}
}
}
}
activity.addOnNewIntentListener(consumer)
onDispose { activity.removeOnNewIntentListener(consumer) }
}
}
@Composable
fun SignUpButton(onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(50.dp),
) {
Text(
text = stringRes(R.string.sign_up),
modifier = Modifier.padding(horizontal = Size40dp),
)
}
}
@Composable
fun LoginWithAmberButton(
enabled: Boolean,
onClick: () -> Unit,
) {
Button(
enabled = enabled,
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(Size50dp),
) {
Text(
text = stringRes(R.string.login_with_external_signer),
modifier = Modifier.padding(horizontal = Size40dp),
)
}
}
@Composable
fun LoginButton(
enabled: Boolean,
processingLogin: Boolean,
onClick: () -> Unit,
) {
Button(
enabled = enabled,
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(Size50dp),
) {
Row(modifier = Modifier.padding(horizontal = Size40dp)) {
if (processingLogin) {
LoadingAnimation()
Spacer(modifier = DoubleHorzSpacer)
}
Text(stringRes(R.string.login))
}
}
}
@@ -36,7 +36,6 @@ import com.vitorpamplona.amethyst.ui.components.appendLink
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorType
@Composable
fun TorSettingsSetup(
@@ -45,8 +44,6 @@ fun TorSettingsSetup(
onError: (String) -> Unit,
) {
var connectOrbotDialogOpen by remember { mutableStateOf(false) }
var activeTor by remember { mutableStateOf(false) }
val primary = MaterialTheme.colorScheme.primary
Text(
@@ -63,7 +60,6 @@ fun TorSettingsSetup(
torSettings = torSettings,
onClose = { connectOrbotDialogOpen = false },
onPost = { torSettings ->
activeTor = torSettings.torType != TorType.OFF
connectOrbotDialogOpen = false
onCheckedChange(torSettings)
},
@@ -0,0 +1,84 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.login
import android.app.Activity
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.DefaultSignerPermissions
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.client.ExternalSignerLogin
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
fun ExternalSignerButton(loginViewModel: LoginViewModel) {
val scope = rememberCoroutineScope()
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result ->
scope.launch {
val resultData = result.data
if (result.resultCode == Activity.RESULT_OK && resultData != null) {
val loginInfo = ExternalSignerLogin.parseResult(resultData)
if (loginInfo is SignerResult.Successful<PubKeyResult>) {
loginViewModel.updateKey(TextFieldValue(loginInfo.result.pubkey), false)
loginViewModel.loginWithExternalSigner(loginInfo.result.packageName)
}
} else {
loginViewModel.errorManager.error(R.string.sign_request_rejected2)
}
}
}
Box(modifier = Modifier.padding(Size40dp, Size20dp, Size40dp, Size0dp)) {
LoginWithAmberButton(
enabled = loginViewModel.acceptedTerms,
onClick = {
if (!loginViewModel.acceptedTerms) {
loginViewModel.termsAcceptanceIsRequiredError = true
} else {
try {
launcher.launch(ExternalSignerLogin.createIntent(DefaultSignerPermissions))
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Signer", "Error opening Signer app", e)
loginViewModel.errorManager.error(R.string.error_opening_external_signer)
}
}
},
)
}
}
@@ -0,0 +1,161 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.login
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun KeyTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue, throughQR: Boolean) -> Unit,
onLogin: () -> Unit,
) {
var dialogOpen by remember { mutableStateOf(false) }
var showCharsKey by remember { mutableStateOf(false) }
val autofillNodeKey =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { onValueChange(TextFieldValue(it), false) },
)
val autofillNodePassword =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { onValueChange(TextFieldValue(it), false) },
)
val autofill = LocalAutofill.current
LocalAutofillTree.current += autofillNodeKey
LocalAutofillTree.current += autofillNodePassword
OutlinedTextField(
modifier =
Modifier
.onGloballyPositioned { coordinates ->
autofillNodeKey.boundingBox = coordinates.boundsInWindow()
}.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNodeKey)
} else {
cancelAutofillForNode(autofillNodeKey)
}
}
},
value = value,
onValueChange = { onValueChange(it, false) },
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
placeholder = {
Text(
text = stringRes(R.string.nsec_npub_hex_private_key),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
Row {
IconButton(onClick = { showCharsKey = !showCharsKey }) {
Icon(
imageVector =
if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription =
if (showCharsKey) {
stringRes(R.string.show_password)
} else {
stringRes(
R.string.hide_password,
)
},
)
}
}
},
leadingIcon = {
if (dialogOpen) {
SimpleQrCodeScanner {
dialogOpen = false
if (!it.isNullOrEmpty()) {
onValueChange(TextFieldValue(it), true)
}
}
}
IconButton(onClick = { dialogOpen = true }) {
Icon(
painter = painterRes(R.drawable.ic_qrcode, 5),
contentDescription = stringRes(R.string.login_with_qr_code),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
},
visualTransformation =
if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(),
keyboardActions =
KeyboardActions(
onGo = {
onLogin()
},
),
)
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.login
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size50dp
@Composable
fun LoginButton(
enabled: Boolean,
processingLogin: Boolean,
onClick: () -> Unit,
) {
Button(
enabled = enabled,
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(Size50dp),
) {
Row(modifier = Modifier.padding(horizontal = Size40dp)) {
if (processingLogin) {
LoadingAnimation()
Spacer(modifier = DoubleHorzSpacer)
}
Text(stringRes(R.string.login))
}
}
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.login
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class LoginErrorManager {
interface IErrorMsg
class SingleErrorMsg(
val errorResId: Int,
) : IErrorMsg
class ParamsErrorMsg(
val errorResId: Int,
val params: Array<out String>,
) : IErrorMsg
var error by mutableStateOf<IErrorMsg?>(null)
fun clearErrors() {
error = null
}
fun error(resourceId: Int) {
error = SingleErrorMsg(resourceId)
}
fun error(
resourceId: Int,
vararg params: String,
) {
error = ParamsErrorMsg(resourceId, params)
}
}
@@ -0,0 +1,362 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.login
import android.widget.Toast
import androidx.compose.foundation.Image
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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms
import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip55AndroidSigner.client.isExternalSignerInstalled
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
fun LoginPagePreview() {
val accountViewModel: AccountStateViewModel = viewModel()
ThemeComparisonRow(
toPreview = {
LoginPage(accountViewModel, true) {}
},
)
}
@Composable
fun LoginPage(
accountStateViewModel: AccountStateViewModel,
isFirstLogin: Boolean,
newAccountKey: String? = null,
onWantsToLogin: () -> Unit,
) {
val loginViewModel: LoginViewModel = viewModel()
loginViewModel.init(accountStateViewModel)
LaunchedEffect(Unit) {
loginViewModel.load(isFirstLogin, newAccountKey)
}
LoginPage(loginViewModel, onWantsToLogin)
}
@Composable
fun LoginPage(
loginViewModel: LoginViewModel,
onWantsToLogin: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
modifier =
Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(Size20dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
imageVector = CustomHashTagIcons.Amethyst,
contentDescription = stringRes(R.string.app_logo),
modifier = Modifier.size(150.dp),
contentScale = ContentScale.Inside,
)
Spacer(modifier = Modifier.height(Size40dp))
KeyTextField(
value = loginViewModel.key,
onValueChange = loginViewModel::updateKey,
onLogin = loginViewModel::login,
)
loginViewModel.errorManager.error?.let { error ->
when (error) {
is LoginErrorManager.SingleErrorMsg ->
Text(
text = stringRes(error.errorResId),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
is LoginErrorManager.ParamsErrorMsg ->
Text(
text = stringRes(error.errorResId, *error.params),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
else -> {}
}
}
Spacer(modifier = Modifier.height(10.dp))
PasswordField(loginViewModel)
Spacer(modifier = Modifier.height(10.dp))
TorSettingsSetup(
torSettings = loginViewModel.torSettings,
onCheckedChange = loginViewModel::updateTorSettings,
onError = {
scope.launch {
Toast
.makeText(
context,
it,
Toast.LENGTH_LONG,
).show()
}
},
)
if (loginViewModel.offerTemporaryLogin) {
OfferTemporaryAccount(
checked = loginViewModel.isTemporary,
onCheckedChange = { loginViewModel.isTemporary = it },
)
}
if (loginViewModel.isFirstLogin) {
AcceptTerms(
checked = loginViewModel.acceptedTerms,
onCheckedChange = loginViewModel::updateAcceptedTerms,
)
if (loginViewModel.termsAcceptanceIsRequiredError) {
Text(
text = stringRes(R.string.acceptance_of_terms_is_required),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(modifier = Modifier.height(Size10dp))
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
LoginButton(
enabled = loginViewModel.acceptedTerms,
processingLogin = loginViewModel.processingLogin,
onClick = loginViewModel::login,
)
}
if (isExternalSignerInstalled(context)) {
ExternalSignerButton(loginViewModel)
}
Spacer(modifier = Modifier.height(Size40dp))
Text(text = stringRes(R.string.don_t_have_an_account))
Spacer(modifier = Modifier.height(Size20dp))
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
SignUpButton(onWantsToLogin)
}
}
OpenURIIfNotLoggedIn { key ->
loginViewModel.updateKey(TextFieldValue(key), true)
loginViewModel.updateOfferTemporaryLogin(true)
}
}
@Composable
fun OfferTemporaryAccount(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
)
Text(stringRes(R.string.temporary_account))
}
}
@Composable
private fun PasswordField(loginViewModel: LoginViewModel) {
if (loginViewModel.needsPassword) {
val passwordFocusRequester = remember { FocusRequester() }
PasswordField(
value = loginViewModel.password,
onValueChange = loginViewModel::updatePassword,
passwordFocusRequester = passwordFocusRequester,
onGo = loginViewModel::login,
)
LaunchedEffect(Unit) {
delay(300)
passwordFocusRequester.requestFocus()
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun PasswordField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
passwordFocusRequester: FocusRequester,
onGo: () -> Unit,
) {
val autofillNodeKey =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { onValueChange(TextFieldValue(it)) },
)
val autofillNodePassword =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { onValueChange(TextFieldValue(it)) },
)
val autofill = LocalAutofill.current
LocalAutofillTree.current += autofillNodeKey
LocalAutofillTree.current += autofillNodePassword
var showCharsPassword by remember { mutableStateOf(false) }
OutlinedTextField(
modifier =
Modifier
.focusRequester(passwordFocusRequester)
.onGloballyPositioned { coordinates ->
autofillNodePassword.boundingBox = coordinates.boundsInWindow()
}.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNodePassword)
} else {
cancelAutofillForNode(autofillNodePassword)
}
}
},
value = value,
onValueChange = onValueChange,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
placeholder = {
Text(
text = stringRes(R.string.ncryptsec_password),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
Row {
IconButton(onClick = { showCharsPassword = !showCharsPassword }) {
Icon(
imageVector =
if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription =
if (showCharsPassword) {
stringRes(R.string.show_password)
} else {
stringRes(
R.string.hide_password,
)
},
)
}
}
},
visualTransformation =
if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardActions =
KeyboardActions(
onGo = {
onGo()
},
),
)
}
@@ -0,0 +1,157 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.login
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.tor.TorSettings
class LoginViewModel : ViewModel() {
lateinit var accountStateViewModel: AccountStateViewModel
val errorManager = LoginErrorManager()
var key by mutableStateOf(TextFieldValue(""))
var acceptedTerms by mutableStateOf(false)
var termsAcceptanceIsRequiredError by mutableStateOf(false)
var torSettings by mutableStateOf(TorSettings())
var offerTemporaryLogin by mutableStateOf(false)
var isTemporary by mutableStateOf(false)
var processingLogin by mutableStateOf(false)
var password by mutableStateOf(TextFieldValue(""))
val needsPassword by derivedStateOf {
key.text.startsWith("ncryptsec1")
}
var isFirstLogin by mutableStateOf(false)
fun init(accountStateViewModel: AccountStateViewModel) {
this.accountStateViewModel = accountStateViewModel
}
fun load(
isFirstLogin: Boolean,
newAccountKey: String?,
) {
this.isFirstLogin = isFirstLogin
acceptedTerms = isFirstLogin
if (newAccountKey != null) {
key = TextFieldValue(newAccountKey)
}
}
fun updateKey(
value: TextFieldValue,
throughQR: Boolean,
) {
key = value
if (throughQR) {
offerTemporaryLogin = true
isTemporary = true
}
errorManager.clearErrors()
}
fun updatePassword(newPassword: TextFieldValue) {
password = newPassword
errorManager.clearErrors()
}
fun updateTorSettings(newTorSettings: TorSettings) {
torSettings = newTorSettings
}
fun updateAcceptedTerms(newAcceptedTerms: Boolean) {
acceptedTerms = newAcceptedTerms
if (newAcceptedTerms) {
termsAcceptanceIsRequiredError = false
}
errorManager.clearErrors()
}
fun updateOfferTemporaryLogin(tempLogin: Boolean) {
offerTemporaryLogin = tempLogin
}
fun checkCanLogin(): Boolean {
if (!acceptedTerms) {
termsAcceptanceIsRequiredError = true
return false
}
if (key.text.isBlank()) {
errorManager.error(R.string.key_is_required)
return false
}
if (needsPassword && password.text.isBlank()) {
errorManager.error(R.string.password_is_required)
return false
}
return true
}
fun login() {
if (checkCanLogin()) {
processingLogin = true
accountStateViewModel.login(
key = key.text,
password = password.text,
torSettings = torSettings,
transientAccount = isTemporary,
) {
processingLogin = false
if (it != null) {
errorManager.error(R.string.invalid_key_with_message, it)
} else {
errorManager.error(R.string.invalid_key)
}
}
}
}
fun loginWithExternalSigner(packageName: String) {
if (checkCanLogin()) {
processingLogin = true
accountStateViewModel.login(
key = key.text,
torSettings = torSettings,
transientAccount = isTemporary,
loginWithExternalSigner = true,
packageName = packageName,
) {
processingLogin = false
errorManager.error(R.string.sign_request_rejected_description)
}
}
}
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.login
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size50dp
@Composable
fun LoginWithAmberButton(
enabled: Boolean,
onClick: () -> Unit,
) {
Button(
enabled = enabled,
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(Size50dp),
) {
Text(
text = stringRes(R.string.login_with_external_signer),
modifier = Modifier.padding(horizontal = Size40dp),
)
}
}
@@ -0,0 +1,116 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.login
import android.content.Intent
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.util.Consumer
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun OpenURIIfNotLoggedIn(onNewNIP19: suspend (String) -> Unit) {
val context = LocalContext.current
val activity = context.getActivity()
val scope = rememberCoroutineScope()
var currentIntentNextPage by remember {
val uri =
activity.intent
?.data
?.toString()
?.ifBlank { null }
activity.intent.data = null
mutableStateOf(uri)
}
currentIntentNextPage?.let { intentNextPage ->
var nip19 by remember {
mutableStateOf(
Nip19Parser.tryParseAndClean(currentIntentNextPage),
)
}
LaunchedEffect(intentNextPage) {
if (nip19 != null) {
nip19?.let {
scope.launch {
onNewNIP19(it)
}
nip19 = null
}
} else {
scope.launch {
Toast
.makeText(
context,
stringRes(context, R.string.invalid_nip19_uri_description, intentNextPage),
Toast.LENGTH_SHORT,
).show()
}
}
currentIntentNextPage = null
}
}
DisposableEffect(activity) {
val consumer =
Consumer<Intent> { intent ->
val uri = intent.data?.toString()
if (!uri.isNullOrBlank()) {
val newNip19 = Nip19Parser.tryParseAndClean(uri)
if (newNip19 != null) {
scope.launch {
onNewNIP19(newNip19)
}
} else {
scope.launch {
delay(1000)
Toast
.makeText(
context,
stringRes(context, R.string.invalid_nip19_uri_description, uri),
Toast.LENGTH_SHORT,
).show()
}
}
}
}
activity.addOnNewIntentListener(consumer)
onDispose { activity.removeOnNewIntentListener(consumer) }
}
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.login
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
@Composable
fun SignUpButton(onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(50.dp),
) {
Text(
text = stringRes(R.string.sign_up),
modifier = Modifier.padding(horizontal = Size40dp),
)
}
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
@Composable
fun LoginButton(onWantsToLogin: () -> Unit) {
OutlinedButton(
onClick = onWantsToLogin,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(50.dp),
) {
Text(
text = stringRes(R.string.login),
modifier = Modifier.padding(horizontal = Size40dp),
)
}
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
@Composable
fun SignUpButton(
enabled: Boolean,
onClick: () -> Unit,
) {
Button(
enabled = enabled,
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(50.dp),
) {
Text(
text = stringRes(R.string.create_account),
modifier = Modifier.padding(horizontal = Size40dp),
)
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff
package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup
import android.widget.Toast
import androidx.compose.foundation.Image
@@ -32,19 +32,14 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -53,7 +48,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -61,19 +55,20 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms
import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import kotlinx.coroutines.launch
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
fun SignUpPage() {
fun SignUpPagePreview() {
val accountViewModel: AccountStateViewModel = viewModel()
ThemeComparisonRow(
@@ -88,13 +83,18 @@ fun SignUpPage(
accountStateViewModel: AccountStateViewModel,
onWantsToLogin: () -> Unit,
) {
val displayName = remember { mutableStateOf(TextFieldValue("")) }
var errorMessage by remember { mutableStateOf("") }
val acceptedTerms = remember { mutableStateOf(false) }
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
val signUpViewModel: SignUpViewModel = viewModel()
signUpViewModel.init(accountStateViewModel)
SignUpPage(signUpViewModel, onWantsToLogin)
}
@Composable
fun SignUpPage(
signUpViewModel: SignUpViewModel,
onWantsToLogin: () -> Unit,
) {
val context = LocalContext.current
val torSettings = remember { mutableStateOf(TorSettings()) }
val scope = rememberCoroutineScope()
Column(
@@ -125,8 +125,8 @@ fun SignUpPage(
Spacer(modifier = Modifier.height(Size20dp))
OutlinedTextField(
value = displayName.value,
onValueChange = { displayName.value = it },
value = signUpViewModel.displayName,
onValueChange = signUpViewModel::updateDisplayName,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
@@ -142,49 +142,47 @@ fun SignUpPage(
keyboardActions =
KeyboardActions(
onGo = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired =
stringRes(context, R.string.acceptance_of_terms_is_required)
}
if (displayName.value.text.isBlank()) {
errorMessage = stringRes(context, R.string.name_is_required)
}
if (acceptedTerms.value && displayName.value.text.isNotBlank()) {
accountStateViewModel.newKey(torSettings.value, displayName.value.text)
}
signUpViewModel.signup()
},
),
)
if (errorMessage.isNotBlank()) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
signUpViewModel.errorManager.error?.let { error ->
when (error) {
is LoginErrorManager.SingleErrorMsg ->
Text(
text = stringRes(error.errorResId),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
is LoginErrorManager.ParamsErrorMsg ->
Text(
text = stringRes(error.errorResId, *error.params),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
else -> {}
}
}
Spacer(modifier = Modifier.height(10.dp))
AcceptTerms(
checked = acceptedTerms.value,
onCheckedChange = { acceptedTerms.value = it },
checked = signUpViewModel.acceptedTerms,
onCheckedChange = signUpViewModel::updateAcceptedTerms,
)
if (termsAcceptanceIsRequired.isNotBlank()) {
if (signUpViewModel.termsAcceptanceIsRequiredError) {
Text(
text = termsAcceptanceIsRequired,
text = stringRes(R.string.acceptance_of_terms_is_required),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
TorSettingsSetup(
torSettings = torSettings.value,
onCheckedChange = {
torSettings.value = it
},
torSettings = signUpViewModel.torSettings,
onCheckedChange = signUpViewModel::updateTorSettings,
onError = {
scope.launch {
Toast
@@ -201,20 +199,8 @@ fun SignUpPage(
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
SignUpButton(
enabled = acceptedTerms.value,
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = stringRes(context, R.string.acceptance_of_terms_is_required)
}
if (displayName.value.text.isBlank()) {
errorMessage = stringRes(context, R.string.name_is_required)
}
if (acceptedTerms.value && displayName.value.text.isNotBlank()) {
accountStateViewModel.newKey(torSettings.value, displayName.value.text)
}
},
enabled = signUpViewModel.acceptedTerms,
onClick = signUpViewModel::signup,
)
}
@@ -229,35 +215,3 @@ fun SignUpPage(
}
}
}
@Composable
fun LoginButton(onWantsToLogin: () -> Unit) {
OutlinedButton(
onClick = onWantsToLogin,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(50.dp),
) {
Text(
text = stringRes(R.string.login),
modifier = Modifier.padding(horizontal = Size40dp),
)
}
}
@Composable
fun SignUpButton(
enabled: Boolean,
onClick: () -> Unit,
) {
Button(
enabled = enabled,
onClick = onClick,
shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(50.dp),
) {
Text(
text = stringRes(R.string.create_account),
modifier = Modifier.padding(horizontal = Size40dp),
)
}
}
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
import com.vitorpamplona.amethyst.ui.tor.TorSettings
class SignUpViewModel : ViewModel() {
lateinit var accountStateViewModel: AccountStateViewModel
val errorManager = LoginErrorManager()
var displayName by mutableStateOf(TextFieldValue(""))
var acceptedTerms by mutableStateOf(false)
var termsAcceptanceIsRequiredError by mutableStateOf(false)
var torSettings by mutableStateOf(TorSettings())
fun init(accountStateViewModel: AccountStateViewModel) {
this.accountStateViewModel = accountStateViewModel
}
fun updateDisplayName(value: TextFieldValue) {
displayName = value
errorManager.clearErrors()
}
fun updateTorSettings(newTorSettings: TorSettings) {
torSettings = newTorSettings
}
fun updateAcceptedTerms(newAcceptedTerms: Boolean) {
acceptedTerms = newAcceptedTerms
if (newAcceptedTerms) {
termsAcceptanceIsRequiredError = false
}
errorManager.clearErrors()
}
fun checkCanSignup(): Boolean {
if (!acceptedTerms) {
termsAcceptanceIsRequiredError = true
return false
}
if (displayName.text.isBlank()) {
errorManager.error(R.string.name_is_required)
return false
}
return true
}
fun signup() {
if (checkCanSignup()) {
accountStateViewModel.newKey(torSettings, displayName.text)
}
}
}
+1
View File
@@ -836,6 +836,7 @@
<string name="wallet_number">Wallet %1$s</string>
<string name="error_opening_external_signer">Error opening signer app</string>
<string name="error_opening_external_signer_description">The signer app could not be found. Check if the app hasn\'t been uninstalled</string>
<string name="sign_request_rejected2">Sign request rejected</string>
<string name="sign_request_rejected">Signer Application Rejected</string>
<string name="sign_request_rejected_description">Make sure the signer application has authorized this transaction</string>
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.core
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.jackson.EventManualSerializer
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
@@ -58,6 +59,14 @@ open class Event(
companion object {
fun fromJson(json: String): Event = JsonMapper.fromJson(json)
fun fromJsonOrNull(json: String) =
try {
fromJson(json)
} catch (e: Exception) {
Log.e("Event", "Unable to parse event JSON: $json", e)
null
}
fun build(
kind: Int,
content: String = "",
@@ -35,9 +35,13 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.RequestDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.ResponseDeserializer
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionDeserializer
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionSerializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorSerializer
import kotlin.jvm.java
class JsonMapper {
companion object Companion {
@@ -60,7 +64,9 @@ class JsonMapper {
.addSerializer(BunkerRequest::class.java, BunkerRequest.BunkerRequestSerializer())
.addDeserializer(BunkerRequest::class.java, BunkerRequest.BunkerRequestDeserializer())
.addSerializer(BunkerResponse::class.java, BunkerResponse.BunkerResponseSerializer())
.addDeserializer(BunkerResponse::class.java, BunkerResponse.BunkerResponseDeserializer()),
.addDeserializer(BunkerResponse::class.java, BunkerResponse.BunkerResponseDeserializer())
.addDeserializer(Permission::class.java, PermissionDeserializer())
.addSerializer(Permission::class.java, PermissionSerializer()),
)
fun fromJson(json: String): Event = mapper.readValue(json, Event::class.java)
@@ -47,25 +47,25 @@ abstract class NostrSigner(
)
abstract fun nip04Encrypt(
decryptedContent: String,
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
)
abstract fun nip04Decrypt(
encryptedContent: String,
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
)
abstract fun nip44Encrypt(
decryptedContent: String,
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
)
abstract fun nip44Decrypt(
encryptedContent: String,
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
)
@@ -45,35 +45,35 @@ class NostrSignerInternal(
}
override fun nip04Encrypt(
decryptedContent: String,
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
) {
signerSync.nip04Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) }
signerSync.nip04Encrypt(plaintext, toPublicKey)?.let { onReady(it) }
}
override fun nip04Decrypt(
encryptedContent: String,
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
) {
signerSync.nip04Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) }
signerSync.nip04Decrypt(ciphertext, fromPublicKey)?.let { onReady(it) }
}
override fun nip44Encrypt(
decryptedContent: String,
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
) {
signerSync.nip44Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) }
signerSync.nip44Encrypt(plaintext, toPublicKey)?.let { onReady(it) }
}
override fun nip44Decrypt(
encryptedContent: String,
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
) {
signerSync.nip44Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) }
signerSync.nip44Decrypt(ciphertext, fromPublicKey)?.let { onReady(it) }
}
override fun decryptZapEvent(
@@ -1,438 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner
import android.content.ContentResolver
import android.content.Intent
import android.util.Log
import android.util.LruCache
import androidx.core.net.toUri
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
enum class SignerType {
SIGN_EVENT,
NIP04_ENCRYPT,
NIP04_DECRYPT,
NIP44_ENCRYPT,
NIP44_DECRYPT,
GET_PUBLIC_KEY,
DECRYPT_ZAP_EVENT,
DERIVE_KEY,
}
class Permission(
val type: String,
val kind: Int? = null,
) {
fun toJson(): String = "{\"type\":\"${type}\",\"kind\":$kind}"
}
class Result(
@JsonProperty("package") val `package`: String?,
@JsonProperty("signature") val signature: String?,
@JsonProperty("result") val result: String?,
@JsonProperty("id") val id: String?,
) {
companion object {
val mapper: ObjectMapper =
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(
SimpleModule().addDeserializer(Result::class.java, ResultDeserializer()),
)
private class ResultDeserializer : StdDeserializer<Result>(Result::class.java) {
override fun deserialize(
jp: JsonParser,
ctxt: DeserializationContext,
): Result {
val jsonObject: JsonNode = jp.codec.readTree(jp)
return Result(
jsonObject.get("package").asText().intern(),
jsonObject.get("signature")?.asText()?.intern(),
jsonObject.get("result")?.asText()?.intern(),
jsonObject.get("id").asText().intern(),
)
}
}
fun fromJson(json: String): Result = mapper.readValue(json, Result::class.java)
/**
* Parses the json with a string of events to an Array of Event objects.
*/
fun fromJsonArray(json: String): Array<Result> = mapper.readValue(json)
}
}
class ExternalSignerLauncher(
private val currentUserPubKeyHex: String,
val signerPackageName: String,
) {
private val contentCache = LruCache<String, (String) -> Unit>(50)
private var signerAppLauncher: ((Intent) -> Unit)? = null
private var contentResolver: (() -> ContentResolver)? = null
/** Call this function when the launcher becomes available on activity, fragment or compose */
fun registerLauncher(
launcher: ((Intent) -> Unit),
contentResolver: (() -> ContentResolver),
) {
this.signerAppLauncher = launcher
this.contentResolver = contentResolver
}
/** Call this function when the activity is destroyed or is about to be replaced. */
fun clearLauncherIf(launcher: ((Intent) -> Unit)) {
if (signerAppLauncher == launcher) {
this.signerAppLauncher = null
this.contentResolver = null
}
}
fun newResult(data: Intent) {
val results = data.getStringExtra("results")
if (results != null) {
val localResults: Array<Result> = Result.fromJsonArray(results)
localResults.forEach {
val signature = it.result ?: it.signature ?: ""
val packageName = it.`package`?.let { "-$it" } ?: ""
val id = it.id ?: ""
if (id.isNotBlank()) {
val result = if (packageName.isNotBlank()) "$signature$packageName" else signature
val contentCache = contentCache.get(id)
contentCache?.invoke(result)
}
}
} else {
val signature = data.getStringExtra("result") ?: data.getStringExtra("signature") ?: ""
val packageName = data.getStringExtra("package")?.let { "-$it" } ?: ""
val id = data.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
val result = if (packageName.isNotBlank()) "$signature$packageName" else signature
val contentCache = contentCache.get(id)
contentCache?.invoke(result)
}
}
}
fun openSignerApp(
data: String,
type: SignerType,
pubKey: HexKey,
id: String,
onReady: (String) -> Unit,
) {
signerAppLauncher?.let {
openSignerApp(
data,
type,
it,
pubKey,
id,
onReady,
)
}
}
private fun defaultPermissions(): String {
val permissions =
listOf(
Permission(
"sign_event",
22242,
),
Permission(
"sign_event",
31234,
),
Permission(
"nip04_encrypt",
),
Permission(
"nip04_decrypt",
),
Permission(
"nip44_encrypt",
),
Permission(
"nip44_decrypt",
),
Permission(
"decrypt_zap_event",
),
)
val jsonArray = StringBuilder("[")
permissions.forEachIndexed { index, permission ->
jsonArray.append(permission.toJson())
if (index < permissions.size - 1) {
jsonArray.append(",")
}
}
jsonArray.append("]")
return jsonArray.toString()
}
private fun openSignerApp(
data: String,
type: SignerType,
intentLauncher: (Intent) -> Unit,
pubKey: HexKey,
id: String,
onReady: (String) -> Unit,
) {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$data".toUri())
val signerType =
when (type) {
SignerType.SIGN_EVENT -> "sign_event"
SignerType.NIP04_ENCRYPT -> "nip04_encrypt"
SignerType.NIP04_DECRYPT -> "nip04_decrypt"
SignerType.NIP44_ENCRYPT -> "nip44_encrypt"
SignerType.NIP44_DECRYPT -> "nip44_decrypt"
SignerType.GET_PUBLIC_KEY -> "get_public_key"
SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event"
SignerType.DERIVE_KEY -> "derive_key"
}
intent.putExtra("type", signerType)
intent.putExtra("pubKey", pubKey)
intent.putExtra("id", id)
if (type !== SignerType.GET_PUBLIC_KEY) {
intent.putExtra("current_user", currentUserPubKeyHex)
} else {
intent.putExtra("permissions", defaultPermissions())
}
if (signerPackageName.isNotBlank()) {
intent.`package` = signerPackageName
}
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
contentCache.put(id, onReady)
intentLauncher(intent)
}
fun openSigner(
event: Event,
onReady: (String) -> Unit,
) {
getDataFromResolver(
SignerType.SIGN_EVENT,
arrayOf(event.toJson(), event.pubKey),
).fold(
onFailure = { },
onSuccess = {
if (it == null) {
openSignerApp(
event.toJson(),
SignerType.SIGN_EVENT,
"",
event.id,
onReady,
)
} else {
onReady(it)
}
},
)
}
private fun getDataFromResolver(
signerType: SignerType,
data: Array<out String>,
): kotlin.Result<String?> = getDataFromResolver(signerType, data, contentResolver)
private fun getDataFromResolver(
signerType: SignerType,
data: Array<out String>,
contentResolver: (() -> ContentResolver)? = null,
): kotlin.Result<String?> {
val localData =
if (signerType !== SignerType.GET_PUBLIC_KEY) {
arrayOf(*data, currentUserPubKeyHex)
} else {
data
}
try {
contentResolver
?.let { it() }
?.query(
"content://$signerPackageName.$signerType".toUri(),
localData,
"1",
null,
null,
).use {
if (it == null) {
return kotlin.Result.success(null)
}
if (it.moveToFirst()) {
if (it.getColumnIndex("rejected") > -1) {
Log.d("getDataFromResolver", "Permission denied")
return kotlin.Result.failure(Exception("Permission denied"))
}
var index = it.getColumnIndex("result")
if (index < 0) {
index = it.getColumnIndex("signature")
}
if (index < 0) {
Log.d("getDataFromResolver", "column 'signature' not found")
return kotlin.Result.success(null)
}
return kotlin.Result.success(it.getString(index))
}
}
} catch (e: Exception) {
Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background", e)
return kotlin.Result.success(null)
}
return kotlin.Result.success(null)
}
fun hashCodeFields(
str1: String,
onReady: (String) -> Unit,
): Int {
var result = str1.hashCode()
result = 31 * result + onReady.hashCode()
return result
}
fun hashCodeFields(
str1: String,
str2: String,
onReady: (String) -> Unit,
): Int {
var result = str1.hashCode()
result = 31 * result + str2.hashCode()
result = 31 * result + onReady.hashCode()
return result
}
fun decrypt(
encryptedContent: String,
pubKey: HexKey,
signerType: SignerType = SignerType.NIP04_DECRYPT,
onReady: (String) -> Unit,
) {
getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey)).fold(
onFailure = { },
onSuccess = {
if (it == null) {
openSignerApp(
encryptedContent,
signerType,
pubKey,
hashCodeFields(encryptedContent, pubKey, onReady).toString(),
onReady,
)
} else {
onReady(it)
}
},
)
}
fun encrypt(
decryptedContent: String,
pubKey: HexKey,
signerType: SignerType = SignerType.NIP04_ENCRYPT,
onReady: (String) -> Unit,
) {
getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey)).fold(
onFailure = { },
onSuccess = {
if (it == null) {
openSignerApp(
decryptedContent,
signerType,
pubKey,
hashCodeFields(decryptedContent, pubKey, onReady).toString(),
onReady,
)
} else {
onReady(it)
}
},
)
}
fun decryptZapEvent(
event: LnZapRequestEvent,
onReady: (String) -> Unit,
) {
getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey)).fold(
onFailure = { },
onSuccess = {
if (it == null) {
openSignerApp(
event.toJson(),
SignerType.DECRYPT_ZAP_EVENT,
event.pubKey,
event.id,
onReady,
)
} else {
onReady(it)
}
},
)
}
fun deriveKey(
nonce: HexKey,
signerType: SignerType = SignerType.DERIVE_KEY,
onReady: (String) -> Unit,
) {
getDataFromResolver(signerType, arrayOf(nonce)).fold(
onFailure = { },
onSuccess = {
if (it == null) {
openSignerApp(
nonce,
signerType,
"",
hashCodeFields(nonce, onReady).toString(),
onReady,
)
} else {
onReady(it)
}
},
)
}
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api
enum class CommandType(
val code: String,
) {
SIGN_EVENT("sign_event"),
NIP04_ENCRYPT("nip04_encrypt"),
NIP04_DECRYPT("nip04_decrypt"),
NIP44_ENCRYPT("nip44_encrypt"),
NIP44_DECRYPT("nip44_decrypt"),
GET_PUBLIC_KEY("get_public_key"),
DECRYPT_ZAP_EVENT("decrypt_zap_event"),
DERIVE_KEY("derive_key"),
;
companion object Companion {
fun parse(code: String): CommandType? =
when (code) {
SIGN_EVENT.code -> SIGN_EVENT
NIP04_ENCRYPT.code -> NIP04_ENCRYPT
NIP04_DECRYPT.code -> NIP04_DECRYPT
NIP44_ENCRYPT.code -> NIP44_ENCRYPT
NIP44_DECRYPT.code -> NIP44_DECRYPT
GET_PUBLIC_KEY.code -> GET_PUBLIC_KEY
DECRYPT_ZAP_EVENT.code -> DECRYPT_ZAP_EVENT
DERIVE_KEY.code -> DERIVE_KEY
else -> null
}
}
}
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
interface RequestAddressed
sealed class SignerResult<T : IResult> {
class Successful<T : IResult>(
val result: T,
) : SignerResult<T>(),
RequestAddressed
class Rejected<T : IResult> :
SignerResult<T>(),
RequestAddressed
class ReceivedButCouldNotPerform<T : IResult>(
message: String? = null,
) : SignerResult<T>(),
RequestAddressed
class ReceivedButCouldNotParseEventFromResult<T : IResult>(
val eventJson: String,
) : SignerResult<T>(),
RequestAddressed
class ReceivedButCouldNotVerifyResultingEvent<T : IResult>(
val invalidEvent: Event,
) : SignerResult<T>(),
RequestAddressed
class ErrorExceptionCallingContentResolver<T : IResult>(
val e: Exception? = null,
) : SignerResult<T>()
class RequiresManualApproval<T : IResult> : SignerResult<T>()
}
interface IResult
data class PubKeyResult(
val pubkey: HexKey,
val packageName: String,
) : IResult
data class SignResult(
val event: Event,
) : IResult
data class EncryptionResult(
val ciphertext: String,
) : IResult
data class DecryptionResult(
val plaintext: String,
) : IResult
data class ZapEventDecryptionResult(
val privateEvent: LnZapPrivateEvent,
) : IResult
data class DerivationResult(
val newPrivKey: HexKey,
) : IResult
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries
import android.content.ContentResolver
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.verify
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import kotlin.text.isNullOrBlank
class DecryptZapQuery(
val loggedInUser: HexKey,
val packageName: String,
val contentResolver: ContentResolver,
) {
fun query(event: Event): SignerResult<ZapEventDecryptionResult> =
contentResolver.query(
"content://$packageName.${CommandType.DECRYPT_ZAP_EVENT}".toUri(),
arrayOf(event.toJson(), event.pubKey, loggedInUser),
) { cursor ->
val decryptedEventAsJson = cursor.getStringByName("result")
if (!decryptedEventAsJson.isNullOrBlank()) {
if (decryptedEventAsJson.startsWith("{")) {
val event = Event.fromJsonOrNull(decryptedEventAsJson) as? LnZapPrivateEvent
if (event != null) {
if (event.verify()) {
SignerResult.Successful(ZapEventDecryptionResult(event))
} else {
SignerResult.ReceivedButCouldNotVerifyResultingEvent(event)
}
} else {
SignerResult.ReceivedButCouldNotParseEventFromResult(decryptedEventAsJson)
}
} else {
SignerResult.ReceivedButCouldNotPerform(decryptedEventAsJson)
}
} else {
SignerResult.ReceivedButCouldNotPerform(decryptedEventAsJson)
}
}
}
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries
import android.content.ContentResolver
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query
class DeriveKeyQuery(
val loggedInUser: HexKey,
val packageName: String,
val contentResolver: ContentResolver,
) {
val uri = "content://$packageName.${CommandType.DERIVE_KEY}".toUri()
fun query(nonce: HexKey): SignerResult<DerivationResult> =
contentResolver.query(
uri,
arrayOf(nonce, loggedInUser),
) { cursor ->
val newPrivateKey = cursor.getStringByName("result")
if (!newPrivateKey.isNullOrBlank()) {
SignerResult.Successful(DerivationResult(newPrivateKey))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries
import android.content.ContentResolver
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query
class LoginQuery(
val packageName: String,
val contentResolver: ContentResolver,
) {
companion object {
val LOGIN = arrayOf("login")
}
val uri = "content://$packageName.${CommandType.GET_PUBLIC_KEY}".toUri()
fun query(): SignerResult<PubKeyResult> =
contentResolver.query(
uri,
LOGIN,
) { cursor ->
val pubkeyHex = cursor.getStringByName("result")
if (!pubkeyHex.isNullOrBlank()) {
SignerResult.Successful(PubKeyResult(pubkeyHex, packageName))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries
import android.content.ContentResolver
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query
class Nip04DecryptQuery(
val loggedInUser: HexKey,
val packageName: String,
val contentResolver: ContentResolver,
) {
val uri = "content://$packageName.${CommandType.NIP04_DECRYPT}".toUri()
fun query(
ciphertext: String,
fromPubKey: HexKey,
): SignerResult<DecryptionResult> =
contentResolver.query(
uri,
arrayOf(ciphertext, fromPubKey, loggedInUser),
) { cursor ->
val plaintext = cursor.getStringByName("result")
if (!plaintext.isNullOrBlank()) {
SignerResult.Successful(DecryptionResult(plaintext))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries
import android.content.ContentResolver
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query
class Nip04EncryptQuery(
val loggedInUser: HexKey,
val packageName: String,
val contentResolver: ContentResolver,
) {
val uri = "content://$packageName.${CommandType.NIP04_ENCRYPT}".toUri()
fun query(
plaintext: String,
toPubKey: HexKey,
): SignerResult<EncryptionResult> =
contentResolver.query(
uri,
arrayOf(plaintext, toPubKey, loggedInUser),
) { cursor ->
val ciphertext = cursor.getStringByName("result")
if (!ciphertext.isNullOrBlank()) {
SignerResult.Successful(EncryptionResult(ciphertext))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries
import android.content.ContentResolver
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query
class Nip44DecryptQuery(
val loggedInUser: HexKey,
val packageName: String,
val contentResolver: ContentResolver,
) {
val uri = "content://$packageName.${CommandType.NIP44_DECRYPT}".toUri()
fun query(
ciphertext: String,
fromPubKey: HexKey,
): SignerResult<DecryptionResult> =
contentResolver.query(
uri,
arrayOf(ciphertext, fromPubKey, loggedInUser),
) { cursor ->
val plaintext = cursor.getStringByName("result")
if (!plaintext.isNullOrBlank()) {
SignerResult.Successful(DecryptionResult(plaintext))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries
import android.content.ContentResolver
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query
class Nip44EncryptQuery(
val loggedInUser: HexKey,
val packageName: String,
val contentResolver: ContentResolver,
) {
val uri = "content://$packageName.${CommandType.NIP44_ENCRYPT}".toUri()
fun query(
plaintext: String,
toPubKey: HexKey,
): SignerResult<EncryptionResult> =
contentResolver.query(
uri,
arrayOf(plaintext, toPubKey, loggedInUser),
) { cursor ->
val ciphertext = cursor.getStringByName("result")
if (!ciphertext.isNullOrBlank()) {
SignerResult.Successful(EncryptionResult(ciphertext))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries
import android.content.ContentResolver
import androidx.core.net.toUri
import com.vitorpamplona.quartz.EventFactory
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.verify
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query
class SignQuery(
val loggedInUser: HexKey,
val packageName: String,
val contentResolver: ContentResolver,
) {
val uri = "content://$packageName.${CommandType.SIGN_EVENT}".toUri()
fun query(unsignedEvent: Event): SignerResult<SignResult> =
contentResolver.query(
uri,
arrayOf(unsignedEvent.toJson(), unsignedEvent.pubKey, loggedInUser),
) { cursor ->
val eventJson = cursor.getStringByName("event")
if (!eventJson.isNullOrBlank()) {
if (eventJson.startsWith("{")) {
val event = Event.fromJsonOrNull(eventJson)
if (event != null) {
if (event.verify()) {
SignerResult.Successful(SignResult(event))
} else {
SignerResult.ReceivedButCouldNotVerifyResultingEvent(event)
}
} else {
SignerResult.ReceivedButCouldNotParseEventFromResult(eventJson)
}
} else {
SignerResult.ReceivedButCouldNotParseEventFromResult(eventJson)
}
} else {
val signature = cursor.getStringByName("result")
if (!signature.isNullOrBlank()) {
val event =
EventFactory.create(
id = unsignedEvent.id,
pubKey = unsignedEvent.pubKey,
createdAt = unsignedEvent.createdAt,
kind = unsignedEvent.kind,
tags = unsignedEvent.tags,
content = unsignedEvent.content,
sig = signature,
)
if (event.verify()) {
SignerResult.Successful(SignResult(event))
} else {
SignerResult.ReceivedButCouldNotVerifyResultingEvent(event)
}
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils
import android.content.ContentResolver
import android.database.Cursor
import android.net.Uri
import android.util.Log
import com.vitorpamplona.quartz.nip55AndroidSigner.api.IResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
fun <T : IResult> ContentResolver.query(
uri: Uri,
projection: Array<out String>,
map: (cursor: Cursor) -> SignerResult<T>,
): SignerResult<T> =
try {
query(
uri,
projection,
null,
null,
null,
).use {
if (it != null && it.moveToFirst()) {
if (it.getColumnIndex("rejected") > -1) {
SignerResult.Rejected()
} else {
map(it)
}
} else {
SignerResult.RequiresManualApproval()
}
}
} catch (e: Exception) {
Log.e("ExternalSignerLauncher", "Failed to query the Signer app in the background", e)
SignerResult.ErrorExceptionCallingContentResolver(e)
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils
import android.database.Cursor
fun Cursor.getStringByName(name: String): String? {
val index = getColumnIndex(name)
return if (index >= 0) {
val result = getString(index)
result.ifBlank { null }
} else {
null
}
}
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground
import android.content.Intent
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.utils.RandomInstance
class IntentRequestDatabase {
private val awaitingRequests = LruCache<String, NewResultProcessor>(2000)
private var appLauncher: ((Intent) -> Unit)? = null
/** Call this function when the launcher becomes available on activity, fragment or compose */
fun registerForegroundLauncher(launcher: ((Intent) -> Unit)) {
this.appLauncher = launcher
}
/** Call this function when the activity is destroyed or is about to be replaced. */
fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit)) {
if (this.appLauncher == launcher) {
this.appLauncher = null
}
}
fun newResponse(data: Intent) {
val callId = data.getStringExtra("id")
if (callId != null) {
awaitingRequests[callId]?.process(data)
awaitingRequests.remove(callId)
}
}
fun launch(
requestIntent: Intent,
responseProcessor: NewResultProcessor,
) {
appLauncher?.let {
val callId = RandomInstance.randomChars(32)
awaitingRequests.put(callId, responseProcessor)
requestIntent.putExtra("id", callId)
requestIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
it.invoke(requestIntent)
}
}
}
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground
import android.content.Intent
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
interface NewResultProcessor {
fun process(intent: Intent)
}
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
class DecryptZapRequest {
companion object {
fun assemble(
event: LnZapRequestEvent,
loggedInUser: HexKey,
packageName: String,
): Intent {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:${event.toJson()}".toUri())
intent.`package` = packageName
intent.putExtra("type", CommandType.NIP44_DECRYPT.code)
intent.putExtra("current_user", loggedInUser)
return intent
}
}
}
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
class DeriveKeyRequest {
companion object {
fun assemble(
nonce: HexKey,
loggedInUser: HexKey,
packageName: String,
): Intent {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$nonce".toUri())
intent.`package` = packageName
intent.putExtra("type", CommandType.DERIVE_KEY.code)
intent.putExtra("current_user", loggedInUser)
return intent
}
}
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
class LoginRequest {
companion object {
val DefaultPermissions =
listOf(
Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND),
Permission(CommandType.NIP04_ENCRYPT),
Permission(CommandType.NIP04_DECRYPT),
Permission(CommandType.NIP44_DECRYPT),
Permission(CommandType.NIP44_DECRYPT),
Permission(CommandType.DECRYPT_ZAP_EVENT),
)
fun assemble(permissions: List<Permission> = DefaultPermissions): Intent {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri())
intent.putExtra("type", CommandType.GET_PUBLIC_KEY.code)
intent.putExtra("permissions", JsonMapper.mapper.writeValueAsString(permissions))
return intent
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
class Nip04DecryptRequest {
companion object {
fun assemble(
ciphertext: String,
fromPubKey: HexKey,
loggedInUser: HexKey,
packageName: String,
): Intent {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$ciphertext".toUri())
intent.`package` = packageName
intent.putExtra("type", CommandType.NIP04_DECRYPT.code)
intent.putExtra("pubKey", fromPubKey)
intent.putExtra("current_user", loggedInUser)
return intent
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
class Nip04EncryptRequest {
companion object {
fun assemble(
plaintext: String,
toPubKey: HexKey,
loggedInUser: HexKey,
packageName: String,
): Intent {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$plaintext".toUri())
intent.`package` = packageName
intent.putExtra("type", CommandType.NIP04_ENCRYPT.code)
intent.putExtra("pubKey", toPubKey)
intent.putExtra("current_user", loggedInUser)
return intent
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
class Nip44DecryptRequest {
companion object {
fun assemble(
ciphertext: String,
fromPubKey: HexKey,
loggedInUser: HexKey,
packageName: String,
): Intent {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$ciphertext".toUri())
intent.`package` = packageName
intent.putExtra("type", CommandType.NIP44_DECRYPT.code)
intent.putExtra("pubKey", fromPubKey)
intent.putExtra("current_user", loggedInUser)
return intent
}
}
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
class Nip44EncryptRequest {
companion object {
fun assemble(
plaintext: String,
toPubKey: HexKey,
loggedInUser: HexKey,
packageName: String,
): Intent {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$plaintext".toUri())
intent.`package` = packageName
intent.putExtra("type", CommandType.NIP44_ENCRYPT.code)
intent.putExtra("pubKey", toPubKey)
intent.putExtra("current_user", loggedInUser)
return intent
}
}
}
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
class SignRequest {
companion object {
fun assemble(
event: Event,
loggedInUser: HexKey,
packageName: String,
): Intent {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:${event.toJson()}".toUri())
intent.`package` = packageName
intent.putExtra("type", CommandType.SIGN_EVENT.code)
intent.putExtra("current_user", loggedInUser)
return intent
}
}
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses
import android.content.Intent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
class DecryptZapResponse {
companion object {
fun assemble(event: LnZapPrivateEvent): Intent {
val intent = Intent()
intent.putExtra("result", event.toJson())
return intent
}
fun parse(intent: Intent): SignerResult<ZapEventDecryptionResult> {
val eventJson = intent.getStringExtra("result")
return if (!eventJson.isNullOrBlank()) {
if (eventJson.startsWith("{")) {
val event = Event.fromJsonOrNull(eventJson) as? LnZapPrivateEvent
if (event != null) {
SignerResult.Successful(ZapEventDecryptionResult(event))
} else {
SignerResult.ReceivedButCouldNotParseEventFromResult(eventJson)
}
} else {
SignerResult.ReceivedButCouldNotPerform()
}
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
class DeriveKeyResponse {
companion object {
fun parse(intent: Intent): SignerResult<DerivationResult> {
val newPrivateKey = intent.getStringExtra("result")
return if (newPrivateKey != null) {
SignerResult.Successful(DerivationResult(newPrivateKey))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
class LoginResponse {
companion object {
fun parse(intent: Intent): SignerResult<PubKeyResult> {
val pubkey = intent.getStringExtra("result")
val packageName = intent.getStringExtra("package")
return if (pubkey != null && packageName != null) {
SignerResult.Successful(
PubKeyResult(
pubkey = pubkey,
packageName = packageName,
),
)
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
class Nip04DecryptResponse {
companion object {
fun parse(intent: Intent): SignerResult<DecryptionResult> {
val ciphertext = intent.getStringExtra("result")
return if (ciphertext != null) {
SignerResult.Successful(DecryptionResult(ciphertext))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
class Nip04EncryptResponse {
companion object {
fun parse(intent: Intent): SignerResult<EncryptionResult> {
val ciphertext = intent.getStringExtra("result")
return if (ciphertext != null) {
SignerResult.Successful(EncryptionResult(ciphertext))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
class Nip44DecryptResponse {
companion object {
fun parse(intent: Intent): SignerResult<DecryptionResult> {
val ciphertext = intent.getStringExtra("result")
return if (ciphertext != null) {
SignerResult.Successful(DecryptionResult(ciphertext))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
class Nip44EncryptResponse {
companion object {
fun parse(intent: Intent): SignerResult<EncryptionResult> {
val ciphertext = intent.getStringExtra("result")
return if (ciphertext != null) {
SignerResult.Successful(EncryptionResult(ciphertext))
} else {
SignerResult.ReceivedButCouldNotPerform()
}
}
}
}
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses
import android.content.Intent
import com.vitorpamplona.quartz.EventFactory
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.verify
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
class SignResponse {
companion object {
fun parse(
intent: Intent,
unsignedEvent: Event,
): SignerResult<SignResult> {
val eventJson = intent.getStringExtra("event")
return if (eventJson != null) {
if (eventJson.startsWith("{")) {
val event = Event.fromJsonOrNull(eventJson)
if (event != null) {
if (event.verify()) {
SignerResult.Successful(SignResult(event))
} else {
SignerResult.ReceivedButCouldNotVerifyResultingEvent(event)
}
} else {
SignerResult.ReceivedButCouldNotParseEventFromResult(eventJson)
}
} else {
SignerResult.ReceivedButCouldNotPerform(eventJson)
}
} else {
val signature = intent.getStringExtra("result")
if (signature != null && signature.length == 128) {
val event =
EventFactory.create(
id = unsignedEvent.id,
pubKey = unsignedEvent.pubKey,
createdAt = unsignedEvent.createdAt,
kind = unsignedEvent.kind,
tags = unsignedEvent.tags,
content = unsignedEvent.content,
sig = signature,
)
if (event.verify()) {
SignerResult.Successful(SignResult(event))
} else {
SignerResult.ReceivedButCouldNotVerifyResultingEvent(event)
}
} else {
SignerResult.ReceivedButCouldNotPerform(signature)
}
}
}
}
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DecryptZapResponse
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
class DecryptZapResultProcessor(
val onReady: (LnZapPrivateEvent) -> Unit,
) : NewResultProcessor {
override fun process(intent: Intent) {
val foregroundResult = DecryptZapResponse.parse(intent)
if (foregroundResult is SignerResult.Successful<ZapEventDecryptionResult>) {
onReady(foregroundResult.result.privateEvent)
}
}
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DeriveKeyResponse
class DeriveKeyResultProcessor(
val onReady: (String) -> Unit,
) : NewResultProcessor {
override fun process(intent: Intent) {
val foregroundResult = DeriveKeyResponse.parse(intent)
if (foregroundResult is SignerResult.Successful<DerivationResult>) {
onReady(foregroundResult.result.newPrivKey)
}
}
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors
import android.content.Intent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.LoginResponse
class LoginResultProcessor(
val onReady: (HexKey, String) -> Unit,
) : NewResultProcessor {
override fun process(intent: Intent) {
val foregroundResult = LoginResponse.parse(intent)
if (foregroundResult is SignerResult.Successful<PubKeyResult>) {
onReady(foregroundResult.result.pubkey, foregroundResult.result.packageName)
}
}
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04DecryptResponse
class Nip04DecryptResultProcessor(
val onReady: (String) -> Unit,
) : NewResultProcessor {
override fun process(intent: Intent) {
val foregroundResult = Nip04DecryptResponse.parse(intent)
if (foregroundResult is SignerResult.Successful<DecryptionResult>) {
onReady(foregroundResult.result.plaintext)
}
}
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04EncryptResponse
class Nip04EncryptResultProcessor(
val onReady: (String) -> Unit,
) : NewResultProcessor {
override fun process(intent: Intent) {
val foregroundResult = Nip04EncryptResponse.parse(intent)
if (foregroundResult is SignerResult.Successful<EncryptionResult>) {
onReady(foregroundResult.result.ciphertext)
}
}
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44DecryptResponse
class Nip44DecryptResultProcessor(
val onReady: (String) -> Unit,
) : NewResultProcessor {
override fun process(intent: Intent) {
val foregroundResult = Nip44DecryptResponse.parse(intent)
if (foregroundResult is SignerResult.Successful<DecryptionResult>) {
onReady(foregroundResult.result.plaintext)
}
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44EncryptResponse
class Nip44EncryptResultProcessor(
val onReady: (String) -> Unit,
) : NewResultProcessor {
override fun process(intent: Intent) {
val foregroundResult = Nip44EncryptResponse.parse(intent)
if (foregroundResult is SignerResult.Successful<EncryptionResult>) {
onReady(foregroundResult.result.ciphertext)
}
}
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors
import android.content.Intent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.NewResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignResponse
class SignResultProcessor(
val unsignedEvent: Event,
val onReady: (Event) -> Unit,
) : NewResultProcessor {
override fun process(intent: Intent) {
val foregroundResult = SignResponse.parse(intent, unsignedEvent)
if (foregroundResult is SignerResult.Successful<SignResult>) {
onReady(foregroundResult.result.event)
}
}
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.permission
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
class Permission(
val type: CommandType?,
val kind: Int? = null,
) {
fun toJson(): String = JsonMapper.mapper.writeValueAsString(this)
companion object {
fun fromJson(json: String): Permission = JsonMapper.mapper.readValue(json, Permission::class.java)
fun fromJsonArray(json: String): Array<Permission> = JsonMapper.mapper.readValue(json, Array<Permission>::class.java)
}
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.permission
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
class PermissionDeserializer : StdDeserializer<Permission>(Permission::class.java) {
override fun deserialize(
jp: JsonParser,
ctxt: DeserializationContext,
): Permission {
val jsonObject: JsonNode = jp.codec.readTree(jp)
return Permission(
type = CommandType.parse(jsonObject.get("type").asText()),
kind = jsonObject.get("kind")?.asInt(),
)
}
}
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.api.permission
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
class PermissionSerializer : StdSerializer<Permission>(Permission::class.java) {
override fun serialize(
permission: Permission,
gen: JsonGenerator,
provider: SerializerProvider,
) {
gen.writeStartObject()
permission.type.let { gen.writeStringField("type", it?.code) }
if (permission.kind != null) {
gen.writeNumberField("kind", permission.kind)
} else {
gen.writeNullField("kind")
}
gen.writeEndObject()
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.client
import android.content.Intent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.LoginRequest
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.LoginResponse
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
object ExternalSignerLogin {
fun createIntent(permissions: List<Permission> = LoginRequest.DefaultPermissions): Intent {
val intent = LoginRequest.assemble(permissions)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
return intent
}
fun parseResult(data: Intent): SignerResult<PubKeyResult> = LoginResponse.parse(data)
}
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.client
import android.content.Intent
interface IActivityLauncher {
fun registerForegroundLauncher(launcher: ((Intent) -> Unit))
fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit))
fun newResponse(data: Intent)
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.client
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
@SuppressLint("QueryPermissionsNeeded")
fun isExternalSignerInstalled(context: Context): Boolean =
context.packageManager
.queryIntentActivities(
Intent().apply {
action = Intent.ACTION_VIEW
data = "nostrsigner:".toUri()
},
0,
).isNotEmpty()
@@ -18,23 +18,42 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner
package com.vitorpamplona.quartz.nip55AndroidSigner.client
import android.util.Log
import com.vitorpamplona.quartz.EventFactory
import android.content.ContentResolver
import android.content.Intent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers.BackgroundRequestHandler
import com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers.ForegroundRequestHandler
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
class NostrSignerExternal(
pubKey: HexKey,
val launcher: ExternalSignerLauncher,
) : NostrSigner(pubKey) {
packageName: String,
contentResolver: ContentResolver,
) : NostrSigner(pubKey),
IActivityLauncher {
override fun isWriteable(): Boolean = true
val backgroundQuery = BackgroundRequestHandler(pubKey, packageName, contentResolver)
val foregroundQuery = ForegroundRequestHandler(pubKey, packageName)
override fun registerForegroundLauncher(launcher: ((Intent) -> Unit)) {
this.foregroundQuery.launcher.registerForegroundLauncher(launcher)
}
override fun unregisterForegroundLauncher(launcher: ((Intent) -> Unit)) {
this.foregroundQuery.launcher.unregisterForegroundLauncher(launcher)
}
override fun newResponse(data: Intent) {
this.foregroundQuery.launcher.newResponse(data)
}
override fun <T : Event> sign(
createdAt: Long,
kind: Int,
@@ -42,7 +61,7 @@ class NostrSignerExternal(
content: String,
onReady: (T) -> Unit,
) {
val event =
val unsignedEvent =
Event(
id = EventHasher.hashId(pubKey, createdAt, kind, tags, content),
pubKey = pubKey,
@@ -53,109 +72,70 @@ class NostrSignerExternal(
sig = "",
)
launcher.openSigner(event) { signature ->
if (signature.startsWith("{")) {
val localEvent = Event.fromJson(signature)
(
EventFactory.create(
localEvent.id,
localEvent.pubKey,
localEvent.createdAt,
localEvent.kind,
localEvent.tags,
localEvent.content,
localEvent.sig,
) as? T?
)?.let { onReady(it) }
} else {
(
EventFactory.create(
event.id,
event.pubKey,
event.createdAt,
event.kind,
event.tags,
event.content,
signature.split("-")[0],
) as? T?
)?.let { onReady(it) }
}
val newOnReady: (Event) -> Unit = { result ->
(result as? T)?.let(onReady)
}
if (!backgroundQuery.sign(unsignedEvent, newOnReady)) {
foregroundQuery.sign(unsignedEvent, newOnReady)
}
}
override fun nip04Encrypt(
decryptedContent: String,
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
) {
launcher.encrypt(
decryptedContent,
toPublicKey,
SignerType.NIP04_ENCRYPT,
onReady,
)
if (!backgroundQuery.nip04Encrypt(plaintext, toPublicKey, onReady)) {
foregroundQuery.nip04Encrypt(plaintext, toPublicKey, onReady)
}
}
override fun nip04Decrypt(
encryptedContent: String,
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
) {
launcher.decrypt(
encryptedContent,
fromPublicKey,
SignerType.NIP04_DECRYPT,
onReady,
)
if (!backgroundQuery.nip04Decrypt(ciphertext, fromPublicKey, onReady)) {
foregroundQuery.nip04Decrypt(ciphertext, fromPublicKey, onReady)
}
}
override fun nip44Encrypt(
decryptedContent: String,
plaintext: String,
toPublicKey: HexKey,
onReady: (String) -> Unit,
) {
launcher.encrypt(
decryptedContent,
toPublicKey,
SignerType.NIP44_ENCRYPT,
onReady,
)
if (!backgroundQuery.nip44Encrypt(plaintext, toPublicKey, onReady)) {
foregroundQuery.nip44Encrypt(plaintext, toPublicKey, onReady)
}
}
override fun nip44Decrypt(
encryptedContent: String,
ciphertext: String,
fromPublicKey: HexKey,
onReady: (String) -> Unit,
) {
launcher.decrypt(
encryptedContent,
fromPublicKey,
SignerType.NIP44_DECRYPT,
onReady,
)
if (!backgroundQuery.nip44Decrypt(ciphertext, fromPublicKey, onReady)) {
foregroundQuery.nip44Decrypt(ciphertext, fromPublicKey, onReady)
}
}
override fun deriveKey(
nonce: HexKey,
onReady: (HexKey) -> Unit,
) {
launcher.deriveKey(
nonce,
SignerType.DERIVE_KEY,
onReady,
)
if (!backgroundQuery.deriveKey(nonce, onReady)) {
foregroundQuery.deriveKey(nonce, onReady)
}
}
override fun decryptZapEvent(
event: LnZapRequestEvent,
onReady: (LnZapPrivateEvent) -> Unit,
) {
launcher.decryptZapEvent(event) { jsonEvent ->
try {
(Event.fromJson(jsonEvent) as? LnZapPrivateEvent)?.let { onReady(it) }
} catch (e: Exception) {
Log.e("NostrExternalSigner", "Unable to parse returned decrypted Zap: $jsonEvent")
}
if (!backgroundQuery.decryptZapEvent(event, onReady)) {
foregroundQuery.decryptZapEvent(event, onReady)
}
}
}
@@ -0,0 +1,163 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers
import android.content.ContentResolver
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.RequestAddressed
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.DecryptZapQuery
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.DeriveKeyQuery
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.LoginQuery
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04DecryptQuery
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04EncryptQuery
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44DecryptQuery
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44EncryptQuery
import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.SignQuery
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
class BackgroundRequestHandler(
loggedInUser: HexKey,
packageName: String,
contentResolver: ContentResolver,
) {
val login = LoginQuery(packageName, contentResolver)
val sign = SignQuery(loggedInUser, packageName, contentResolver)
val nip04Encrypt = Nip04EncryptQuery(loggedInUser, packageName, contentResolver)
val nip04Decrypt = Nip04DecryptQuery(loggedInUser, packageName, contentResolver)
val nip44Encrypt = Nip44EncryptQuery(loggedInUser, packageName, contentResolver)
val nip44Decrypt = Nip44DecryptQuery(loggedInUser, packageName, contentResolver)
val decryptZap = DecryptZapQuery(loggedInUser, packageName, contentResolver)
val deriveKey = DeriveKeyQuery(loggedInUser, packageName, contentResolver)
fun login(onReady: (HexKey) -> Unit): Boolean {
val backgroundResult = login.query()
if (backgroundResult is SignerResult.Successful<PubKeyResult>) {
onReady(backgroundResult.result.pubkey)
}
return backgroundResult is RequestAddressed
}
fun sign(
unsignedEvent: Event,
onReady: (Event) -> Unit,
): Boolean {
val backgroundResult = sign.query(unsignedEvent)
if (backgroundResult is SignerResult.Successful<SignResult>) {
onReady(backgroundResult.result.event)
}
return backgroundResult is RequestAddressed
}
fun nip04Encrypt(
plaintext: String,
toPubKey: HexKey,
onReady: (String) -> Unit,
): Boolean {
val backgroundResult = nip04Encrypt.query(plaintext, toPubKey)
if (backgroundResult is SignerResult.Successful<EncryptionResult>) {
onReady(backgroundResult.result.ciphertext)
}
return backgroundResult is RequestAddressed
}
fun nip04Decrypt(
ciphertext: String,
fromPubKey: HexKey,
onReady: (String) -> Unit,
): Boolean {
val backgroundResult = nip04Decrypt.query(ciphertext, fromPubKey)
if (backgroundResult is SignerResult.Successful<DecryptionResult>) {
onReady(backgroundResult.result.plaintext)
}
return backgroundResult is RequestAddressed
}
fun nip44Encrypt(
plaintext: String,
toPubKey: HexKey,
onReady: (String) -> Unit,
): Boolean {
val backgroundResult = nip44Encrypt.query(plaintext, toPubKey)
if (backgroundResult is SignerResult.Successful<EncryptionResult>) {
onReady(backgroundResult.result.ciphertext)
}
return backgroundResult is RequestAddressed
}
fun nip44Decrypt(
ciphertext: String,
fromPubKey: HexKey,
onReady: (String) -> Unit,
): Boolean {
val backgroundResult = nip44Decrypt.query(ciphertext, fromPubKey)
if (backgroundResult is SignerResult.Successful<DecryptionResult>) {
onReady(backgroundResult.result.plaintext)
}
return backgroundResult is RequestAddressed
}
fun decryptZapEvent(
event: LnZapRequestEvent,
onReady: (LnZapPrivateEvent) -> Unit,
): Boolean {
val backgroundResult = decryptZap.query(event)
if (backgroundResult is SignerResult.Successful<ZapEventDecryptionResult>) {
onReady(backgroundResult.result.privateEvent)
}
return backgroundResult is RequestAddressed
}
fun deriveKey(
nonce: HexKey,
onReady: (HexKey) -> Unit,
): Boolean {
val backgroundResult = deriveKey.query(nonce)
if (backgroundResult is SignerResult.Successful<DerivationResult>) {
onReady(backgroundResult.result.newPrivKey)
}
return backgroundResult is RequestAddressed
}
}
@@ -0,0 +1,108 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.IntentRequestDatabase
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.DecryptZapRequest
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.DeriveKeyRequest
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip04DecryptRequest
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip04EncryptRequest
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44DecryptRequest
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44EncryptRequest
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.SignRequest
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.DecryptZapResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.DeriveKeyResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.Nip04DecryptResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.Nip04EncryptResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.Nip44DecryptResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.Nip44EncryptResultProcessor
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.processors.SignResultProcessor
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
class ForegroundRequestHandler(
val loggedInUser: HexKey,
val packageName: String,
) {
val launcher = IntentRequestDatabase()
fun sign(
unsignedEvent: Event,
onReady: (Event) -> Unit,
) = launcher.launch(
SignRequest.assemble(unsignedEvent, loggedInUser, packageName),
SignResultProcessor(unsignedEvent, onReady),
)
fun nip04Encrypt(
plaintext: String,
toPubKey: HexKey,
onReady: (String) -> Unit,
) = launcher.launch(
Nip04EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName),
Nip04EncryptResultProcessor(onReady),
)
fun nip04Decrypt(
ciphertext: String,
fromPubKey: HexKey,
onReady: (String) -> Unit,
) = launcher.launch(
Nip04DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName),
Nip04DecryptResultProcessor(onReady),
)
fun nip44Encrypt(
plaintext: String,
toPubKey: HexKey,
onReady: (String) -> Unit,
) = launcher.launch(
Nip44EncryptRequest.assemble(plaintext, toPubKey, loggedInUser, packageName),
Nip44EncryptResultProcessor(onReady),
)
fun nip44Decrypt(
ciphertext: String,
fromPubKey: HexKey,
onReady: (String) -> Unit,
) = launcher.launch(
Nip44DecryptRequest.assemble(ciphertext, fromPubKey, loggedInUser, packageName),
Nip44DecryptResultProcessor(onReady),
)
fun decryptZapEvent(
event: LnZapRequestEvent,
onReady: (LnZapPrivateEvent) -> Unit,
) = launcher.launch(
DecryptZapRequest.assemble(event, loggedInUser, packageName),
DecryptZapResultProcessor(onReady),
)
fun deriveKey(
nonce: HexKey,
onReady: (HexKey) -> Unit,
) = launcher.launch(
DeriveKeyRequest.assemble(nonce, loggedInUser, packageName),
DeriveKeyResultProcessor(onReady),
)
}