Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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