diff --git a/packages/ndk_flutter/android/build.gradle b/packages/ndk_flutter/android/build.gradle
index 26305fd27..6312bb6fd 100644
--- a/packages/ndk_flutter/android/build.gradle
+++ b/packages/ndk_flutter/android/build.gradle
@@ -1,6 +1,4 @@
-package android
-
-group = 'com.sebdeveloper6952.amberflutter.amberflutter'
+group = 'relaystr.ndk'
version = '1.0-SNAPSHOT'
buildscript {
@@ -11,7 +9,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.12.3'
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.20"
}
}
@@ -27,7 +25,7 @@ apply plugin: 'kotlin-android'
android {
if (project.android.hasProperty("namespace")) {
- namespace = 'relastr.ndk'
+ namespace = 'relaystr.ndk'
}
compileSdk = 36
diff --git a/packages/ndk_flutter/android/settings.gradle b/packages/ndk_flutter/android/settings.gradle
index cbdd0a405..aa877f0ab 100644
--- a/packages/ndk_flutter/android/settings.gradle
+++ b/packages/ndk_flutter/android/settings.gradle
@@ -1,7 +1,5 @@
-package android
-
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
}
-rootProject.name = 'amberflutter'
+rootProject.name = 'ndk_flutter'
diff --git a/packages/ndk_flutter/android/src/main/AndroidManifest.xml b/packages/ndk_flutter/android/src/main/AndroidManifest.xml
index a2f47b605..44bda0c13 100644
--- a/packages/ndk_flutter/android/src/main/AndroidManifest.xml
+++ b/packages/ndk_flutter/android/src/main/AndroidManifest.xml
@@ -1,2 +1,10 @@
+
+
+
+
+
+
+
diff --git a/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/DartNdkPlugin.kt b/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/DartNdkPlugin.kt
index fb8ff76c2..45edafc76 100644
--- a/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/DartNdkPlugin.kt
+++ b/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/DartNdkPlugin.kt
@@ -1,13 +1,13 @@
package relaystr.ndk
import android.app.Activity
+import android.content.ContentResolver
+import android.content.Context
import android.content.Intent
import android.net.Uri
+import android.os.Handler
+import android.os.Looper
import android.util.Log
-import androidx.annotation.NonNull
-import com.google.gson.Gson
-import android.content.Context
-
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
@@ -19,6 +19,13 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
+/// ndk_flutter native plugin.
+///
+/// Hosts the NIP-55 "Android Signer Application" bridge (external signer apps
+/// such as Amber, Primal, Aegis, ...). Communication happens either silently
+/// through a ContentResolver query (when the user has pre-authorized the
+/// permission) or, as a fallback, by launching the signer via an Intent and
+/// reading the result in [onActivityResult].
class DartNdkPlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
PluginRegistry.ActivityResultListener {
private lateinit var _channel : MethodChannel
@@ -29,29 +36,135 @@ class DartNdkPlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
private val _intentRequestCode = 0
-
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
_channel = MethodChannel(flutterPluginBinding.binaryMessenger, "ndk")
_channel.setMethodCallHandler(this)
_context = flutterPluginBinding.applicationContext
}
- override fun onMethodCall(call: MethodCall, result: Result) {
- _result = result
+ private fun isPackageInstalled(context: Context, target: String): Boolean {
+ return context.packageManager.getInstalledApplications(0)
+ .find { info -> info.packageName == target } != null
+ }
+
+ private fun isExternalSignerInstalled(context: Context): Boolean {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$nostrSignerScheme:"))
+ return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
+ }
+
+ override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
- "example" -> {
- Log.i("example", "example onMethodCall")
+ nostrSignerScheme -> {
+ _result = MethodResultWrapper(result)
+
+ val paramsMap = call.arguments as? HashMap<*, *>
+ if (paramsMap == null) {
+ Log.d("onMethodCall", "paramsMap is null")
+ return
+ }
+
+ val requestType = paramsMap[keyType] as? String ?: ""
+ val currentUser = paramsMap[keyCurrentUser] as? String ?: ""
+ val pubKey = paramsMap[keyPubKey] as? String
+ ?: paramsMap["pubkey"] as? String
+ ?: ""
+ val id = paramsMap[keyId] as? String ?: ""
+ val uriData = paramsMap[keyUriData] as? String ?: ""
+ val permissions = paramsMap[keyPermissions] as? String ?: ""
+ // Signer app package captured at login (Amber, Primal, ...).
+ // Empty for get_public_key / legacy accounts.
+ val signerPackage = paramsMap[keyPackage] as? String ?: ""
+
+ // First try the silent ContentResolver path (pre-authorized
+ // permissions). Only attempt it when we know which signer to
+ // query (a captured package): querying a foreign provider
+ // (e.g. Amber for a Primal account) returns wrong/empty data.
+ // get_public_key (login) is Intent-only per NIP-55.
+ if (requestType != "get_public_key" && signerPackage.isNotEmpty()) {
+ val data = getDataFromContentResolver(
+ requestType.uppercase(),
+ arrayOf(uriData, pubKey, currentUser),
+ _context.contentResolver,
+ signerPackage,
+ )
+ if (!data.isNullOrEmpty()) {
+ Log.d("onMethodCall", "content resolver got data")
+ _result.success(data)
+ return
+ }
+ }
+
+ // Fallback: launch the signer app via Intent.
+ val intent = Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("$nostrSignerScheme:$uriData")
+ )
+ intent.putExtra(keyType, requestType)
+ intent.putExtra(keyCurrentUser, currentUser)
+ intent.putExtra(keyPubKey, pubKey)
+ intent.putExtra("pubkey", pubKey)
+ intent.putExtra(keyId, id)
+ intent.putExtra(keyPermissions, permissions)
+ // Target the captured signer directly (no app chooser). Empty
+ // for login, so the user can pick a signer the first time.
+ if (signerPackage.isNotEmpty()) {
+ intent.setPackage(signerPackage)
+ intent.putExtra(keyPackage, signerPackage)
+ }
+
+ try {
+ _activity?.startActivityForResult(intent, _intentRequestCode)
+ } catch (e: Exception) {
+ Log.d("onMethodCall", "startActivityForResult failed for '$signerPackage': ${e.message}")
+ _result.success(HashMap())
+ }
+ }
+
+ "isAppInstalled" -> {
+ val paramsMap = call.arguments as? HashMap<*, *>
+ val packageName = paramsMap?.get("packageName") as? String
+ val isInstalled = if (packageName.isNullOrEmpty()) {
+ isExternalSignerInstalled(_context)
+ } else {
+ isPackageInstalled(_context, packageName)
+ }
+ result.success(isInstalled)
}
else -> {
result.notImplemented()
- return
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?): Boolean {
- return false;
+ if (requestCode == _intentRequestCode) {
+ if (resultCode == Activity.RESULT_OK && intent != null) {
+ val dataMap: HashMap = HashMap()
+ if (intent.hasExtra(keyResult)) {
+ val result = intent.getStringExtra(keyResult)
+ dataMap[keyResult] = result
+ // keep `signature` populated for backwards compatibility
+ dataMap[keySignature] = result
+ }
+ if (intent.hasExtra(keySignature)) {
+ dataMap[keySignature] = intent.getStringExtra(keySignature)
+ }
+ if (intent.hasExtra(keyPackage)) {
+ dataMap[keyPackage] = intent.getStringExtra(keyPackage)
+ }
+ if (intent.hasExtra(keyId)) {
+ dataMap[keyId] = intent.getStringExtra(keyId)
+ }
+ if (intent.hasExtra(keyEvent)) {
+ dataMap[keyEvent] = intent.getStringExtra(keyEvent)
+ }
+
+ _result.success(dataMap)
+ return true
+ }
+ }
+ return false
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -76,5 +189,86 @@ class DartNdkPlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
_activity = null
}
+ /*
+ Content resolver path adapted from:
+ https://github.com/0xchat-app/nostr-dart/blob/main/android/src/main/kotlin/com/oxchat/nostrcore/ChatcorePlugin.kt
+ */
+ private fun getDataFromContentResolver(
+ type: String,
+ uriData: Array,
+ resolver: ContentResolver,
+ signerPackage: String,
+ ): HashMap? {
+ try {
+ resolver.query(
+ Uri.parse("content://${signerPackage}.$type"),
+ uriData,
+ null,
+ null,
+ null
+ ).use {
+ if (it == null) {
+ Log.d("getDataFromResolver", "resolver query is NULL")
+ return null
+ }
+ if (it.moveToFirst()) {
+ // The signer reports it cannot answer silently (permission
+ // not granted / user denied): fall back to the Intent so
+ // the user can approve, instead of returning empty data.
+ val rejectedIndex = it.getColumnIndex("rejected")
+ if (rejectedIndex >= 0) {
+ Log.d("getDataFromResolver", "request rejected -> fallback to intent")
+ return null
+ }
+
+ val dataMap: HashMap = HashMap()
+ val resultIndex = it.getColumnIndex("result")
+ if (resultIndex >= 0) {
+ val result = it.getString(resultIndex)
+ dataMap["result"] = result
+ dataMap["signature"] = result
+ }
+ val index = it.getColumnIndex("signature")
+ if (index >= 0) {
+ dataMap["signature"] = it.getString(index)
+ }
+ val indexJson = it.getColumnIndex("event")
+ if (indexJson >= 0) {
+ dataMap["event"] = it.getString(indexJson)
+ }
+
+ // Only short-circuit the Intent if we actually got a result;
+ // an empty/absent result means the signer didn't answer.
+ if (dataMap["signature"].isNullOrEmpty()) {
+ Log.d("getDataFromResolver", "empty result -> fallback to intent")
+ return null
+ }
+ return dataMap
+ }
+ }
+ } catch (e: Exception) {
+ Log.d("contentResolver", e.message ?: "unknown error")
+ return null
+ }
+ return null
+ }
+}
+
+
+private class MethodResultWrapper internal constructor(result: MethodChannel.Result) :
+ MethodChannel.Result {
+ private val methodResult: MethodChannel.Result = result
+ private val handler: Handler = Handler(Looper.getMainLooper())
+
+ override fun success(result: Any?) {
+ handler.post { methodResult.success(result) }
+ }
+
+ override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
+ handler.post { methodResult.error(errorCode, errorMessage, errorDetails) }
+ }
+ override fun notImplemented() {
+ handler.post { methodResult.notImplemented() }
+ }
}
diff --git a/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/Nip55Constants.kt b/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/Nip55Constants.kt
new file mode 100644
index 000000000..5ab3ef5fb
--- /dev/null
+++ b/packages/ndk_flutter/android/src/main/kotlin/relaystr/ndk/Nip55Constants.kt
@@ -0,0 +1,19 @@
+package relaystr.ndk
+
+/// Constants for the NIP-55 "Android Signer Application" protocol.
+///
+/// NIP-55 is a protocol implemented by several external signer apps
+/// (Amber, Primal, Aegis, ...). The wire format below is generic.
+
+const val nostrSignerScheme = "nostrsigner"
+
+const val keyType = "type"
+const val keyCurrentUser = "current_user"
+const val keyUriData = "uri_data"
+const val keyPubKey = "pubKey"
+const val keyId = "id"
+const val keyPermissions = "permissions"
+const val keyResult = "result"
+const val keySignature = "signature"
+const val keyPackage = "package"
+const val keyEvent = "event"
diff --git a/packages/ndk_flutter/lib/data_layer/data_sources/amber_flutter.dart b/packages/ndk_flutter/lib/data_layer/data_sources/amber_flutter.dart
deleted file mode 100644
index 88d18d31d..000000000
--- a/packages/ndk_flutter/lib/data_layer/data_sources/amber_flutter.dart
+++ /dev/null
@@ -1,10 +0,0 @@
-import 'package:amberflutter/amberflutter.dart';
-
-/// amber DS
-class AmberFlutterDS {
- /// the amber obj
- final Amberflutter amber;
-
- /// ...
- AmberFlutterDS(this.amber);
-}
diff --git a/packages/ndk_flutter/lib/data_layer/data_sources/nip55_signer.dart b/packages/ndk_flutter/lib/data_layer/data_sources/nip55_signer.dart
new file mode 100644
index 000000000..be67eab09
--- /dev/null
+++ b/packages/ndk_flutter/lib/data_layer/data_sources/nip55_signer.dart
@@ -0,0 +1,218 @@
+import 'dart:convert';
+
+import 'package:flutter/services.dart';
+import 'package:ndk/ndk.dart';
+
+/// A permission that can be requested when logging in with a NIP-55 external
+/// signer, so the signer can pre-authorize silent (ContentResolver) responses.
+///
+/// See https://github.com/nostr-protocol/nips/blob/master/55.md
+class Nip55Permission {
+ const Nip55Permission({required this.type, this.kind});
+
+ /// The request type to authorize, e.g. `sign_event`, `nip04_encrypt`.
+ final String type;
+
+ /// Optional event kind, only relevant for `sign_event`.
+ final int? kind;
+
+ Map toJson() {
+ return {'type': type, if (kind != null) 'kind': kind};
+ }
+}
+
+/// Result of a NIP-55 login (`get_public_key`).
+class Nip55LoginResult {
+ const Nip55LoginResult({required this.pubkey, this.package});
+
+ /// The user's public key, in hex format.
+ final String pubkey;
+
+ /// The signer app package name (e.g. Amber, Primal), if the signer returned
+ /// it. Used to target the same signer for subsequent requests.
+ final String? package;
+}
+
+/// Dart bridge to a NIP-55 "Android Signer Application".
+///
+/// NIP-55 is a protocol implemented by several external signer apps
+/// (Amber, Primal, Aegis, ...). This class talks to whichever compatible
+/// signer is installed through the native `ndk` method channel
+/// ([DartNdkPlugin]).
+///
+/// Every method resolves to a `Map` that contains (at least) a `signature`
+/// key with the result, mirroring the historical `amberflutter` API.
+class Nip55Signer {
+ /// The method channel shared with the native [DartNdkPlugin].
+ static const MethodChannel _channel = MethodChannel('ndk');
+
+ /// Default permissions requested at login so common operations can be
+ /// answered silently (via ContentResolver) without reopening the signer.
+ static const List defaultPermissions = [
+ Nip55Permission(type: 'sign_event'),
+ Nip55Permission(type: 'nip04_encrypt'),
+ Nip55Permission(type: 'nip04_decrypt'),
+ Nip55Permission(type: 'nip44_encrypt'),
+ Nip55Permission(type: 'nip44_decrypt'),
+ ];
+
+ /// The signer app package (e.g. Amber, Primal), captured at login. Used to
+ /// target the right signer for both the ContentResolver and the Intent.
+ /// When `null`, the native side lets Android route through a compatible
+ /// signer app.
+ final String? package;
+
+ const Nip55Signer({this.package});
+
+ /// Whether a NIP-55 compatible external signer is installed.
+ Future isAppInstalled() async {
+ final data = await _channel.invokeMethod('isAppInstalled');
+ return data ?? false;
+ }
+
+ /// Requests the user's public key (login). Optionally pre-authorizes
+ /// [permissions] so subsequent requests can be answered silently.
+ ///
+ /// Returns the raw signer response map. Most callers want
+ /// [getPublicKeyHex] instead.
+ Future