diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9b558b21c..dbb9b2b1c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,11 @@ + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt index 6ac277e53..31f6842c1 100644 --- a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt +++ b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt @@ -19,6 +19,7 @@ import dev.dimension.flare.data.model.AvatarShape import dev.dimension.flare.data.model.LocalAppearanceSettings import dev.dimension.flare.data.model.VideoAutoplay import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.ui.common.BindAmberSignerLauncher import dev.dimension.flare.ui.component.ComponentAppearance import dev.dimension.flare.ui.component.LocalComponentAppearance import dev.dimension.flare.ui.screen.home.HomeScreen @@ -38,6 +39,7 @@ fun AppContainer(afterInit: () -> Unit) { @Composable fun FlareApp(content: @Composable () -> Unit) { + BindAmberSignerLauncher() val settingsRepository = koinInject() val appearanceSettings by settingsRepository.appearanceSettings.collectAsState( AppearanceSettings(), diff --git a/compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml b/compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml index 6506b4427..975785afe 100644 --- a/compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-ja-rJP/strings.xml @@ -346,6 +346,10 @@ 認証情報を確認するまでしばらくお待ちください。 Nostr アカウントをインポート 読み取り専用アクセス用に npub または hexpubkey を貼り付けるか、書き込み可能なアカウントをインポートするために nsecを提供します。 + npub、nsec、または hex キー + bunker:// または nostrconnect:// URI + bunker に接続 + Amber に接続 生成してログイン npub または hexpubkey (nsecが設定されている場合はオプション) nsecまたはhex秘密キー diff --git a/compose-ui/src/commonMain/composeResources/values/strings.xml b/compose-ui/src/commonMain/composeResources/values/strings.xml index 3204f74a1..108fd3b2b 100644 --- a/compose-ui/src/commonMain/composeResources/values/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values/strings.xml @@ -452,7 +452,7 @@ Welcome to Flare Please input the server to get started. - Flare supports Mastodon, Misskey, Bluesky and X. + Flare supports Mastodon, Misskey, Bluesky, Nostr and X. Or pick from these servers Username Password @@ -464,6 +464,10 @@ Please wait while we verify your credentials. Import Nostr account Paste an npub or hex pubkey for read-only access, or provide an nsec to import a writable account. + npub, nsec, or hex key + bunker:// or nostrconnect:// URI + Connect bunker + Connect Amber Generate and login npub or hex pubkey (optional if nsec is set) nsec or hex private key diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt index deca1484b..93d0ced70 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/NostrInputPresenter.kt @@ -14,23 +14,33 @@ import kotlin.native.HiddenFromObjC public class NostrInputPresenter : PresenterBase() { @Immutable public interface State { - public val secretKey: TextFieldState - public val canLogin: Boolean + public val accountInput: TextFieldState + public val bunkerInput: TextFieldState + public val canLoginAccount: Boolean + public val canLoginBunker: Boolean } @Composable override fun body(): State { - val secretKey = rememberTextFieldState() + val accountInput = rememberTextFieldState() + val bunkerInput = rememberTextFieldState() - val canLogin by remember(secretKey) { + val canLoginAccount by remember(accountInput) { derivedStateOf { - secretKey.text.isNotEmpty() + accountInput.text.isNotEmpty() + } + } + val canLoginBunker by remember(bunkerInput) { + derivedStateOf { + bunkerInput.text.isNotEmpty() } } return object : State { - override val secretKey: TextFieldState = secretKey - override val canLogin: Boolean = canLogin + override val accountInput: TextFieldState = accountInput + override val bunkerInput: TextFieldState = bunkerInput + override val canLoginAccount: Boolean = canLoginAccount + override val canLoginBunker: Boolean = canLoginBunker } } } diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt index b706d5568..dd1d2ee07 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt @@ -57,7 +57,11 @@ import dev.dimension.flare.compose.ui.eula_privacy_policy import dev.dimension.flare.compose.ui.login_agreement import dev.dimension.flare.compose.ui.login_button import dev.dimension.flare.compose.ui.mastodon_login_verify_message -import dev.dimension.flare.compose.ui.nostr_login_nsec_hint +import dev.dimension.flare.compose.ui.nostr_login_account_hint +import dev.dimension.flare.compose.ui.nostr_login_amber_button +import dev.dimension.flare.compose.ui.nostr_login_bunker_button +import dev.dimension.flare.compose.ui.nostr_login_bunker_hint +import dev.dimension.flare.compose.ui.nostr_login_hint import dev.dimension.flare.compose.ui.service_select_compatibility_warning import dev.dimension.flare.compose.ui.service_select_empty_message import dev.dimension.flare.compose.ui.service_select_instance_input_placeholder @@ -617,24 +621,29 @@ private fun NostrLoginContent(state: SelectionPresenter.State) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { - PlatformSecureTextField( - state = state.nostrInputState.secretKey, + PlatformText( + text = stringResource(Res.string.nostr_login_hint), + textAlign = TextAlign.Center, + modifier = Modifier.width(300.dp), + ) + PlatformTextField( + state = state.nostrInputState.accountInput, label = { - PlatformText(text = stringResource(Res.string.nostr_login_nsec_hint)) + PlatformText(text = stringResource(Res.string.nostr_login_account_hint)) }, enabled = !state.nostrLoginState.loading, modifier = Modifier.width(300.dp), keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done, autoCorrectEnabled = false, ), onKeyboardAction = { - if (state.nostrInputState.canLogin) { + if (state.nostrInputState.canLoginAccount) { state.nostrLoginState.login( - secretKey = - state.nostrInputState.secretKey.text + input = + state.nostrInputState.accountInput.text .toString(), ) } @@ -643,16 +652,61 @@ private fun NostrLoginContent(state: SelectionPresenter.State) { PlatformFilledTonalButton( onClick = { state.nostrLoginState.login( - secretKey = - state.nostrInputState.secretKey.text + input = + state.nostrInputState.accountInput.text .toString(), ) }, modifier = Modifier.width(300.dp), - enabled = state.nostrInputState.canLogin && !state.nostrLoginState.loading, + enabled = state.nostrInputState.canLoginAccount && !state.nostrLoginState.loading, ) { PlatformText(text = stringResource(Res.string.login_button)) } + PlatformTextField( + state = state.nostrInputState.bunkerInput, + label = { + PlatformText(text = stringResource(Res.string.nostr_login_bunker_hint)) + }, + enabled = !state.nostrLoginState.loading, + modifier = Modifier.width(300.dp), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + ), + onKeyboardAction = { + if (state.nostrInputState.canLoginBunker) { + state.nostrLoginState.login( + input = + state.nostrInputState.bunkerInput.text + .toString(), + ) + } + }, + ) + PlatformFilledTonalButton( + onClick = { + state.nostrLoginState.login( + input = + state.nostrInputState.bunkerInput.text + .toString(), + ) + }, + modifier = Modifier.width(300.dp), + enabled = state.nostrInputState.canLoginBunker && !state.nostrLoginState.loading, + ) { + PlatformText(text = stringResource(Res.string.nostr_login_bunker_button)) + } + if (state.nostrLoginState.amberAvailable) { + PlatformFilledTonalButton( + onClick = state.nostrLoginState::connectAmber, + modifier = Modifier.width(300.dp), + enabled = !state.nostrLoginState.loading, + ) { + PlatformText(text = stringResource(Res.string.nostr_login_amber_button)) + } + } state.nostrLoginState.error?.let { PlatformText( text = it.message ?: "Unknown error", diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index ffdac8467..ee6860183 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -106,6 +106,8 @@ kotlin { dependencies { implementation(libs.core.ktx) implementation(libs.koin.android) + implementation(libs.koin.compose) + implementation(libs.activity.compose) } } val androidDeviceTest by getting { diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.android.kt new file mode 100644 index 000000000..cfa473143 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.android.kt @@ -0,0 +1,177 @@ +package dev.dimension.flare.data.network.nostr + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import dev.dimension.flare.ui.model.NostrSignerCredential +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal data class AmberIntentResult( + val resultCode: Int, + val data: Intent?, +) + +internal class AmberIntentLauncherRegistry { + private val mutex = Mutex() + private var launcher: ((Intent, (AmberIntentResult) -> Unit) -> Unit)? = null + + fun attach(launcher: (Intent, (AmberIntentResult) -> Unit) -> Unit): AutoCloseable { + this.launcher = launcher + return AutoCloseable { + if (this.launcher === launcher) { + this.launcher = null + } + } + } + + suspend fun launch(intent: Intent): AmberIntentResult = + mutex.withLock { + suspendCancellableCoroutine { continuation -> + val currentLauncher = launcher + if (currentLauncher == null) { + continuation.resumeWithException( + IllegalStateException("Amber signer launcher is not attached."), + ) + return@suspendCancellableCoroutine + } + currentLauncher.invoke(intent) { result -> + if (continuation.isActive) { + continuation.resume(result) + } + } + } + } +} + +internal class AndroidAmberSignerBridge( + private val context: Context, + private val launcherRegistry: AmberIntentLauncherRegistry, +) : AmberSignerBridge { + override fun isAvailable(): Boolean { + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + } + return context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL).isNotEmpty() + } + + override suspend fun connect(): AmberConnection { + ensureAvailable() + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + putExtra("type", "get_public_key") + } + val result = launcherRegistry.launch(intent) + val data = requireApprovedResult(result) + val pubkeyHex = + data + .getStringExtra("result") + ?.let(::parsePublicKeyHex) + .orEmpty() + val packageName = data.getStringExtra("package")?.trim().orEmpty() + require(pubkeyHex.isNotEmpty()) { + "Amber did not return a public key." + } + require(packageName.isNotEmpty()) { + "Amber did not return a signer package name." + } + return AmberConnection( + credential = + NostrSignerCredential.Amber( + userPubkeyHex = pubkeyHex, + packageName = packageName, + approvedSignerPubkey = pubkeyHex, + ), + pubkeyHex = pubkeyHex, + ) + } + + override suspend fun getPublicKey(credential: NostrSignerCredential.Amber): String = credential.userPubkeyHex + + override suspend fun signEvent( + credential: NostrSignerCredential.Amber, + unsignedEventJson: String, + ): String = + querySignedEvent( + credential = credential, + unsignedEventJson = unsignedEventJson, + ) ?: launchSignEvent( + credential = credential, + unsignedEventJson = unsignedEventJson, + ) + + private suspend fun launchSignEvent( + credential: NostrSignerCredential.Amber, + unsignedEventJson: String, + ): String { + ensureAvailable() + val currentUser = credential.userPubkeyHex + val intentId = unsignedEventJson.hashCode().toString() + val intent = + Intent( + Intent.ACTION_VIEW, + Uri.parse("nostrsigner:${Uri.encode(unsignedEventJson)}"), + ).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + `package` = credential.packageName + putExtra("type", "sign_event") + putExtra("id", intentId) + putExtra("current_user", currentUser) + } + val result = launcherRegistry.launch(intent) + val data = requireApprovedResult(result) + return data + .getStringExtra("event") + ?.takeIf { it.isNotBlank() } + ?: throw IllegalStateException("Amber did not return a signed event.") + } + + private fun querySignedEvent( + credential: NostrSignerCredential.Amber, + unsignedEventJson: String, + ): String? { + val packageName = credential.packageName ?: return null + val uri = Uri.parse("content://$packageName.SIGN_EVENT") + val cursor = + context.contentResolver.query( + uri, + arrayOf(unsignedEventJson, "", credential.userPubkeyHex), + null, + null, + null, + ) ?: return null + cursor.use { + if (it.getColumnIndex("rejected") >= 0) { + throw IllegalStateException("Amber rejected the signing request.") + } + if (!it.moveToFirst()) { + return null + } + val eventIndex = it.getColumnIndex("event") + if (eventIndex < 0) { + return null + } + return it.getString(eventIndex)?.takeIf(String::isNotBlank) + } + } + + private fun requireApprovedResult(result: AmberIntentResult): Intent { + if (result.resultCode != Activity.RESULT_OK) { + throw IllegalStateException("Amber rejected the request.") + } + return result.data ?: throw IllegalStateException("Amber returned no result data.") + } + + private fun ensureAvailable() { + check(isAvailable()) { + "Amber signer is not installed." + } + } +} diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt index 695e6658c..663bf2fb8 100644 --- a/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt @@ -4,11 +4,15 @@ import dev.dimension.flare.data.database.DriverFactory import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.io.AndroidPlatformPathProducer import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.data.network.nostr.AmberIntentLauncherRegistry +import dev.dimension.flare.data.network.nostr.AmberSignerBridge +import dev.dimension.flare.data.network.nostr.AndroidAmberSignerBridge import dev.dimension.flare.data.network.rss.NativeWebScraper import dev.dimension.flare.shared.image.AndroidImageCompressor import dev.dimension.flare.shared.image.ImageCompressor import dev.dimension.flare.ui.humanizer.AndroidFormatter import dev.dimension.flare.ui.humanizer.PlatformFormatter +import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind @@ -19,6 +23,8 @@ internal actual val platformModule: Module = singleOf(::AppDataStore) singleOf(::DriverFactory) singleOf(::NativeWebScraper) + singleOf(::AmberIntentLauncherRegistry) + single { AndroidAmberSignerBridge(androidContext(), get()) } singleOf(::AndroidPlatformPathProducer) bind PlatformPathProducer::class singleOf(::AndroidFormatter) bind PlatformFormatter::class singleOf(::AndroidImageCompressor) bind ImageCompressor::class diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/ui/common/AmberSignerLauncherBinding.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/ui/common/AmberSignerLauncherBinding.kt new file mode 100644 index 000000000..ade388c59 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/ui/common/AmberSignerLauncherBinding.kt @@ -0,0 +1,41 @@ +package dev.dimension.flare.ui.common + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import dev.dimension.flare.data.network.nostr.AmberIntentLauncherRegistry +import dev.dimension.flare.data.network.nostr.AmberIntentResult +import org.koin.compose.koinInject + +@Composable +public fun BindAmberSignerLauncher() { + val registry = koinInject() + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + pendingCallback?.invoke( + AmberIntentResult( + resultCode = result.resultCode, + data = result.data, + ), + ) + pendingCallback = null + } + + DisposableEffect(registry, launcher) { + val closeable = + registry.attach { intent: Intent, callback: (AmberIntentResult) -> Unit -> + pendingCallback = callback + launcher.launch(intent) + } + onDispose { + pendingCallback = null + closeable.close() + } + } +} + +private var pendingCallback: ((AmberIntentResult) -> Unit)? = null diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.apple.kt new file mode 100644 index 000000000..e4e6b21e4 --- /dev/null +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.apple.kt @@ -0,0 +1,5 @@ +@file:Suppress("ktlint:standard:filename") + +package dev.dimension.flare.data.network.nostr + +internal class AppleAmberSignerBridge : AmberSignerBridge by UnsupportedAmberSignerBridge("Amber signer is only available on Android.") diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt index 079c2d213..c313d4890 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt @@ -6,6 +6,8 @@ import dev.dimension.flare.data.database.DriverFactory import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.io.ApplePlatformPathProducer import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.data.network.nostr.AmberSignerBridge +import dev.dimension.flare.data.network.nostr.AppleAmberSignerBridge import dev.dimension.flare.data.network.rss.NativeWebScraper import dev.dimension.flare.shared.image.ImageCompressor import dev.dimension.flare.shared.image.IosImageCompressor @@ -28,4 +30,5 @@ internal actual val platformModule: Module = singleOf(::ApplePlatformTextRenderer) bind PlatformTextRendering::class singleOf(::IosImageCompressor) bind ImageCompressor::class singleOf(::AppleOnDeviceAI) bind OnDeviceAI::class + singleOf(::AppleAmberSignerBridge) bind AmberSignerBridge::class } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt index 91e28a6f6..977fca2e7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt @@ -24,6 +24,7 @@ import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.datasource.microblog.paging.RemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.notSupported +import dev.dimension.flare.data.network.nostr.AmberSignerBridge import dev.dimension.flare.data.network.nostr.NostrService import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.model.MicroBlogKey @@ -33,6 +34,8 @@ import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.mapper.nostrLike import dev.dimension.flare.ui.model.mapper.nostrRepost +import dev.dimension.flare.ui.model.normalized +import dev.dimension.flare.ui.model.signerStableId import dev.dimension.flare.ui.presenter.compose.ComposeStatus import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -57,6 +60,7 @@ internal class NostrDataSource( private val accountRepository: AccountRepository by inject() private val ioScope: CoroutineScope by inject() private val nostrCache: NostrCache by inject() + private val amberSignerBridge: AmberSignerBridge by inject() private val credentialFlow by lazy { accountRepository.credentialFlow(accountKey) } @@ -69,13 +73,14 @@ internal class NostrDataSource( private val serviceManager by lazy { SwitchingServiceManager( - credentialFlow.distinctUntilChangedBy { it.nsec }, + credentialFlow.distinctUntilChangedBy { it.signerStableId(accountKey) }, ioScope, { NostrService( nostrCache, accountKey, - nsec = it.nsec, + credential = it.normalized(accountKey), + amberSignerBridge = amberSignerBridge, initialRelays = it.relays, ).also { it.ensureConnection() diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.kt new file mode 100644 index 000000000..47583a0c6 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.kt @@ -0,0 +1,38 @@ +package dev.dimension.flare.data.network.nostr + +import dev.dimension.flare.ui.model.NostrSignerCredential + +internal data class AmberConnection( + val credential: NostrSignerCredential.Amber, + val pubkeyHex: String, +) + +internal interface AmberSignerBridge { + fun isAvailable(): Boolean + + suspend fun connect(): AmberConnection + + suspend fun getPublicKey(credential: NostrSignerCredential.Amber): String + + suspend fun signEvent( + credential: NostrSignerCredential.Amber, + unsignedEventJson: String, + ): String +} + +internal class UnsupportedAmberSignerBridge( + private val message: String, +) : AmberSignerBridge { + override fun isAvailable(): Boolean = false + + override suspend fun connect(): AmberConnection = unsupported() + + override suspend fun getPublicKey(credential: NostrSignerCredential.Amber): String = unsupported() + + override suspend fun signEvent( + credential: NostrSignerCredential.Amber, + unsignedEventJson: String, + ): String = unsupported() + + private fun unsupported(): Nothing = throw UnsupportedOperationException(message) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrCompat.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrCompat.kt index 163738e5f..51fd727d4 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrCompat.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrCompat.kt @@ -692,6 +692,27 @@ internal fun Array>.mutedUserIdSet(): Set = userIdSet() internal fun bech32PublicKey(hex: String): String = RustPublicKey.Companion.parse(hex).use { it.toBech32() } +internal fun parsePublicKeyHex(raw: String): String? { + val value = raw.removePrefix("nostr:").trim() + return when { + value.startsWith("npub1", ignoreCase = true) -> + withNip19(value) { nip19 -> + (nip19 as? RustNip19Enum.Pubkey)?.npub?.use { it.toHex() } + } + + value.startsWith("nprofile1", ignoreCase = true) -> + withNip19(value) { nip19 -> + (nip19 as? RustNip19Enum.Profile)?.nprofile?.use { profile -> + profile.publicKey().use { it.toHex() } + } + } + + isHexKey(value) -> value.lowercase() + + else -> null + } +} + internal inline fun withNip19( value: String, block: (RustNip19Enum) -> T, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt index 06f4c2e7f..13ee7d4d8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/nostr/NostrService.kt @@ -1,6 +1,8 @@ package dev.dimension.flare.data.network.nostr import dev.dimension.flare.common.FileType +import dev.dimension.flare.common.JSON +import dev.dimension.flare.common.jsonObjectOrNull import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.userActionsMenu import dev.dimension.flare.data.datasource.nostr.NostrCache @@ -9,6 +11,7 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.model.ReferenceType import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.NostrSignerCredential import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiHandle import dev.dimension.flare.ui.model.UiIcon @@ -16,8 +19,11 @@ import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiNumber import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.effectivePubkeyHex +import dev.dimension.flare.ui.model.effectiveSigner import dev.dimension.flare.ui.model.mapper.nostrLike import dev.dimension.flare.ui.model.mapper.nostrRepost +import dev.dimension.flare.ui.model.normalized import dev.dimension.flare.ui.render.RenderContent import dev.dimension.flare.ui.render.RenderRun import dev.dimension.flare.ui.render.RenderTextStyle @@ -36,8 +42,11 @@ import kotlinx.coroutines.sync.withLock import rust.nostr.sdk.Client import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant import rust.nostr.sdk.Contact as RustContact +import rust.nostr.sdk.CustomNostrSigner as RustCustomNostrSigner +import rust.nostr.sdk.Event as RustSignedEvent import rust.nostr.sdk.EventBuilder as RustEventBuilder import rust.nostr.sdk.EventDeletionRequest as RustEventDeletionRequest import rust.nostr.sdk.EventId as RustEventId @@ -45,15 +54,19 @@ import rust.nostr.sdk.Keys as RustKeys import rust.nostr.sdk.Kind as RustKind import rust.nostr.sdk.MuteList as RustMuteList import rust.nostr.sdk.Nip19Enum as RustNip19Enum +import rust.nostr.sdk.NostrConnect as RustNostrConnect +import rust.nostr.sdk.NostrConnectUri as RustNostrConnectUri import rust.nostr.sdk.NostrSigner as RustNostrSigner import rust.nostr.sdk.PublicKey as RustPublicKey import rust.nostr.sdk.RelayUrl as RustRelayUrl import rust.nostr.sdk.Report as RustReport import rust.nostr.sdk.SecretKey as RustSecretKey import rust.nostr.sdk.SendEventOutput as RustSendEventOutput +import rust.nostr.sdk.SignerBackend as RustSignerBackend import rust.nostr.sdk.Tag as RustTag import rust.nostr.sdk.TagKind as RustTagKind import rust.nostr.sdk.Timestamp as RustTimestamp +import rust.nostr.sdk.UnsignedEvent as RustUnsignedEvent public val defaultNostrRelays: List = listOf( @@ -66,13 +79,15 @@ public val defaultNostrRelays: List = internal class NostrService( private val cache: NostrCache, private val accountKey: MicroBlogKey, - nsec: String, + credential: UiAccount.Nostr.Credential, + private val amberSignerBridge: AmberSignerBridge, initialRelays: List = emptyList(), ) : AutoCloseable { companion object { private val HEX_KEY_REGEX = Regex("^[0-9a-fA-F]{64}\$") private val HEX_DIGITS = "0123456789abcdef".toCharArray() internal const val NOSTR_HOST: String = "nostr" + private const val RELAY_LIST_METADATA_KIND = 10_002 private const val MIN_EARLY_RETURN_EVENTS = 4 private const val PUBLISH_SUCCESS_QUORUM = 3 private const val RELAY_PUBLISH_TIMEOUT_MILLIS = 1_500L @@ -84,42 +99,115 @@ internal class NostrService( private const val MAX_HOME_AUTHORS = 250 private const val MIN_METADATA_EVENT_LIMIT = 50 - internal fun importAccount(secretKeyInput: String): ImportedAccount { - val normalizedSecret = - secretKeyInput.trim().takeIf { it.isNotEmpty() }?.let(::normalizeSecret) - return normalizedSecret?.use { secretKey -> + internal suspend fun importAccount(input: String): ImportedAccount { + val value = input.removePrefix("nostr:").trim() + require(value.isNotEmpty()) { "A public key, private key, or bunker URI is required" } + + parseSecret(value)?.use { secretKey -> val pubkeyHex = RustKeys(secretKey).use { keys -> keys.publicKey().use { it.toHex() } } - ImportedAccount( + return ImportedAccount.LocalKey( pubkeyHex = pubkeyHex, npub = bech32PublicKey(pubkeyHex), nsec = secretKey.toBech32(), ) - } ?: error("A public key or secret key is required") + } + + parsePublicKeyHex(value)?.let { pubkeyHex -> + return ImportedAccount.ReadOnly( + pubkeyHex = pubkeyHex, + npub = bech32PublicKey(pubkeyHex), + ) + } + + if (value.startsWith("bunker://", ignoreCase = true) || value.startsWith("nostrconnect://", ignoreCase = true)) { + return importBunkerAccount(value) + } + + error("Unsupported Nostr account input") } - internal fun generateAccount(): ImportedAccount = + internal fun generateAccount(): ImportedAccount.LocalKey = RustKeys.Companion.generate().use { keys -> - ImportedAccount( + ImportedAccount.LocalKey( pubkeyHex = keys.publicKey().use { it.toHex() }, npub = keys.publicKey().use { it.toBech32() }, nsec = keys.secretKey().use { it.toBech32() }, ) } - internal fun exportAccount(credential: UiAccount.Nostr.Credential): ImportedAccount { - val secretKey = - requireNotNull(credential.nsec) { - "Nostr account does not have an exportable private key" + internal suspend fun resolvePublicRelays( + pubkeyHex: String, + bootstrapRelays: List = defaultNostrRelays, + ): List { + val normalizedBootstrap = normalizeRelayUrls(bootstrapRelays) + val client = Client(null) + val relayUrls = mutableListOf() + return try { + normalizedBootstrap.forEach { relay -> + val relayUrl = RustRelayUrl.parse(relay) + relayUrls += relayUrl + client.addRelay(relayUrl) } - return importAccount( - secretKeyInput = secretKey, + client.connect() + val events = + client + .fetchEvents( + Filter( + authors = listOf(pubkeyHex), + kinds = listOf(RELAY_LIST_METADATA_KIND, ContactListEvent.KIND), + limit = 10, + ).toRust(), + timeout = 10.seconds, + ).toVec() + .map { it.use { event -> event.toCompatEvent() } } + extractRelayUrls(events).ifEmpty { normalizedBootstrap } + } finally { + client.close() + relayUrls.forEach { it.close() } + } + } + + internal fun exportAccount(credential: UiAccount.Nostr.Credential): ImportedAccount { + val secretKey = (credential.effectiveSigner as? NostrSignerCredential.LocalKey)?.nsec + requireNotNull(secretKey) { + "Nostr account does not have an exportable private key" + } + val normalizedSecret = parseSecret(secretKey)?.use { it.toBech32() } + return ImportedAccount.LocalKey( + pubkeyHex = credential.pubkeyHex.ifBlank { error("Nostr account is missing a public key") }, + npub = bech32PublicKey(credential.pubkeyHex.ifBlank { error("Nostr account is missing a public key") }), + nsec = normalizedSecret ?: error("Failed to normalize exported secret"), ) } - private fun normalizeSecret(raw: String): RustSecretKey { + private suspend fun importBunkerAccount(uri: String): ImportedAccount.RemoteSigner { + val parsedUri = RustNostrConnectUri.parse(uri) + val generatedKeys = RustKeys.Companion.generate() + val appSecret = generatedKeys.secretKey().use { it.toBech32() } + val connect = RustNostrConnect(parsedUri, generatedKeys, 10.seconds, null) + return try { + val pubkeyHex = connect.getPublicKey().use { it.toHex() } + ImportedAccount.RemoteSigner( + pubkeyHex = pubkeyHex, + npub = bech32PublicKey(pubkeyHex), + signerCredential = + NostrSignerCredential.Bunker( + uri = uri, + userPubkeyHex = pubkeyHex, + secret = appSecret, + ), + ) + } finally { + connect.close() + generatedKeys.close() + parsedUri.close() + } + } + + private fun parseSecret(raw: String): RustSecretKey? { val value = raw.removePrefix("nostr:").trim() return when { value.startsWith("nsec1", ignoreCase = true) -> @@ -127,34 +215,73 @@ internal class NostrService( HEX_KEY_REGEX.matches(value) -> RustSecretKey.Companion.parse(value.lowercase()) - else -> error("Unsupported secret key format") + else -> null } } + + private fun extractRelayUrls(events: List): List { + val relayListEvent = + events + .filter { it.kind == RELAY_LIST_METADATA_KIND } + .maxByOrNull { it.createdAt } + relayListEvent?.let { event -> + relayUrlsFromTags(event.tags).takeIf { it.isNotEmpty() }?.let { return it } + } + + val contactListEvent = events.filterIsInstance().maxByOrNull { it.createdAt } + contactListEvent?.let { event -> + relayUrlsFromTags(event.tags).takeIf { it.isNotEmpty() }?.let { return it } + runCatching { + JSON + .parseToJsonElement(event.content) + .jsonObjectOrNull + ?.keys + ?.toList() + .orEmpty() + }.getOrDefault(emptyList()) + .let(::normalizeRelayUrls) + .takeIf { it.isNotEmpty() } + ?.let { return it } + } + + return emptyList() + } + + private fun relayUrlsFromTags(tags: Array>): List = + normalizeRelayUrls( + tags.mapNotNull { tag -> + tag + .takeIf { it.size > 1 && it[0] == "r" } + ?.get(1) + }, + ) + + private fun normalizeRelayUrls(relays: List): List = + relays + .ifEmpty { defaultNostrRelays } + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() } private var connected = false private val relayMutex = Mutex() private val currentRelays = linkedMapOf() private val initialRelays = initialRelays.normalizeRelayUrls() - - private val secretKey by lazy { - RustSecretKey.parse(nsec) - } - - private val keys by lazy { - RustKeys(secretKey) + private val credential = credential.normalized(accountKey) + private val signerHandle by lazy { + signerHandleOf(credential.effectiveSigner) } - + internal val canSign: Boolean + get() = signerHandle.canSign private val pubKey by lazy { - keys.publicKey() + publicKey(credential.effectivePubkeyHex(accountKey)) } - private val pubKeyHex by lazy { - pubKey.toHex() + credential.effectivePubkeyHex(accountKey) } - private val client by lazy { - Client(RustNostrSigner.keys(keys)) + Client(signerHandle.rustSigner) } private val blossomUploader by lazy { NostrBlossomUploader( @@ -171,8 +298,7 @@ internal class NostrService( currentRelays.values.forEach { it.close() } currentRelays.clear() pubKey.close() - keys.close() - secretKey.close() + signerHandle.close() } suspend fun ensureConnection() { @@ -216,17 +342,135 @@ internal class NostrService( } } - internal data class ImportedAccount( - val pubkeyHex: String, - val npub: String, - val nsec: String, - ) + internal sealed interface ImportedAccount { + val pubkeyHex: String + val npub: String + val signerCredential: NostrSignerCredential? - private fun List.normalizeRelayUrls(): List = - ifEmpty { defaultNostrRelays } - .map(String::trim) - .filter(String::isNotEmpty) - .distinct() + data class ReadOnly( + override val pubkeyHex: String, + override val npub: String, + ) : ImportedAccount { + override val signerCredential: NostrSignerCredential? = null + } + + data class LocalKey( + override val pubkeyHex: String, + override val npub: String, + val nsec: String, + ) : ImportedAccount { + override val signerCredential: NostrSignerCredential = NostrSignerCredential.LocalKey(nsec) + } + + data class RemoteSigner( + override val pubkeyHex: String, + override val npub: String, + override val signerCredential: NostrSignerCredential, + ) : ImportedAccount + } + + private sealed interface SignerHandle : AutoCloseable { + val rustSigner: RustNostrSigner? + val canSign: Boolean + + data object ReadOnly : SignerHandle { + override val rustSigner: RustNostrSigner? = null + override val canSign: Boolean = false + + override fun close() = Unit + } + + class LocalKey( + nsec: String, + ) : SignerHandle { + private val secretKey = RustSecretKey.parse(nsec) + private val keys = RustKeys(secretKey) + override val rustSigner: RustNostrSigner = RustNostrSigner.keys(keys) + override val canSign: Boolean = true + + override fun close() { + rustSigner.close() + keys.close() + secretKey.close() + } + } + + class Bunker( + credential: NostrSignerCredential.Bunker, + ) : SignerHandle { + private val appKeys = + RustKeys.parse( + credential.secret ?: error("Bunker signer requires an app secret"), + ) + private val uri = RustNostrConnectUri.parse(credential.uri) + private val connect = RustNostrConnect(uri, appKeys, 10.seconds, null) + override val rustSigner: RustNostrSigner = RustNostrSigner.nostrConnect(connect) + override val canSign: Boolean = true + + override fun close() { + rustSigner.close() + connect.close() + uri.close() + appKeys.close() + } + } + + class Amber( + private val credential: NostrSignerCredential.Amber, + private val amberSignerBridge: AmberSignerBridge, + ) : SignerHandle { + private val customSigner = + object : RustCustomNostrSigner { + override fun backend(): RustSignerBackend = RustSignerBackend.Custom("amber") + + override suspend fun getPublicKey(): RustPublicKey = RustPublicKey.parse(amberSignerBridge.getPublicKey(credential)) + + override suspend fun signEvent(unsignedEvent: RustUnsignedEvent): RustSignedEvent = + RustSignedEvent.fromJson( + amberSignerBridge.signEvent( + credential = credential, + unsignedEventJson = unsignedEvent.asJson(), + ), + ) + + override suspend fun nip04Encrypt( + publicKey: RustPublicKey, + content: String, + ): String = error("Amber NIP-04 encrypt is not supported") + + override suspend fun nip04Decrypt( + publicKey: RustPublicKey, + encryptedContent: String, + ): String = error("Amber NIP-04 decrypt is not supported") + + override suspend fun nip44Encrypt( + publicKey: RustPublicKey, + content: String, + ): String = error("Amber NIP-44 encrypt is not supported") + + override suspend fun nip44Decrypt( + publicKey: RustPublicKey, + payload: String, + ): String = error("Amber NIP-44 decrypt is not supported") + } + override val rustSigner: RustNostrSigner = RustNostrSigner.custom(customSigner) + override val canSign: Boolean = amberSignerBridge.isAvailable() + + override fun close() { + rustSigner.close() + } + } + } + + private fun signerHandleOf(credential: NostrSignerCredential?): SignerHandle = + when (credential) { + null -> SignerHandle.ReadOnly + is NostrSignerCredential.LocalKey -> SignerHandle.LocalKey(credential.nsec) + is NostrSignerCredential.Bunker -> SignerHandle.Bunker(credential) + is NostrSignerCredential.Amber -> SignerHandle.Amber(credential, amberSignerBridge) + } + + private fun List.normalizeRelayUrls(): List = normalizeRelayUrls(this) internal suspend fun loadHomeTimeline( pageSize: Int, @@ -942,6 +1186,7 @@ internal class NostrService( } private suspend fun sendEventBuilder(builder: RustEventBuilder): String { + requireWritable() val requiredSuccessCount = relayMutex.withLock { minOf(PUBLISH_SUCCESS_QUORUM, currentRelays.size) @@ -978,6 +1223,12 @@ internal class NostrService( "Failed to publish event to enough relays: $successCount/$requiredSuccessCount succeeded.", ) + private fun requireWritable() { + check(canSign) { + "This Nostr account is read-only. Connect a signer to publish events." + } + } + private fun parseSearchProfilePubkey(raw: String): String? { val value = raw.removePrefix("nostr:").trim() return when { @@ -1076,6 +1327,7 @@ internal class NostrService( .tags(tags.map { RustTag.Companion.parse(it.toList()) }) private suspend fun buildBlossomUploadAuthEvent(sha256: String): String { + requireWritable() val expirationSeconds = (Clock.System.now().toEpochMilliseconds() / 1000L).toULong() + 5.minutes.inWholeSeconds.toULong() @@ -1420,59 +1672,61 @@ internal class NostrService( content = parseNostrRichText(actualRenderContext, accountKey = accountKey, profiles = profiles), actions = buildList { - add( - ActionMenu.Item( - icon = UiIcon.Reply, - text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Reply), - clickEvent = - ClickEvent.Deeplink( - DeeplinkRoute.Compose.Reply( - accountKey = accountKey, - statusKey = statusKey, + if (canSign) { + add( + ActionMenu.Item( + icon = UiIcon.Reply, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Reply), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Compose.Reply( + accountKey = accountKey, + statusKey = statusKey, + ), ), - ), - count = UiNumber(0L), - ), - ) - add( - ActionMenu.Group( - displayItem = - ActionMenu.nostrRepost( - statusKey = statusKey, - repostEventId = stats.myRepostEventId, - count = stats.repostCount, - accountKey = accountKey, - ), - actions = - listOf( + count = UiNumber(0L), + ), + ) + add( + ActionMenu.Group( + displayItem = ActionMenu.nostrRepost( statusKey = statusKey, repostEventId = stats.myRepostEventId, count = stats.repostCount, accountKey = accountKey, ), - ActionMenu.Item( - icon = UiIcon.Quote, - text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Quote), - clickEvent = - ClickEvent.Deeplink( - DeeplinkRoute.Compose.Quote( - accountKey = accountKey, - statusKey = statusKey, + actions = + listOf( + ActionMenu.nostrRepost( + statusKey = statusKey, + repostEventId = stats.myRepostEventId, + count = stats.repostCount, + accountKey = accountKey, + ), + ActionMenu.Item( + icon = UiIcon.Quote, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Quote), + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.Compose.Quote( + accountKey = accountKey, + statusKey = statusKey, + ), ), - ), - ), - ).toImmutableList(), - ), - ) - add( - ActionMenu.nostrLike( - statusKey = statusKey, - reactionEventId = stats.myReactionEventId, - count = stats.reactionCount, - accountKey = accountKey, - ), - ) + ), + ).toImmutableList(), + ), + ) + add( + ActionMenu.nostrLike( + statusKey = statusKey, + reactionEventId = stats.myReactionEventId, + count = stats.reactionCount, + accountKey = accountKey, + ), + ) + } add( ActionMenu.Group( displayItem = @@ -1496,7 +1750,7 @@ internal class NostrService( ), ), ) - if (pubKey == accountKey.id) { + if (canSign && pubKey == accountKey.id) { add( ActionMenu.Item( icon = UiIcon.Delete, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt index 071527d8b..3f42d4bfd 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiAccount.kt @@ -17,6 +17,47 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import sh.christian.ozone.oauth.OAuthToken +@Immutable +@Serializable +internal sealed interface NostrSignerCredential { + val stableId: String + + @Immutable + @Serializable + @SerialName("NostrSignerLocalKey") + data class LocalKey( + val nsec: String, + ) : NostrSignerCredential { + override val stableId: String + get() = "local:$nsec" + } + + @Immutable + @Serializable + @SerialName("NostrSignerBunker") + data class Bunker( + val uri: String, + val userPubkeyHex: String? = null, + val signerRelay: String? = null, + val secret: String? = null, + ) : NostrSignerCredential { + override val stableId: String + get() = "bunker:$uri" + } + + @Immutable + @Serializable + @SerialName("NostrSignerAmber") + data class Amber( + val userPubkeyHex: String, + val packageName: String? = null, + val approvedSignerPubkey: String? = null, + ) : NostrSignerCredential { + override val stableId: String + get() = "amber:$userPubkeyHex:${packageName.orEmpty()}:${approvedSignerPubkey.orEmpty()}" + } +} + @Immutable public sealed class UiAccount { public abstract val accountKey: MicroBlogKey @@ -37,10 +78,12 @@ public sealed class UiAccount { @Serializable @SerialName("NostrCredential") data class Credential( -// val pubkey: String, - val nsec: String, + val pubkeyHex: String = "", val relays: List = emptyList(), val mediaServerUrl: String = "https://blossom.nostr.build/", + val signer: NostrSignerCredential? = null, + @SerialName("nsec") + internal val legacyNsec: String? = null, ) : UiAccount.Credential } @@ -270,3 +313,17 @@ public sealed class UiAccount { } } } + +internal val UiAccount.Nostr.Credential.effectiveSigner: NostrSignerCredential? + get() = signer ?: legacyNsec?.let(NostrSignerCredential::LocalKey) + +internal fun UiAccount.Nostr.Credential.effectivePubkeyHex(accountKey: MicroBlogKey): String = pubkeyHex.ifBlank { accountKey.id } + +internal fun UiAccount.Nostr.Credential.normalized(accountKey: MicroBlogKey): UiAccount.Nostr.Credential = + copy( + pubkeyHex = effectivePubkeyHex(accountKey), + signer = effectiveSigner, + ) + +internal fun UiAccount.Nostr.Credential.signerStableId(accountKey: MicroBlogKey): String = + effectiveSigner?.stableId ?: "readonly:${effectivePubkeyHex(accountKey)}" diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt index f25e4917e..b5cab4d56 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiProfile.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable import dev.dimension.flare.common.SerializableImmutableList import dev.dimension.flare.common.SerializableImmutableMap +import dev.dimension.flare.data.network.nostr.bech32PublicKey import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.humanizer.Formatter.humanize @@ -48,17 +49,17 @@ public data class UiProfile internal constructor( UiProfile( key = key, handle = - if (handle.raw.isBlank()) { + if (handle.raw.isBlank() || (isNostrFallbackHandle() && !existing.isNostrFallbackHandle())) { existing.handle } else { handle }, avatar = avatar.ifBlank { existing.avatar }, nameInternal = - if (name.raw.isBlank()) { - existing.name + if (nameInternal.raw.isBlank() || (isNostrFallbackName() && !existing.isNostrFallbackName())) { + existing.nameInternal } else { - name + nameInternal }, platformType = platformType, clickEvent = clickEvent, @@ -70,6 +71,16 @@ public data class UiProfile internal constructor( bottomContent = bottomContent ?: existing.bottomContent, ) + private fun isNostrFallbackHandle(): Boolean = + platformType == PlatformType.Nostr && + handle.raw == derivedNostrFallbackName() + + private fun isNostrFallbackName(): Boolean = + platformType == PlatformType.Nostr && + nameInternal.raw == derivedNostrFallbackName() + + private fun derivedNostrFallbackName(): String? = runCatching { bech32PublicKey(key.id).take(16) }.getOrNull() + @Serializable @Immutable public data class Matrices internal constructor( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginPresenter.kt index 5c305bd4a..3e2de22a1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginPresenter.kt @@ -7,7 +7,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import dev.dimension.flare.data.network.nostr.AmberSignerBridge import dev.dimension.flare.data.network.nostr.NostrService +import dev.dimension.flare.data.network.nostr.defaultNostrRelays import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiAccount @@ -21,6 +23,7 @@ public class NostrLoginPresenter( ) : PresenterBase(), KoinComponent { private val accountRepository: AccountRepository by inject() + private val amberSignerBridge: AmberSignerBridge by inject() @Composable override fun body(): NostrLoginState { @@ -30,15 +33,16 @@ public class NostrLoginPresenter( return object : NostrLoginState { override val loading: Boolean = loading override val error: Throwable? = error + override val amberAvailable: Boolean = amberSignerBridge.isAvailable() - override fun login(secretKey: String) { + override fun login(input: String) { scope.launch { loading = true error = null runCatching { loginWith( NostrService.importAccount( - secretKeyInput = secretKey, + input = input, ), ) }.onFailure { @@ -48,7 +52,45 @@ public class NostrLoginPresenter( } } - private fun loginWith(imported: NostrService.ImportedAccount) { + override fun connectAmber() { + scope.launch { + loading = true + error = null + runCatching { + val connection = amberSignerBridge.connect() + val relays = + runCatching { + NostrService.resolvePublicRelays(connection.pubkeyHex) + }.getOrDefault(defaultNostrRelays) + accountRepository.addAccount( + account = + UiAccount.Nostr( + accountKey = + MicroBlogKey( + id = connection.pubkeyHex, + host = NostrService.NOSTR_HOST, + ), + ), + credential = + UiAccount.Nostr.Credential( + pubkeyHex = connection.pubkeyHex, + relays = relays, + signer = connection.credential, + ), + ) + toHome.invoke() + }.onFailure { + error = it + } + loading = false + } + } + + private suspend fun loginWith(imported: NostrService.ImportedAccount) { + val relays = + runCatching { + NostrService.resolvePublicRelays(imported.pubkeyHex) + }.getOrDefault(defaultNostrRelays) accountRepository.addAccount( account = UiAccount.Nostr( @@ -60,8 +102,9 @@ public class NostrLoginPresenter( ), credential = UiAccount.Nostr.Credential( - nsec = imported.nsec, - relays = dev.dimension.flare.data.network.nostr.defaultNostrRelays, + pubkeyHex = imported.pubkeyHex, + relays = relays, + signer = imported.signerCredential, ), ) toHome.invoke() @@ -74,6 +117,9 @@ public class NostrLoginPresenter( public interface NostrLoginState { public val loading: Boolean public val error: Throwable? + public val amberAvailable: Boolean + + public fun login(input: String) - public fun login(secretKey: String) + public fun connectAmber() } diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt index 4f703315f..ef25ac532 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/database/cache/mapper/MicroblogTest.kt @@ -22,6 +22,8 @@ import dev.dimension.flare.data.database.cache.model.translationPayload import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.data.network.nostr.NostrService +import dev.dimension.flare.data.network.nostr.bech32PublicKey import dev.dimension.flare.data.translation.cacheKey import dev.dimension.flare.memoryDatabaseBuilder import dev.dimension.flare.model.AccountType @@ -1667,6 +1669,83 @@ class MicroblogTest : RobolectricTest() { ) } + @Test + fun saveToDatabaseKeepsExistingNostrProfileWhenIncomingUsesFallbackNpub() = + runTest { + val accountKey = MicroBlogKey(id = "nostr-account", host = NostrService.NOSTR_HOST) + val userKey = + MicroBlogKey( + id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + host = NostrService.NOSTR_HOST, + ) + val detailedUser = + UiProfile( + key = userKey, + handle = UiHandle(raw = "alice", host = NostrService.NOSTR_HOST), + avatar = "https://example.com/alice.png", + nameInternal = "Alice".toUiPlainText(), + platformType = dev.dimension.flare.model.PlatformType.Nostr, + clickEvent = ClickEvent.Noop, + banner = "https://example.com/banner.png", + description = "hello".toUiPlainText(), + matrices = UiProfile.Matrices(0, 0, 0), + mark = persistentListOf(), + bottomContent = null, + ) + val fallback = bech32PublicKey(userKey.id).take(16) + val fallbackUser = + UiProfile( + key = userKey, + handle = UiHandle(raw = fallback, host = NostrService.NOSTR_HOST), + avatar = "", + nameInternal = fallback.toUiPlainText(), + platformType = dev.dimension.flare.model.PlatformType.Nostr, + clickEvent = ClickEvent.Noop, + banner = null, + description = null, + matrices = UiProfile.Matrices(0, 0, 0), + mark = persistentListOf(), + bottomContent = null, + ) + + saveToDatabase( + db, + listOf( + TimelinePagingMapper.toDb( + createPost( + accountKey = accountKey, + user = detailedUser, + statusKey = MicroBlogKey(id = "status-detailed", host = NostrService.NOSTR_HOST), + text = "detailed", + ), + pagingKey = "home", + ), + ), + ) + saveToDatabase( + db, + listOf( + TimelinePagingMapper.toDb( + createPost( + accountKey = accountKey, + user = fallbackUser, + statusKey = MicroBlogKey(id = "status-fallback", host = NostrService.NOSTR_HOST), + text = "fallback", + ), + pagingKey = "home", + ), + ), + ) + + val savedUser = db.userDao().findByKey(userKey).first() + val savedProfile = assertNotNull(savedUser).content + assertEquals("alice", savedProfile.handle.raw) + assertEquals("Alice", savedProfile.name.raw) + assertEquals("https://example.com/alice.png", savedProfile.avatar) + assertEquals("https://example.com/banner.png", savedProfile.banner) + assertEquals("hello", savedProfile.description?.raw) + } + private fun createUser( key: MicroBlogKey, name: String, diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt index d123669c3..36b8fc944 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/network/nostr/NostrServiceTest.kt @@ -5,10 +5,12 @@ import dev.dimension.flare.data.datasource.nostr.NostrCache import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.ReferenceType import dev.dimension.flare.ui.humanizer.PlatformFormatter +import dev.dimension.flare.ui.model.NostrSignerCredential import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.test.runTest import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module @@ -17,6 +19,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -40,39 +43,101 @@ class NostrServiceTest { } @Test - fun exportAccountKeepsPrivateAndPublicKeysConsistent() { + fun exportAccountKeepsPrivateAndPublicKeysConsistent() = + runTest { + val generated = NostrService.generateAccount() + val exported = + NostrService.exportAccount( + UiAccount.Nostr.Credential( + pubkeyHex = generated.pubkeyHex, + signer = NostrSignerCredential.LocalKey(generated.nsec), + ), + ) + + assertMatchesKeyPair(exported) + assertEquals(generated.pubkeyHex, exported.pubkeyHex) + assertEquals(generated.npub, exported.npub) + } + + @Test + fun importAccountAcceptsSecretOnly() = + runTest { + val imported = + NostrService.importAccount( + input = SECRET_KEY_HEX, + ) + + assertMatchesKeyPair(imported) + } + + @Test + fun importAccountAcceptsReadOnlyNpub() = + runTest { + val generated = NostrService.generateAccount() + + val imported = + NostrService.importAccount( + input = generated.npub, + ) + + assertIs(imported) + assertEquals(generated.pubkeyHex, imported.pubkeyHex) + assertEquals(generated.npub, imported.npub) + } + + @Test + fun parsePublicKeyHexNormalizesNpubToHex() { val generated = NostrService.generateAccount() - val exported = - NostrService.exportAccount( - UiAccount.Nostr.Credential( - nsec = generated.nsec, - ), - ) - assertMatchesKeyPair(exported) - assertEquals(generated.pubkeyHex, exported.pubkeyHex) - assertEquals(generated.npub, exported.npub) + val parsed = parsePublicKeyHex(generated.npub) + + assertEquals(generated.pubkeyHex, parsed) } @Test - fun importAccountAcceptsSecretOnly() { - val imported = - NostrService.importAccount( - secretKeyInput = SECRET_KEY_HEX, - ) + fun readOnlyAccountRejectsPublishing() = + runTest { + val service = + NostrService( + cache = + object : NostrCache { + override suspend fun getProfiles(pubKeys: List): Map = emptyMap() - assertMatchesKeyPair(imported) - } + override suspend fun getPost( + accountKey: MicroBlogKey, + statusKey: MicroBlogKey, + ): UiTimelineV2.Post? = null + }, + accountKey = MicroBlogKey(ROOT_EVENT_PUBKEY, NostrService.NOSTR_HOST), + credential = + UiAccount.Nostr.Credential( + pubkeyHex = ROOT_EVENT_PUBKEY, + ), + amberSignerBridge = UnsupportedAmberSignerBridge("Amber signer is unavailable in tests."), + ) - private fun assertMatchesKeyPair(account: NostrService.ImportedAccount) { + try { + val error = + assertFailsWith { + service.composeNote( + content = "read only", + ) + } + assertTrue(error.message?.contains("read-only") == true) + } finally { + service.close() + } + } + + private suspend fun assertMatchesKeyPair(account: NostrService.ImportedAccount) { assertEquals(64, account.pubkeyHex.length) assertTrue(account.npub.startsWith("npub1")) - val normalizedSecret = assertNotNull(account.nsec) + val normalizedSecret = assertNotNull((account as? NostrService.ImportedAccount.LocalKey)?.nsec) assertTrue(normalizedSecret.isNotBlank()) val reImported = NostrService.importAccount( - secretKeyInput = normalizedSecret, + input = normalizedSecret, ) assertEquals(account.pubkeyHex, reImported.pubkeyHex) assertEquals(account.npub, bech32PublicKey(account.pubkeyHex)) @@ -202,8 +267,9 @@ class NostrServiceTest { } private companion object { - fun createService(): NostrService = - NostrService( + fun createService(): NostrService { + val generated = NostrService.generateAccount() + return NostrService( cache = object : NostrCache { override suspend fun getProfiles(pubKeys: List): Map = emptyMap() @@ -214,8 +280,14 @@ class NostrServiceTest { ): UiTimelineV2.Post? = null }, accountKey = MicroBlogKey("nostr-test", NostrService.NOSTR_HOST), - nsec = NostrService.generateAccount().nsec, + credential = + UiAccount.Nostr.Credential( + pubkeyHex = generated.pubkeyHex, + signer = NostrSignerCredential.LocalKey(generated.nsec), + ), + amberSignerBridge = UnsupportedAmberSignerBridge("Amber signer is unavailable in tests."), ) + } const val SECRET_KEY_HEX = "1111111111111111111111111111111111111111111111111111111111111111" const val ROOT_EVENT_ID = "1b14014e85b5a3f554dc92198ce118d83562147ca08a98e4bb07b00d003108f7" diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.jvm.kt new file mode 100644 index 000000000..24fa254a9 --- /dev/null +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/network/nostr/AmberSignerBridge.jvm.kt @@ -0,0 +1,5 @@ +@file:Suppress("ktlint:standard:filename") + +package dev.dimension.flare.data.network.nostr + +internal class JvmAmberSignerBridge : AmberSignerBridge by UnsupportedAmberSignerBridge("Amber signer is only available on Android.") diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt index 94e4972c6..736d78b71 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt @@ -6,6 +6,8 @@ import dev.dimension.flare.data.database.DriverFactory import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.io.JvmPlatformPathProducer import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.data.network.nostr.AmberSignerBridge +import dev.dimension.flare.data.network.nostr.JvmAmberSignerBridge import dev.dimension.flare.shared.image.ImageCompressor import dev.dimension.flare.shared.image.JvmImageCompressor import dev.dimension.flare.ui.humanizer.JVMFormatter @@ -23,4 +25,5 @@ internal actual val platformModule: Module = singleOf(::JVMFormatter) bind PlatformFormatter::class singleOf(::JvmImageCompressor) bind ImageCompressor::class singleOf(::JvmOnDeviceAI) bind OnDeviceAI::class + singleOf(::JvmAmberSignerBridge) bind AmberSignerBridge::class }