diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2738fe79..9cc0544d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt
index a5989e41..e29a1987 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt
@@ -55,7 +55,7 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
Amber.instance.setMainActivity(this)
mainViewModel = MainViewModel(applicationContext)
- intent?.let { mainViewModel.onNewIntent(it, callingPackage) }
+ intent?.let { mainViewModel.onNewIntent(it, callingPackage, referrer) }
setContent {
HttpClientManager.setDefaultUserAgent("Amber/${BuildConfig.VERSION_NAME}")
@@ -141,6 +141,7 @@ class MainActivity : AppCompatActivity() {
intent = remember(intent) { IntentWrapper(intent) },
packageName = packageName,
appName = appName,
+ referrer = referrer,
bunkerRequests = bunkerRequests.value,
navController = NavHostControllerWrapper(navController),
)
@@ -176,7 +177,7 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
- mainViewModel.onNewIntent(intent, callingPackage)
+ mainViewModel.onNewIntent(intent, callingPackage, referrer)
}
override fun onDestroy() {
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt
index fc693a59..a692ce7e 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainViewModel.kt
@@ -3,6 +3,7 @@ package com.greenart7c3.nostrsigner
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
+import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -126,11 +127,12 @@ class MainViewModel(val context: Context) : ViewModel() {
fun onNewIntent(
intent: Intent,
callingPackage: String?,
+ referrer: Uri? = null,
) {
viewModelScope.launch(Dispatchers.IO) {
val account = LocalPreferences.loadFromEncryptedStorage(context)
account?.let { acc ->
- val intentData = IntentUtils.getIntentData(context, intent, callingPackage, intent.getStringExtra("route"), acc)
+ val intentData = IntentUtils.getIntentData(context, intent, callingPackage, intent.getStringExtra("route"), acc, referrer)
if (intentData != null) {
IntentUtils.addAll(listOf(intentData))
addedIntentIds.add(intentData.id)
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt
index 060d121f..3ea5abc6 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerActivity.kt
@@ -68,7 +68,7 @@ class SignerActivity : AppCompatActivity() {
Amber.instance.setMainActivity(this)
mainViewModel = MainViewModel(applicationContext)
- intent?.let { mainViewModel.onNewIntent(it, callingPackage) }
+ intent?.let { mainViewModel.onNewIntent(it, callingPackage, referrer) }
setContent {
NostrSignerTheme {
HttpClientManager.setDefaultUserAgent("Amber/${BuildConfig.VERSION_NAME}")
@@ -180,6 +180,7 @@ class SignerActivity : AppCompatActivity() {
intent = remember(intent) { IntentWrapper(intent) },
packageName = packageName,
appName = appName,
+ referrer = referrer,
bunkerRequests = bunkerRequests.value,
navController = NavHostControllerWrapper(navController),
isExternalRequest = true,
@@ -221,7 +222,7 @@ class SignerActivity : AppCompatActivity() {
super.onNewIntent(intent)
setIntent(intent)
if (::mainViewModel.isInitialized) {
- mainViewModel.onNewIntent(intent, callingPackage)
+ mainViewModel.onNewIntent(intent, callingPackage, referrer)
}
}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt
index eda794fa..e269cd63 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt
@@ -4,6 +4,7 @@ import android.app.Activity.RESULT_OK
import android.content.ClipData
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Browser
import android.widget.Toast
@@ -709,12 +710,70 @@ object IntentUtils {
}
}
+ /**
+ * Returns true when the launching app is a web browser. [referrer] is the value of
+ * `Activity.getReferrer()`: an `http(s)` referrer is the web page itself, an
+ * `android-app://` referrer is the launching app.
+ */
+ fun isBrowserReferrer(context: Context, referrer: Uri?): Boolean = when (referrer?.scheme) {
+ "http", "https" -> true
+ "android-app" -> isBrowserPackage(context, referrer.host)
+ else -> false
+ }
+
+ fun isBrowserPackage(context: Context, packageName: String?): Boolean {
+ if (packageName.isNullOrBlank()) return false
+ val browserIntent = Intent(Intent.ACTION_VIEW, "https://example.com".toUri())
+ .addCategory(Intent.CATEGORY_BROWSABLE)
+ .setPackage(packageName)
+ return try {
+ context.packageManager.queryIntentActivities(browserIntent, PackageManager.MATCH_ALL).isNotEmpty()
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ /**
+ * Decides whether a `nostrsigner:` request carries its parameters in the URI query
+ * (web apps) instead of intent extras (native NIP-55 apps). Browsers don't agree on
+ * how they mark external intents: Chrome adds [Browser.EXTRA_APPLICATION_ID], others
+ * (e.g. DuckDuckGo) only add [Intent.CATEGORY_BROWSABLE] or nothing at all. A `type`
+ * extra means the caller used the native extras API, so only then do we ignore the
+ * weaker signals (BROWSABLE category, browser referrer, `type=` query parameter).
+ */
+ internal fun isWebRequest(
+ hasApplicationIdExtra: Boolean,
+ hasBrowsableCategory: Boolean,
+ referrerIsBrowser: Boolean,
+ typeExtra: String?,
+ dataString: String?,
+ ): Boolean {
+ if (hasApplicationIdExtra) return true
+ if (!typeExtra.isNullOrBlank()) return false
+ return hasBrowsableCategory || referrerIsBrowser || hasTypeQueryParameter(dataString)
+ }
+
+ private fun hasTypeQueryParameter(dataString: String?): Boolean {
+ if (dataString.isNullOrBlank()) return false
+ val decoded = try {
+ URLDecoder.decode(dataString.replace("+", "%2b"), "utf-8")
+ } catch (_: Exception) {
+ dataString
+ }
+ return listOf(dataString, decoded).any { data ->
+ data.substringAfter('?', "")
+ .split('?', '&')
+ .any { it.startsWith("type=") }
+ }
+ }
+
suspend fun getIntentData(
context: Context,
intent: Intent,
packageName: String?,
route: String?,
currentLoggedInAccount: Account,
+ referrer: Uri? = null,
): IntentData? {
try {
if (intent.data == null) {
@@ -748,10 +807,18 @@ object IntentUtils {
if (intent.dataString?.startsWith("nostrconnect:") == true) {
NostrConnectUtils.getIntentFromNostrConnect(intent, localAccount)
- } else if (intent.extras?.getString(Browser.EXTRA_APPLICATION_ID) == null) {
- return getIntentDataFromIntent(context, intent, packageName, route, localAccount)
- } else {
+ } else if (
+ isWebRequest(
+ hasApplicationIdExtra = intent.extras?.getString(Browser.EXTRA_APPLICATION_ID) != null,
+ hasBrowsableCategory = intent.hasCategory(Intent.CATEGORY_BROWSABLE),
+ referrerIsBrowser = isBrowserReferrer(context, referrer),
+ typeExtra = intent.extras?.getString("type"),
+ dataString = intent.dataString,
+ )
+ ) {
return getIntentDataWithoutExtras(context, intent.data?.toString() ?: "", intent, packageName, route, localAccount)
+ } else {
+ return getIntentDataFromIntent(context, intent, packageName, route, localAccount)
}
} catch (e: Exception) {
AmberLog.e(Amber.TAG, "Error parsing intent: ${e.message}", e)
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
index 76a405dc..8b1a1b2f 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
@@ -1,6 +1,7 @@
package com.greenart7c3.nostrsigner.ui
import android.annotation.SuppressLint
+import android.net.Uri
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
@@ -54,6 +55,7 @@ fun AccountScreen(
bunkerRequests: ImmutableList,
navController: NavHostControllerWrapper,
isExternalRequest: Boolean = false,
+ referrer: Uri? = null,
) {
val accountState by accountStateViewModel.accountContent.collectAsState()
val context = LocalContext.current
@@ -89,6 +91,7 @@ fun AccountScreen(
packageName,
it.getStringExtra("route"),
state.account,
+ referrer,
)?.let { intentData ->
IntentUtils.addAll(listOf(intentData))
}
diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/IntentUtilsTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/IntentUtilsTest.kt
index ca3db950..14499128 100644
--- a/app/src/test/java/com/greenart7c3/nostrsigner/service/IntentUtilsTest.kt
+++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/IntentUtilsTest.kt
@@ -192,6 +192,53 @@ class IntentUtilsTest {
assertNull(IntentUtils.isRemembered(signPolicy = 1, permission = null))
}
+ private fun isWebRequest(
+ hasApplicationIdExtra: Boolean = false,
+ hasBrowsableCategory: Boolean = false,
+ referrerIsBrowser: Boolean = false,
+ typeExtra: String? = null,
+ dataString: String? = "nostrsigner:",
+ ) = IntentUtils.isWebRequest(hasApplicationIdExtra, hasBrowsableCategory, referrerIsBrowser, typeExtra, dataString)
+
+ @Test
+ fun `isWebRequest is true for Chrome application id extra`() {
+ assertTrue(isWebRequest(hasApplicationIdExtra = true, typeExtra = "sign_event"))
+ }
+
+ @Test
+ fun `isWebRequest is true for browsable category`() {
+ assertTrue(isWebRequest(hasBrowsableCategory = true))
+ }
+
+ @Test
+ fun `isWebRequest is true when referrer is a browser`() {
+ assertTrue(isWebRequest(referrerIsBrowser = true))
+ }
+
+ @Test
+ fun `isWebRequest is true for type query parameter without browser markers`() {
+ assertTrue(isWebRequest(dataString = "nostrsigner:?type=get_public_key&callbackUrl=https://example.com/?event="))
+ assertTrue(isWebRequest(dataString = "nostrsigner:%7B%22kind%22%3A1%7D?compressionType=none&type=sign_event"))
+ }
+
+ @Test
+ fun `isWebRequest is true for url encoded type query parameter`() {
+ assertTrue(isWebRequest(dataString = "nostrsigner:abc%3Ftype%3Dnip04_encrypt%26pubkey%3D123"))
+ }
+
+ @Test
+ fun `isWebRequest is false for native app with type extra`() {
+ assertFalse(isWebRequest(typeExtra = "sign_event", dataString = "nostrsigner:{\"content\":\"what?type=x\"}"))
+ assertFalse(isWebRequest(hasBrowsableCategory = true, referrerIsBrowser = true, typeExtra = "sign_event"))
+ }
+
+ @Test
+ fun `isWebRequest is false without markers or query`() {
+ assertFalse(isWebRequest())
+ assertFalse(isWebRequest(dataString = null))
+ assertFalse(isWebRequest(dataString = "nostrsigner:?pubkey=123"))
+ }
+
// Helper to build a minimal ApplicationPermissionsEntity for isRemembered tests
private fun permissionWith(
acceptable: Boolean = true,