From 54f9e2eebd50af034ab77b40ff0edee5431eb1e1 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Mon, 5 Oct 2026 08:49:18 -0300 Subject: [PATCH] Detect web requests from browsers other than Chrome IntentUtils only treated a nostrsigner: request as coming from a web page when Chrome's Browser.EXTRA_APPLICATION_ID extra was present. Browsers like DuckDuckGo don't set it, so their URL-encoded requests were parsed as intent extras and failed with "Unknown signer type". The request is now parsed from the URI when Chrome's extra is present, or, if there is no native `type` extra, when the intent has the BROWSABLE category, the referrer is a browser (http(s) referrer or a package that handles https links), or the URI carries a `type=` query parameter. Co-Authored-By: Claude Opus 5.5 --- app/src/main/AndroidManifest.xml | 6 ++ .../greenart7c3/nostrsigner/MainActivity.kt | 5 +- .../greenart7c3/nostrsigner/MainViewModel.kt | 4 +- .../greenart7c3/nostrsigner/SignerActivity.kt | 5 +- .../nostrsigner/service/IntentUtils.kt | 73 ++++++++++++++++++- .../nostrsigner/ui/AccountScreen.kt | 3 + .../nostrsigner/service/IntentUtilsTest.kt | 47 ++++++++++++ 7 files changed, 135 insertions(+), 8 deletions(-) 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,