Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions packages/ndk_flutter/android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
package android

group = 'com.sebdeveloper6952.amberflutter.amberflutter'
group = 'relaystr.ndk'
version = '1.0-SNAPSHOT'

buildscript {
Expand All @@ -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"
}
}

Expand All @@ -27,7 +25,7 @@ apply plugin: 'kotlin-android'

android {
if (project.android.hasProperty("namespace")) {
namespace = 'relastr.ndk'
namespace = 'relaystr.ndk'
}

compileSdk = 36
Expand Down
4 changes: 1 addition & 3 deletions packages/ndk_flutter/android/settings.gradle
Original file line number Diff line number Diff line change
@@ -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'
8 changes: 8 additions & 0 deletions packages/ndk_flutter/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required on Android 11+ to detect and launch a NIP-55 external signer
(Amber, Primal, Aegis, ...) via the nostrsigner: scheme. -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="nostrsigner" />
</intent>
</queries>
</manifest>
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
}
Comment on lines +61 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Complete the MethodChannel result on every exit path.

On Line 63, Line 116, and the non-OK branch in Line 141+, execution can exit without calling success/error/notImplemented, which leaves the Dart caller hanging indefinitely.

Suggested fix
                 val paramsMap = call.arguments as? HashMap<*, *>
                 if (paramsMap == null) {
                     Log.d("onMethodCall", "paramsMap is null")
+                    _result.error("invalid_args", "Expected map arguments", null)
                     return
                 }
...
-                try {
-                    _activity?.startActivityForResult(intent, _intentRequestCode)
+                val activity = _activity
+                if (activity == null) {
+                    _result.success(HashMap<String, String?>())
+                    return
+                }
+                try {
+                    activity.startActivityForResult(intent, _intentRequestCode)
                 } catch (e: Exception) {
                     Log.d("onMethodCall", "startActivityForResult failed for '$signerPackage': ${e.message}")
                     _result.success(HashMap<String, String?>())
                 }
...
     override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?): Boolean {
         if (requestCode == _intentRequestCode) {
             if (resultCode == Activity.RESULT_OK && intent != null) {
                 ...
                 _result.success(dataMap)
                 return true
             }
+            _result.success(HashMap<String, String?>())
+            return true
         }
         return false
     }

Also applies to: 116-120, 141-167


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<String, String?>())
}
}

"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)
} || isExternalSignerInstalled(_context)
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<String, String?> = 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) {
Expand All @@ -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<out String>,
resolver: ContentResolver,
signerPackage: String,
): HashMap<String, String?>? {
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<String, String?> = 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() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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, ...). Amber is only the reference app used for
/// installation detection / linking; the wire format below is generic.

const val nostrSignerScheme = "nostrsigner"

/// Reference external signer package (Amber) used as install-detection fallback.
const val referenceSignerPackage = "com.greenart7c3.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"

This file was deleted.

Loading
Loading