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 <noreply@anthropic.com>
This commit is contained in:
greenart7c3
2026-10-05 08:49:18 -03:00
co-authored by Claude Opus 5.5
parent c3eb08974e
commit 54f9e2eebd
7 changed files with 135 additions and 8 deletions
+6
View File
@@ -6,6 +6,12 @@
<package android:name="com.greenart7c3.nostrsigner.debug" />
<package android:name="com.greenart7c3.nostrsigner" />
<package android:name="dev.zapstore.app" />
<!-- Lets IntentUtils tell whether a nostrsigner: request was launched by a browser -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
</queries>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -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() {
@@ -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)
@@ -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)
}
}
@@ -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://<package>` 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)
@@ -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<AmberBunkerRequest>,
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))
}
@@ -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,