Commit 45ef5233 authored by Roger Yang's avatar Roger Yang
Browse files

For #7614: Simplify app link query for external application

parent 27dd1be4
......@@ -8,15 +8,16 @@ import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.SystemClock
import android.provider.Browser.EXTRA_APPLICATION_ID
import androidx.annotation.VisibleForTesting
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.net.isHttpOrHttps
import java.net.URISyntaxException
import java.util.UUID
private const val EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url"
private const val MARKET_INTENT_URI_PACKAGE_PREFIX = "market://details?id="
......@@ -39,19 +40,17 @@ internal const val APP_LINKS_CACHE_INTERVAL = 30 * 1000L // 30 seconds
* @param context Context the feature is associated with.
* @param launchInApp If {true} then launch app links in third party app(s). Default to false because
* of security concerns.
* @param unguessableWebUrl URL is not likely to be opened by a native app but will fallback to a browser.
* @param alwaysDeniedSchemes List of schemes that will never be opened in a third-party app.
*/
class AppLinksUseCases(
private val context: Context,
private val launchInApp: () -> Boolean = { false },
private val unguessableWebUrl: String = "https://${UUID.randomUUID()}.net",
private val alwaysDeniedSchemes: Set<String> = ALWAYS_DENY_SCHEMES
) {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun findActivities(intent: Intent): List<ResolveInfo> {
return context.packageManager
.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) ?: emptyList()
.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER) ?: emptyList()
}
private fun findDefaultActivity(intent: Intent): ResolveInfo? {
......@@ -66,31 +65,6 @@ class AppLinksUseCases(
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun findExcludedPackages(randomWebURLString: String): Set<String> {
val intent = safeParseUri(randomWebURLString, 0) ?: return emptySet()
// We generate a URL is not likely to be opened by a native app
// but will fallback to a browser.
// In this way, we're looking for only the browsers — including us.
return findActivities(intent.addCategory(Intent.CATEGORY_BROWSABLE))
.map { it.activityInfo.packageName }
.toHashSet()
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getBrowserPackageNames(): Set<String> {
val currentTimeStamp = SystemClock.elapsedRealtime()
val cache = browserNamesCache
if (cache != null && currentTimeStamp <= cache.cacheTimeStamp + APP_LINKS_CACHE_INTERVAL) {
return cache.cachedBrowserNames
}
val browserNames = findExcludedPackages(unguessableWebUrl)
browserNamesCache = AppLinkBrowserNamesCache(currentTimeStamp, browserNames)
return browserNames
}
/**
* Parse a URL and check if it can be handled by an app elsewhere on the Android device.
* If that app is not available, then a market place intent is also provided.
......@@ -122,9 +96,11 @@ class AppLinksUseCases(
val redirectData = createBrowsableIntents(url)
val isAppIntentHttpOrHttps = redirectData.appIntent?.data?.isHttpOrHttps ?: false
val isEngineSupportedScheme = ENGINE_SUPPORTED_SCHEMES.contains(Uri.parse(url).scheme)
val appIntent = when {
redirectData.resolveInfo == null -> null
redirectData.resolveInfo == null && isEngineSupportedScheme -> null
redirectData.resolveInfo == null && redirectData.marketplaceIntent != null -> null
includeHttpAppLinks && (ignoreDefaultBrowser ||
(redirectData.appIntent != null && isDefaultBrowser(redirectData.appIntent))) -> null
includeHttpAppLinks && isAppIntentHttpOrHttps -> redirectData.appIntent
......@@ -147,22 +123,8 @@ class AppLinksUseCases(
private fun isDefaultBrowser(intent: Intent) =
findDefaultActivity(intent)?.activityInfo?.packageName == context.packageName
private fun getNonBrowserActivities(intent: Intent): List<ResolveInfo> {
return findActivities(intent)
.map { it.activityInfo.packageName to it }
.filter { intent.`package` == it.first || !getBrowserPackageNames().contains(it.first) }
.map { it.second }
}
private fun createBrowsableIntents(url: String): RedirectData {
val intent = safeParseUri(url, 0)
if (intent != null && intent.action == Intent.ACTION_VIEW) {
intent.addCategory(Intent.CATEGORY_BROWSABLE)
intent.component = null
intent.selector = null
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
val intent = safeParseUri(url, Intent.URI_INTENT_SCHEME)
val fallbackIntent = intent?.getStringExtra(EXTRA_BROWSER_FALLBACK_URL)?.let {
Intent.parseUri(it, 0)
}
......@@ -185,8 +147,20 @@ class AppLinksUseCases(
else -> intent
}
appIntent?.let {
it.addCategory(Intent.CATEGORY_BROWSABLE)
it.component = null
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK
it.selector?.addCategory(Intent.CATEGORY_BROWSABLE)
it.selector?.component = null
it.putExtra(EXTRA_APPLICATION_ID, context.packageName)
}
val resolveInfoList = appIntent?.let {
getNonBrowserActivities(it)
findActivities(appIntent).filter {
it.filter != null &&
!(it.filter.countDataPaths() == 0 && it.filter.countDataAuthorities() == 0)
}
}
val resolveInfo = resolveInfoList?.firstOrNull()
......@@ -228,6 +202,9 @@ class AppLinksUseCases(
} catch (e: ActivityNotFoundException) {
failedToLaunchAction()
Logger.error("failed to start third party app activity", e)
} catch (e: SecurityException) {
failedToLaunchAction()
Logger.error("failed to start third party app activity", e)
}
}
}
......@@ -268,23 +245,15 @@ class AppLinksUseCases(
var cachedAppLinkRedirect: AppLinkRedirect
)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal data class AppLinkBrowserNamesCache(
var cacheTimeStamp: Long,
var cachedBrowserNames: Set<String>
)
companion object {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var redirectCache: AppLinkRedirectCache? = null
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var browserNamesCache: AppLinkBrowserNamesCache? = null
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
// list of scheme from https://searchfox.org/mozilla-central/source/netwerk/build/components.conf
internal val ENGINE_SUPPORTED_SCHEMES: Set<String> = setOf("about", "data", "file", "ftp", "http",
"https", "moz-extension", "moz-safe-about", "resource", "view-source", "ws", "wss")
internal val ALWAYS_DENY_SCHEMES: Set<String> = setOf("file", "javascript", "data", "about")
}
}
......@@ -7,6 +7,7 @@ package mozilla.components.feature.app.links
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
......@@ -17,7 +18,6 @@ import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.whenever
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
......@@ -53,11 +53,12 @@ class AppLinksUseCasesTest {
private val layerUrl = "https://exmaple.com"
private val layerPackage = "com.example.app"
private val layerActivity = "com.example2.app.intentActivity"
private val mailUrl = "mailto:example@example.com"
private val mailPackage = "com.mail.app"
@Before
fun setup() {
AppLinksUseCases.redirectCache = null
AppLinksUseCases.browserNamesCache = null
}
private fun createContext(vararg urlToPackages: Triple<String, String, String>, default: Boolean = false): Context {
......@@ -77,6 +78,10 @@ class AppLinksUseCasesTest {
val resolveInfo = ResolveInfo().apply {
labelRes = android.R.string.ok
activityInfo = info
if (pkgName != browserPackage && pkgName != mailPackage) {
filter = IntentFilter()
filter.addDataPath("test", 0)
}
}
packageManager.addResolveInfoForIntent(intent, resolveInfo)
packageManager.addDrawableResolution(pkgName, android.R.drawable.btn_default, mock())
......@@ -96,7 +101,7 @@ class AppLinksUseCasesTest {
val context = createContext()
val subject = AppLinksUseCases(context, { true })
val redirect = subject.interceptedAppLinkRedirect("test://test#Intent;")
assertFalse(redirect.isRedirect())
assert(redirect.isRedirect())
}
@Test
......@@ -191,9 +196,9 @@ class AppLinksUseCasesTest {
}
@Test
fun `A URL that matches only excluded packages is not an app link`() {
fun `A URL that matches only general packages is not an app link`() {
val context = createContext(Triple(appUrl, browserPackage, ""), Triple(browserUrl, browserPackage, ""))
val subject = AppLinksUseCases(context, { true }, unguessableWebUrl = browserUrl)
val subject = AppLinksUseCases(context, { true })
val redirect = subject.interceptedAppLinkRedirect(appUrl)
assertFalse(redirect.isRedirect())
......@@ -203,9 +208,9 @@ class AppLinksUseCasesTest {
}
@Test
fun `A URL that also matches excluded packages is an app link`() {
fun `A URL that also matches both specialized and general packages is an app link`() {
val context = createContext(Triple(appUrl, appPackage, ""), Triple(appUrl, browserPackage, ""), Triple(browserUrl, browserPackage, ""))
val subject = AppLinksUseCases(context, { true }, unguessableWebUrl = browserUrl)
val subject = AppLinksUseCases(context, { true })
val redirect = subject.interceptedAppLinkRedirect(appUrl)
assertTrue(redirect.isRedirect())
......@@ -216,22 +221,21 @@ class AppLinksUseCasesTest {
}
@Test
fun `A URL that only matches default activity is not an app link`() {
val context = createContext(Triple(appUrl, appPackage, ""), default = true)
fun `A URL that also matches general packages but the scheme is not supported is an app link`() {
val context = createContext(Triple(mailUrl, mailPackage, ""))
val subject = AppLinksUseCases(context, { true })
val menuRedirect = subject.appLinkRedirect(appUrl)
assertFalse(menuRedirect.hasExternalApp())
val redirect = subject.interceptedAppLinkRedirect(mailUrl)
assertTrue(redirect.isRedirect())
}
@Test
fun `A list of browser package names can be generated if not supplied`() {
val unguessable = "https://unguessable-test-url.com"
val context = createContext(Triple(unguessable, browserPackage, ""))
val subject = AppLinksUseCases(context, unguessableWebUrl = unguessable)
fun `A URL that only matches default activity is not an app link`() {
val context = createContext(Triple(appUrl, appPackage, ""), default = true)
val subject = AppLinksUseCases(context, { true })
subject.appLinkRedirect(unguessable)
assertEquals(subject.getBrowserPackageNames(), setOf(browserPackage))
val menuRedirect = subject.appLinkRedirect(appUrl)
assertFalse(menuRedirect.hasExternalApp())
}
@Test
......@@ -353,34 +357,6 @@ class AppLinksUseCasesTest {
}
}
@Test
fun `AppLinksUsecases uses browser names cache`() {
val testDispatcher = TestCoroutineDispatcher()
TestCoroutineScope(testDispatcher).launch {
val context = createContext(Triple(appUrl, appPackage, ""))
var subject = AppLinksUseCases(context, { true })
whenever(subject.findExcludedPackages(any())).thenReturn(emptySet())
var browserNames = subject.getBrowserPackageNames()
assertTrue(browserNames.isEmpty())
val timestamp = AppLinksUseCases.browserNamesCache?.cacheTimeStamp
whenever(subject.findExcludedPackages(any())).thenReturn(setOf(appPackage))
testDispatcher.advanceTimeBy(APP_LINKS_CACHE_INTERVAL / 2)
subject = AppLinksUseCases(context, { true })
browserNames = subject.getBrowserPackageNames()
assertTrue(browserNames.isEmpty())
assert(timestamp == AppLinksUseCases.browserNamesCache?.cacheTimeStamp)
testDispatcher.advanceTimeBy(APP_LINKS_CACHE_INTERVAL / 2 + 1)
subject = AppLinksUseCases(context, { true })
browserNames = subject.getBrowserPackageNames()
assertFalse(browserNames.isEmpty())
assertFalse(browserNames.contains(appPackage))
assert(timestamp != AppLinksUseCases.browserNamesCache?.cacheTimeStamp)
}
}
@Test
fun `OpenAppLinkRedirect should not try to open files`() {
val context = spy(createContext())
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment