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,