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
}