summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/service/digitalassetlinks/src
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/android-components/components/service/digitalassetlinks/src')
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt128
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt41
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt10
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt29
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt21
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt25
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt16
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt25
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt120
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt56
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt20
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt20
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt143
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt35
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt170
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt229
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt356
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt89
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker1
-rw-r--r--mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties1
21 files changed, 1539 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/digitalassetlinks/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt
new file mode 100644
index 0000000000..bd4e497d34
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks
+
+import android.annotation.SuppressLint
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.pm.Signature
+import android.os.Build
+import android.os.Build.VERSION.SDK_INT
+import androidx.annotation.VisibleForTesting
+import mozilla.components.support.utils.ext.getPackageInfoCompat
+import java.io.ByteArrayInputStream
+import java.security.MessageDigest
+import java.security.NoSuchAlgorithmException
+import java.security.cert.CertificateEncodingException
+import java.security.cert.CertificateException
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+
+/**
+ * Get the SHA256 certificate for an installed Android app.
+ */
+class AndroidAssetFinder {
+
+ /**
+ * Converts the Android App with the given package name into an asset descriptor
+ * by computing the SHA256 certificate for each signing signature.
+ *
+ * The output is lazily computed. If desired, only the first item from the sequence could
+ * be used and other certificates (if any) will not be computed.
+ */
+ fun getAndroidAppAsset(
+ packageName: String,
+ packageManager: PackageManager,
+ ): Sequence<AssetDescriptor.Android> {
+ return packageManager.getSignatures(packageName).asSequence()
+ .mapNotNull { signature -> getCertificateSHA256Fingerprint(signature) }
+ .map { fingerprint -> AssetDescriptor.Android(packageName, fingerprint) }
+ }
+
+ /**
+ * Computes the SHA256 certificate for the given package name. The app with the given package
+ * name has to be installed on device. The output will be a 30 long HEX string with : between
+ * each value.
+ * @return The SHA256 certificate for the package name.
+ */
+ @VisibleForTesting
+ internal fun getCertificateSHA256Fingerprint(signature: Signature): String? {
+ val input = ByteArrayInputStream(signature.toByteArray())
+ return try {
+ val certificate = CertificateFactory.getInstance("X509").generateCertificate(input) as X509Certificate
+ byteArrayToHexString(MessageDigest.getInstance("SHA256").digest(certificate.encoded))
+ } catch (e: CertificateEncodingException) {
+ // Certificate type X509 encoding failed
+ null
+ } catch (e: CertificateException) {
+ throw AssertionError("Should not happen", e)
+ } catch (e: NoSuchAlgorithmException) {
+ throw AssertionError("Should not happen", e)
+ }
+ }
+
+ @Suppress("PackageManagerGetSignatures")
+ // https://stackoverflow.com/questions/39192844/android-studio-warning-when-using-packagemanager-get-signatures
+ private fun PackageManager.getSignatures(packageName: String): Array<Signature> {
+ val packageInfo = getPackageSignatureInfo(packageName) ?: return emptyArray()
+
+ return if (SDK_INT >= Build.VERSION_CODES.P) {
+ val signingInfo = packageInfo.signingInfo
+ if (signingInfo.hasMultipleSigners()) {
+ signingInfo.apkContentsSigners
+ } else {
+ val history = signingInfo.signingCertificateHistory
+ if (history.isEmpty()) {
+ emptyArray()
+ } else {
+ arrayOf(history.first())
+ }
+ }
+ } else {
+ @Suppress("Deprecation")
+ packageInfo.signatures
+ }
+ }
+
+ @SuppressLint("PackageManagerGetSignatures")
+ private fun PackageManager.getPackageSignatureInfo(packageName: String): PackageInfo? {
+ return try {
+ if (SDK_INT >= Build.VERSION_CODES.P) {
+ getPackageInfoCompat(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
+ } else {
+ @Suppress("Deprecation")
+ getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
+ }
+ } catch (e: PackageManager.NameNotFoundException) {
+ // Will return null if there is no package found.
+ return null
+ }
+ }
+
+ /**
+ * Converts a byte array to hex string with : inserted between each element.
+ * @param bytes The array to be converted.
+ * @return A string with two letters representing each byte and : in between.
+ */
+ @Suppress("MagicNumber")
+ @VisibleForTesting
+ internal fun byteArrayToHexString(bytes: ByteArray): String {
+ val hexString = StringBuilder(bytes.size * HEX_STRING_SIZE - 1)
+ var v: Int
+ for (j in bytes.indices) {
+ v = bytes[j].toInt() and 0xFF
+ hexString.append(HEX_CHAR_LOOKUP[v.ushr(HALF_BYTE)])
+ hexString.append(HEX_CHAR_LOOKUP[v and 0x0F])
+ if (j < bytes.lastIndex) hexString.append(':')
+ }
+ return hexString.toString()
+ }
+
+ companion object {
+ private const val HALF_BYTE = 4
+ private const val HEX_STRING_SIZE = "0F:".length
+ private val HEX_CHAR_LOOKUP = "0123456789ABCDEF".toCharArray()
+ }
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt
new file mode 100644
index 0000000000..a3f81799e2
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks
+
+/**
+ * Uniquely identifies an asset.
+ *
+ * A digital asset is an identifiable and addressable online entity that typically provides some
+ * service or content.
+ */
+sealed class AssetDescriptor {
+
+ /**
+ * A web site asset descriptor.
+ * @property site URI representing the domain of the website.
+ * @sample
+ * AssetDescriptor.Web(
+ * site = "https://{fully-qualified domain}{:optional port}"
+ * )
+ */
+ data class Web(
+ val site: String,
+ ) : AssetDescriptor()
+
+ /**
+ * An Android app asset descriptor.
+ * @property packageName Package name for the Android app.
+ * @property sha256CertFingerprint A colon-separated hex string.
+ * @sample
+ * AssetDescriptor.Android(
+ * packageName = "com.costingtons.app",
+ * sha256CertFingerprint = "A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
+ * )
+ */
+ data class Android(
+ val packageName: String,
+ val sha256CertFingerprint: String,
+ ) : AssetDescriptor()
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt
new file mode 100644
index 0000000000..e1860c0463
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks
+
+import java.util.concurrent.TimeUnit
+
+@Suppress("MagicNumber", "TopLevelPropertyNaming")
+internal val TIMEOUT = 3L to TimeUnit.SECONDS
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt
new file mode 100644
index 0000000000..2cc4c32429
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks
+
+/**
+ * Describes the nature of a statement, and consists of a kind and a detail.
+ * @property kindAndDetail Kind and detail, separated by a slash character.
+ */
+enum class Relation(val kindAndDetail: String) {
+
+ /**
+ * Grants the target permission to retrieve sign-in credentials stored for the source.
+ * For App -> Web transitions, requests the app to use the declared origin to be used as origin
+ * for the client app in the web APIs context.
+ */
+ USE_AS_ORIGIN("delegate_permission/common.use_as_origin"),
+
+ /**
+ * Requests the ability to handle all URLs from a given origin.
+ */
+ HANDLE_ALL_URLS("delegate_permission/common.handle_all_urls"),
+
+ /**
+ * Grants the target permission to retrieve sign-in credentials stored for the source.
+ */
+ GET_LOGIN_CREDS("delegate_permission/common.get_login_creds"),
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt
new file mode 100644
index 0000000000..7134b6cd68
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks
+
+/**
+ * Verifies that a source is linked to a target.
+ */
+interface RelationChecker {
+
+ /**
+ * Performs a check to ensure a directional relationships exists between the specified
+ * [source] and [target] assets. The relationship must match the [relation] type given.
+ */
+ fun checkRelationship(
+ source: AssetDescriptor.Web,
+ relation: Relation,
+ target: AssetDescriptor,
+ ): Boolean
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt
new file mode 100644
index 0000000000..5686e74375
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks
+
+/**
+ * Represents a statement that can be found in an assetlinks.json file.
+ */
+sealed class StatementResult
+
+/**
+ * Entry in a Digital Asset Links statement file.
+ */
+data class Statement(
+ val relation: Relation,
+ val target: AssetDescriptor,
+) : StatementResult()
+
+/**
+ * Include statements point to another Digital Asset Links statement file.
+ */
+data class IncludeStatement(
+ val include: String,
+) : StatementResult()
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt
new file mode 100644
index 0000000000..435332635f
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks
+
+/**
+ * Lists all statements made by a given source.
+ */
+interface StatementListFetcher {
+
+ /**
+ * Retrieves a list of all statements from a given [source].
+ */
+ fun listStatements(source: AssetDescriptor.Web): Sequence<Statement>
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt
new file mode 100644
index 0000000000..1ab14d687e
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.api
+
+import org.json.JSONObject
+
+/**
+ * @property linked True if the assets specified in the request are linked by the relation specified in the request.
+ * @property maxAge From serving time, how much longer the response should be considered valid barring further updates.
+ * Formatted as a duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s".
+ * @property debug Human-readable message containing information about the response.
+ */
+data class CheckAssetLinksResponse(
+ val linked: Boolean,
+ val maxAge: String,
+ val debug: String,
+)
+
+internal fun parseCheckAssetLinksJson(json: JSONObject) = CheckAssetLinksResponse(
+ linked = json.getBoolean("linked"),
+ maxAge = json.getString("maxAge"),
+ debug = json.optString("debugString"),
+)
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt
new file mode 100644
index 0000000000..c1a09e06c2
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt
@@ -0,0 +1,120 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.api
+
+import android.net.Uri
+import androidx.annotation.VisibleForTesting
+import androidx.core.net.toUri
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.Request
+import mozilla.components.service.digitalassetlinks.AssetDescriptor
+import mozilla.components.service.digitalassetlinks.Relation
+import mozilla.components.service.digitalassetlinks.RelationChecker
+import mozilla.components.service.digitalassetlinks.Statement
+import mozilla.components.service.digitalassetlinks.StatementListFetcher
+import mozilla.components.service.digitalassetlinks.TIMEOUT
+import mozilla.components.service.digitalassetlinks.ext.parseJsonBody
+import mozilla.components.service.digitalassetlinks.ext.safeFetch
+import mozilla.components.support.ktx.kotlin.sanitizeURL
+import org.json.JSONObject
+
+/**
+ * Digital Asset Links allows any caller to check pre declared relationships between
+ * two assets which can be either web domains or native applications.
+ * This class checks for a specific relationship declared by two assets via the online API.
+ */
+class DigitalAssetLinksApi(
+ private val httpClient: Client,
+ private val apiKey: String?,
+) : RelationChecker, StatementListFetcher {
+
+ override fun checkRelationship(
+ source: AssetDescriptor.Web,
+ relation: Relation,
+ target: AssetDescriptor,
+ ): Boolean {
+ val request = buildCheckApiRequest(source, relation, target)
+ val response = httpClient.safeFetch(request)
+ val parsed = response?.parseJsonBody { body ->
+ parseCheckAssetLinksJson(JSONObject(body))
+ }
+ return parsed?.linked == true
+ }
+
+ override fun listStatements(source: AssetDescriptor.Web): Sequence<Statement> {
+ val request = buildListApiRequest(source)
+ val response = httpClient.safeFetch(request)
+ val parsed = response?.parseJsonBody { body ->
+ parseListStatementsJson(JSONObject(body))
+ }
+ return parsed?.statements.orEmpty().asSequence()
+ }
+
+ private fun apiUrlBuilder(path: String) = BASE_URL.toUri().buildUpon()
+ .encodedPath(path)
+ .appendQueryParameter("prettyPrint", false.toString())
+ .appendQueryParameter("key", apiKey)
+
+ /**
+ * Returns a [Request] used to check whether the specified (directional) relationship exists
+ * between the specified source and target assets.
+ *
+ * https://developers.google.com/digital-asset-links/reference/rest/v1/assetlinks/check
+ */
+ @VisibleForTesting
+ internal fun buildCheckApiRequest(
+ source: AssetDescriptor,
+ relation: Relation,
+ target: AssetDescriptor,
+ ): Request {
+ val uriBuilder = apiUrlBuilder(CHECK_PATH)
+ .appendQueryParameter("relation", relation.kindAndDetail)
+
+ // source and target follow the same format, so re-use the query logic for both.
+ uriBuilder.appendAssetAsQuery(source, "source")
+ uriBuilder.appendAssetAsQuery(target, "target")
+
+ return Request(
+ url = uriBuilder.build().toString().sanitizeURL(),
+ method = Request.Method.GET,
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ )
+ }
+
+ @VisibleForTesting
+ internal fun buildListApiRequest(source: AssetDescriptor): Request {
+ val uriBuilder = apiUrlBuilder(LIST_PATH)
+ uriBuilder.appendAssetAsQuery(source, "source")
+
+ return Request(
+ url = uriBuilder.build().toString().sanitizeURL(),
+ method = Request.Method.GET,
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ )
+ }
+
+ private fun Uri.Builder.appendAssetAsQuery(asset: AssetDescriptor, prefix: String) {
+ when (asset) {
+ is AssetDescriptor.Web -> {
+ appendQueryParameter("$prefix.web.site", asset.site)
+ }
+ is AssetDescriptor.Android -> {
+ appendQueryParameter("$prefix.androidApp.packageName", asset.packageName)
+ appendQueryParameter(
+ "$prefix.androidApp.certificate.sha256Fingerprint",
+ asset.sha256CertFingerprint,
+ )
+ }
+ }
+ }
+
+ companion object {
+ private const val BASE_URL = "https://digitalassetlinks.googleapis.com"
+ private const val CHECK_PATH = "/v1/assetlinks:check"
+ private const val LIST_PATH = "/v1/statements:list"
+ }
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt
new file mode 100644
index 0000000000..4a9106be9f
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.api
+
+import mozilla.components.service.digitalassetlinks.AssetDescriptor
+import mozilla.components.service.digitalassetlinks.Relation
+import mozilla.components.service.digitalassetlinks.Statement
+import mozilla.components.support.ktx.android.org.json.asSequence
+import org.json.JSONObject
+
+/**
+ * @property statements A list of all the matching statements that have been found.
+ * @property maxAge From serving time, how much longer the response should be considered valid barring further updates.
+ * Formatted as a duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s".
+ * @property debug Human-readable message containing information about the response.
+ */
+data class ListStatementsResponse(
+ val statements: List<Statement>,
+ val maxAge: String,
+ val debug: String,
+)
+
+internal fun parseListStatementsJson(json: JSONObject): ListStatementsResponse {
+ val statements = json.getJSONArray("statements")
+ .asSequence { i -> getJSONObject(i) }
+ .mapNotNull { statementJson ->
+ val relationString = statementJson.getString("relation")
+ val relation = Relation.values().find { relationString == it.kindAndDetail }
+
+ val targetJson = statementJson.getJSONObject("target")
+ val webJson = targetJson.optJSONObject("web")
+ val androidJson = targetJson.optJSONObject("androidApp")
+ val target = when {
+ webJson != null -> AssetDescriptor.Web(site = webJson.getString("site"))
+ androidJson != null -> AssetDescriptor.Android(
+ packageName = androidJson.getString("packageName"),
+ sha256CertFingerprint = androidJson.getJSONObject("certificate")
+ .getString("sha256Fingerprint"),
+ )
+ else -> null
+ }
+
+ if (relation != null && target != null) {
+ Statement(relation, target)
+ } else {
+ null
+ }
+ }
+ return ListStatementsResponse(
+ statements = statements.toList(),
+ maxAge = json.getString("maxAge"),
+ debug = json.optString("debugString"),
+ )
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt
new file mode 100644
index 0000000000..cbdcf5e14a
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.ext
+
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import mozilla.components.concept.fetch.Response.Companion.SUCCESS
+import java.io.IOException
+
+internal fun Client.safeFetch(request: Request): Response? {
+ return try {
+ val response = fetch(request)
+ if (response.status == SUCCESS) response else null
+ } catch (e: IOException) {
+ null
+ }
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt
new file mode 100644
index 0000000000..36a1d82afc
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.ext
+
+import mozilla.components.concept.fetch.Response
+import org.json.JSONException
+
+/**
+ * Safely parse a JSON [Response] returned by an API.
+ */
+inline fun <T> Response.parseJsonBody(crossinline parser: (String) -> T): T? {
+ val responseBody = use { body.string() }
+ return try {
+ parser(responseBody)
+ } catch (e: JSONException) {
+ null
+ }
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt
new file mode 100644
index 0000000000..798650af34
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt
@@ -0,0 +1,143 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.local
+
+import androidx.core.net.toUri
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE
+import mozilla.components.concept.fetch.Headers.Values.CONTENT_TYPE_APPLICATION_JSON
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import mozilla.components.service.digitalassetlinks.AssetDescriptor
+import mozilla.components.service.digitalassetlinks.IncludeStatement
+import mozilla.components.service.digitalassetlinks.Relation
+import mozilla.components.service.digitalassetlinks.Statement
+import mozilla.components.service.digitalassetlinks.StatementListFetcher
+import mozilla.components.service.digitalassetlinks.StatementResult
+import mozilla.components.service.digitalassetlinks.TIMEOUT
+import mozilla.components.service.digitalassetlinks.ext.safeFetch
+import mozilla.components.support.ktx.android.org.json.asSequence
+import mozilla.components.support.ktx.kotlin.sanitizeURL
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+
+/**
+ * Builds a list of statements by sending HTTP requests to the given website.
+ */
+class StatementApi(private val httpClient: Client) : StatementListFetcher {
+
+ /**
+ * Lazily list all the statements in the given [source] website.
+ * If include statements are present, they will be resolved lazily.
+ */
+ override fun listStatements(source: AssetDescriptor.Web): Sequence<Statement> {
+ val url = source.site.toUri().buildUpon()
+ .path("/.well-known/assetlinks.json")
+ .build()
+ .toString()
+ return getWebsiteStatementList(url, seenSoFar = mutableSetOf())
+ }
+
+ /**
+ * Recursively download all the website statements.
+ * @param assetLinksUrl URL to download.
+ * @param seenSoFar URLs that have been downloaded already.
+ * Used to prevent infinite loops.
+ */
+ private fun getWebsiteStatementList(
+ assetLinksUrl: String,
+ seenSoFar: MutableSet<String>,
+ ): Sequence<Statement> {
+ if (assetLinksUrl in seenSoFar) {
+ return emptySequence()
+ } else {
+ seenSoFar.add(assetLinksUrl)
+ }
+
+ val request = Request(
+ url = assetLinksUrl.sanitizeURL(),
+ method = Request.Method.GET,
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ )
+ val response = httpClient.safeFetch(request)?.let { res ->
+ val contentTypes = res.headers.getAll(CONTENT_TYPE)
+ if (contentTypes.any { it.contains(CONTENT_TYPE_APPLICATION_JSON, ignoreCase = true) }) {
+ res
+ } else {
+ res.close()
+ null
+ }
+ }
+
+ val statements = response?.let { parseStatementResponse(response) }.orEmpty()
+ return sequence<Statement> {
+ val includeStatements = mutableListOf<IncludeStatement>()
+ // Yield all statements that have already been downloaded
+ statements.forEach { statement ->
+ when (statement) {
+ is Statement -> yield(statement)
+ is IncludeStatement -> includeStatements.add(statement)
+ }
+ }
+ // Recursively download include statements
+ yieldAll(
+ includeStatements.asSequence().flatMap {
+ getWebsiteStatementList(it.include, seenSoFar)
+ },
+ )
+ }
+ }
+
+ /**
+ * Parse the JSON [Response] returned by the website.
+ */
+ private fun parseStatementResponse(response: Response): List<StatementResult> {
+ val responseBody = response.use { response.body.string() }
+ return try {
+ val responseJson = JSONArray(responseBody)
+ parseStatementListJson(responseJson)
+ } catch (e: JSONException) {
+ emptyList()
+ }
+ }
+
+ private fun parseStatementListJson(json: JSONArray): List<StatementResult> {
+ return json.asSequence { i -> getJSONObject(i) }
+ .flatMap { parseStatementJson(it) }
+ .toList()
+ }
+
+ private fun parseStatementJson(json: JSONObject): Sequence<StatementResult> {
+ val include = json.optString("include")
+ if (include.isNotEmpty()) {
+ return sequenceOf(IncludeStatement(include))
+ }
+
+ val relationTypes = Relation.values()
+ val relations = json.getJSONArray("relation")
+ .asSequence { i -> getString(i) }
+ .mapNotNull { relation -> relationTypes.find { relation == it.kindAndDetail } }
+
+ return relations.flatMap { relation ->
+ val target = json.getJSONObject("target")
+ val assets = when (target.getString("namespace")) {
+ "web" -> sequenceOf(
+ AssetDescriptor.Web(site = target.getString("site")),
+ )
+ "android_app" -> {
+ val packageName = target.getString("package_name")
+ target.getJSONArray("sha256_cert_fingerprints")
+ .asSequence { i -> getString(i) }
+ .map { fingerprint -> AssetDescriptor.Android(packageName, fingerprint) }
+ }
+ else -> emptySequence()
+ }
+
+ assets.map { asset -> Statement(relation, asset) }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt
new file mode 100644
index 0000000000..2dc3548f7b
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.local
+
+import mozilla.components.service.digitalassetlinks.AssetDescriptor
+import mozilla.components.service.digitalassetlinks.Relation
+import mozilla.components.service.digitalassetlinks.RelationChecker
+import mozilla.components.service.digitalassetlinks.Statement
+import mozilla.components.service.digitalassetlinks.StatementListFetcher
+
+/**
+ * Checks if a matching relationship is present in a remote statement list.
+ */
+class StatementRelationChecker(
+ private val listFetcher: StatementListFetcher,
+) : RelationChecker {
+
+ override fun checkRelationship(source: AssetDescriptor.Web, relation: Relation, target: AssetDescriptor): Boolean {
+ val statements = listFetcher.listStatements(source)
+ return checkLink(statements, relation, target)
+ }
+
+ companion object {
+
+ /**
+ * Check if any of the given [Statement]s are linked to the given [target].
+ */
+ fun checkLink(statements: Sequence<Statement>, relation: Relation, target: AssetDescriptor) =
+ statements.any { statement ->
+ statement.relation == relation && statement.target == target
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt
new file mode 100644
index 0000000000..32c244286d
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt
@@ -0,0 +1,170 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks
+
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.pm.Signature
+import android.content.pm.SigningInfo
+import android.os.Build
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.Mock
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+import org.mockito.MockitoAnnotations
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class AndroidAssetFinderTest {
+
+ private lateinit var assetFinder: AndroidAssetFinder
+ private lateinit var packageInfo: PackageInfo
+
+ @Mock lateinit var packageManager: PackageManager
+
+ @Mock lateinit var signingInfo: SigningInfo
+
+ @Before
+ fun setup() {
+ assetFinder = spy(AndroidAssetFinder())
+
+ MockitoAnnotations.openMocks(this)
+ packageInfo = PackageInfo()
+ @Suppress("DEPRECATION")
+ `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo)
+ }
+
+ @Test
+ fun `test getAndroidAppAsset returns empty list if name not found`() {
+ @Suppress("DEPRECATION")
+ `when`(packageManager.getPackageInfo(anyString(), anyInt()))
+ .thenThrow(PackageManager.NameNotFoundException::class.java)
+
+ assertEquals(
+ emptyList<AssetDescriptor.Android>(),
+ assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(),
+ )
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.P])
+ @Test
+ fun `test getAndroidAppAsset on P SDK`() {
+ val signature = mock<Signature>()
+ packageInfo.signingInfo = signingInfo
+ `when`(signingInfo.hasMultipleSigners()).thenReturn(false)
+ `when`(signingInfo.signingCertificateHistory).thenReturn(arrayOf(signature, mock()))
+ doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature)
+
+ assertEquals(
+ listOf(AssetDescriptor.Android("com.test.app", "01:BB:AA:10:30")),
+ assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(),
+ )
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.P])
+ @Test
+ fun `test getAndroidAppAsset with multiple signatures on P SDK`() {
+ val signature1 = mock<Signature>()
+ val signature2 = mock<Signature>()
+ packageInfo.signingInfo = signingInfo
+ `when`(signingInfo.hasMultipleSigners()).thenReturn(true)
+ `when`(signingInfo.apkContentsSigners).thenReturn(arrayOf(signature1, signature2))
+ doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature1)
+ doReturn("FF:CC:AA:99:77").`when`(assetFinder).getCertificateSHA256Fingerprint(signature2)
+
+ assertEquals(
+ listOf(
+ AssetDescriptor.Android("org.test.app", "01:BB:AA:10:30"),
+ AssetDescriptor.Android("org.test.app", "FF:CC:AA:99:77"),
+ ),
+ assetFinder.getAndroidAppAsset("org.test.app", packageManager).toList(),
+ )
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.P])
+ @Test
+ fun `test getAndroidAppAsset with empty history`() {
+ packageInfo.signingInfo = signingInfo
+ `when`(signingInfo.hasMultipleSigners()).thenReturn(false)
+ `when`(signingInfo.signingCertificateHistory).thenReturn(emptyArray())
+
+ assertEquals(
+ emptyList<AssetDescriptor.Android>(),
+ assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(),
+ )
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.LOLLIPOP])
+ @Suppress("Deprecation")
+ @Test
+ fun `test getAndroidAppAsset on deprecated SDK`() {
+ val signature = mock<Signature>()
+ packageInfo.signatures = arrayOf(signature)
+ doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature)
+
+ assertEquals(
+ listOf(AssetDescriptor.Android("com.test.app", "01:BB:AA:10:30")),
+ assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(),
+ )
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.LOLLIPOP])
+ @Suppress("Deprecation")
+ @Test
+ fun `test getAndroidAppAsset with multiple signatures on deprecated SDK`() {
+ val signature1 = mock<Signature>()
+ val signature2 = mock<Signature>()
+ packageInfo.signatures = arrayOf(signature1, signature2)
+ doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature1)
+ doReturn("FF:CC:AA:99:77").`when`(assetFinder).getCertificateSHA256Fingerprint(signature2)
+
+ assertEquals(
+ listOf(
+ AssetDescriptor.Android("org.test.app", "01:BB:AA:10:30"),
+ AssetDescriptor.Android("org.test.app", "FF:CC:AA:99:77"),
+ ),
+ assetFinder.getAndroidAppAsset("org.test.app", packageManager).toList(),
+ )
+ }
+
+ @Config(sdk = [Build.VERSION_CODES.LOLLIPOP])
+ @Suppress("Deprecation")
+ @Test
+ fun `test getAndroidAppAsset is lazily computed`() {
+ val signature1 = mock<Signature>()
+ val signature2 = mock<Signature>()
+ packageInfo.signatures = arrayOf(signature1, signature2)
+ doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature1)
+ doReturn("FF:CC:AA:99:77").`when`(assetFinder).getCertificateSHA256Fingerprint(signature2)
+
+ val result = assetFinder.getAndroidAppAsset("android.package", packageManager).first()
+ assertEquals(
+ AssetDescriptor.Android("android.package", "01:BB:AA:10:30"),
+ result,
+ )
+
+ verify(assetFinder, times(1)).getCertificateSHA256Fingerprint(any())
+ }
+
+ @Test
+ fun `test byteArrayToHexString`() {
+ val array = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xcc.toByte(), 0x10, 0x20, 0x30, 0x01, 0x02)
+ assertEquals(
+ "AA:BB:CC:10:20:30:01:02",
+ assetFinder.byteArrayToHexString(array),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt
new file mode 100644
index 0000000000..53418751b7
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt
@@ -0,0 +1,229 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.api
+
+import android.net.Uri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.MutableHeaders
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import mozilla.components.concept.fetch.Response.Companion.SUCCESS
+import mozilla.components.service.digitalassetlinks.AssetDescriptor
+import mozilla.components.service.digitalassetlinks.Relation.HANDLE_ALL_URLS
+import mozilla.components.service.digitalassetlinks.Relation.USE_AS_ORIGIN
+import mozilla.components.service.digitalassetlinks.Statement
+import mozilla.components.service.digitalassetlinks.TIMEOUT
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class DigitalAssetLinksApiTest {
+
+ private val webAsset = AssetDescriptor.Web(site = "https://mozilla.org")
+ private val androidAsset = AssetDescriptor.Android(
+ packageName = "com.mozilla.fenix",
+ sha256CertFingerprint = "01:23:45:67:89",
+ )
+ private val baseRequest = Request(
+ url = "https://mozilla.org",
+ method = Request.Method.GET,
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ )
+ private val apiKey = "X"
+ private lateinit var client: Client
+ private lateinit var api: DigitalAssetLinksApi
+
+ @Before
+ fun setup() {
+ client = mock()
+ api = DigitalAssetLinksApi(client, apiKey)
+
+ doReturn(mockResponse("")).`when`(client).fetch(any())
+ }
+
+ @Test
+ fun `reject for invalid status`() {
+ val response = mockResponse("").copy(status = 400)
+ doReturn(response).`when`(client).fetch(any())
+
+ assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset))
+ assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList())
+ }
+
+ @Test
+ fun `reject check for invalid json`() {
+ doReturn(mockResponse("")).`when`(client).fetch(any())
+ assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, webAsset))
+
+ doReturn(mockResponse("{}")).`when`(client).fetch(any())
+ assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset))
+
+ doReturn(mockResponse("[]")).`when`(client).fetch(any())
+ assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset))
+
+ doReturn(mockResponse("{\"lnkd\":true}")).`when`(client).fetch(any())
+ assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset))
+ }
+
+ @Test
+ fun `reject list for invalid json`() {
+ val empty = emptyList<Statement>()
+
+ doReturn(mockResponse("")).`when`(client).fetch(any())
+ assertEquals(empty, api.listStatements(webAsset).toList())
+
+ doReturn(mockResponse("{}")).`when`(client).fetch(any())
+ assertEquals(empty, api.listStatements(webAsset).toList())
+
+ doReturn(mockResponse("[]")).`when`(client).fetch(any())
+ assertEquals(empty, api.listStatements(webAsset).toList())
+
+ doReturn(mockResponse("{\"stmt\":[]}")).`when`(client).fetch(any())
+ assertEquals(empty, api.listStatements(webAsset).toList())
+ }
+
+ @Test
+ fun `return linked from json`() {
+ doReturn(mockResponse("{\"linked\":true,\"maxAge\":\"3s\"}")).`when`(client).fetch(any())
+ assertTrue(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset))
+
+ doReturn(mockResponse("{\"linked\":false}\"maxAge\":\"3s\"}")).`when`(client).fetch(any())
+ assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset))
+ }
+
+ @Test
+ fun `return empty list if json doesn't match expected format`() {
+ val jsonPrefix = "{\"statements\":["
+ val jsonSuffix = "],\"maxAge\":\"3s\"}"
+ doReturn(mockResponse(jsonPrefix + jsonSuffix)).`when`(client).fetch(any())
+ assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList())
+
+ val invalidRelation = """
+ {
+ "source": {"web":{"site": "https://mozilla.org"}},
+ "target": {"web":{"site": "https://mozilla.org"}},
+ "relation": "not-a-relation"
+ }
+ """
+ doReturn(mockResponse(jsonPrefix + invalidRelation + jsonSuffix)).`when`(client).fetch(any())
+ assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList())
+
+ val invalidTarget = """
+ {
+ "source": {"web":{"site": "https://mozilla.org"}},
+ "target": {},
+ "relation": "delegate_permission/common.use_as_origin"
+ }
+ """
+ doReturn(mockResponse(jsonPrefix + invalidTarget + jsonSuffix)).`when`(client).fetch(any())
+ assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList())
+ }
+
+ @Test
+ fun `parses json statement list with web target`() {
+ val webStatement = """
+ {"statements": [{
+ "source": {"web":{"site": "https://mozilla.org"}},
+ "target": {"web":{"site": "https://mozilla.org"}},
+ "relation": "delegate_permission/common.use_as_origin"
+ }], "maxAge": "59s"}
+ """
+ doReturn(mockResponse(webStatement)).`when`(client).fetch(any())
+ assertEquals(
+ listOf(
+ Statement(
+ relation = USE_AS_ORIGIN,
+ target = webAsset,
+ ),
+ ),
+ api.listStatements(webAsset).toList(),
+ )
+ }
+
+ @Test
+ fun `parses json statement list with android target`() {
+ val androidStatement = """
+ {"statements": [{
+ "source": {"web":{"site": "https://mozilla.org"}},
+ "target": {"androidApp":{
+ "packageName": "com.mozilla.fenix",
+ "certificate": {"sha256Fingerprint": "01:23:45:67:89"}
+ }},
+ "relation": "delegate_permission/common.handle_all_urls"
+ }], "maxAge": "2m"}
+ """
+ doReturn(mockResponse(androidStatement)).`when`(client).fetch(any())
+ assertEquals(
+ listOf(
+ Statement(
+ relation = HANDLE_ALL_URLS,
+ target = androidAsset,
+ ),
+ ),
+ api.listStatements(webAsset).toList(),
+ )
+ }
+
+ @Test
+ fun `passes data in get check request URL for android target`() {
+ api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)
+ verify(client).fetch(
+ baseRequest.copy(
+ url = "https://digitalassetlinks.googleapis.com/v1/assetlinks:check?" +
+ "prettyPrint=false&key=X&relation=delegate_permission%2Fcommon.use_as_origin&" +
+ "source.web.site=${Uri.encode("https://mozilla.org")}&" +
+ "target.androidApp.packageName=com.mozilla.fenix&" +
+ "target.androidApp.certificate.sha256Fingerprint=${Uri.encode("01:23:45:67:89")}",
+ ),
+ )
+ }
+
+ @Test
+ fun `passes data in get check request URL for web target`() {
+ api.checkRelationship(webAsset, HANDLE_ALL_URLS, webAsset)
+ verify(client).fetch(
+ baseRequest.copy(
+ url = "https://digitalassetlinks.googleapis.com/v1/assetlinks:check?" +
+ "prettyPrint=false&key=X&relation=delegate_permission%2Fcommon.handle_all_urls&" +
+ "source.web.site=${Uri.encode("https://mozilla.org")}&" +
+ "target.web.site=${Uri.encode("https://mozilla.org")}",
+ ),
+ )
+ }
+
+ @Test
+ fun `passes data in get list request URL`() {
+ api.listStatements(webAsset)
+ verify(client).fetch(
+ baseRequest.copy(
+ url = "https://digitalassetlinks.googleapis.com/v1/statements:list?" +
+ "prettyPrint=false&key=X&source.web.site=${Uri.encode("https://mozilla.org")}",
+ ),
+ )
+ }
+
+ private fun mockResponse(data: String) = Response(
+ url = "",
+ status = SUCCESS,
+ headers = MutableHeaders(),
+ body = mockBody(data),
+ )
+
+ private fun mockBody(data: String): Response.Body {
+ val mockBody: Response.Body = mock()
+ doReturn(data).`when`(mockBody).string()
+ return mockBody
+ }
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt
new file mode 100644
index 0000000000..7ebd8ac67e
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt
@@ -0,0 +1,356 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.local
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE
+import mozilla.components.concept.fetch.Headers.Values.CONTENT_TYPE_APPLICATION_JSON
+import mozilla.components.concept.fetch.Headers.Values.CONTENT_TYPE_FORM_URLENCODED
+import mozilla.components.concept.fetch.MutableHeaders
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import mozilla.components.service.digitalassetlinks.AssetDescriptor
+import mozilla.components.service.digitalassetlinks.Relation
+import mozilla.components.service.digitalassetlinks.Statement
+import mozilla.components.service.digitalassetlinks.StatementListFetcher
+import mozilla.components.service.digitalassetlinks.TIMEOUT
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.MockitoAnnotations
+import java.io.ByteArrayInputStream
+import java.io.IOException
+
+@RunWith(AndroidJUnit4::class)
+class StatementApiTest {
+
+ @Mock private lateinit var httpClient: Client
+ private lateinit var listFetcher: StatementListFetcher
+ private val jsonHeaders = MutableHeaders(
+ CONTENT_TYPE to CONTENT_TYPE_APPLICATION_JSON,
+ )
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.openMocks(this)
+ listFetcher = StatementApi(httpClient)
+ }
+
+ @Test
+ fun `return empty list if request fails`() {
+ `when`(
+ httpClient.fetch(
+ Request(
+ url = "https://mozilla.org/.well-known/assetlinks.json",
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ ),
+ ),
+ ).thenThrow(IOException::class.java)
+
+ val source = AssetDescriptor.Web("https://mozilla.org")
+ assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList())
+ }
+
+ @Test
+ fun `return empty list if response does not have status 200`() {
+ val response = Response(
+ url = "https://firefox.com/.well-known/assetlinks.json",
+ status = 201,
+ headers = jsonHeaders,
+ body = mock(),
+ )
+ `when`(
+ httpClient.fetch(
+ Request(
+ url = "https://firefox.com/.well-known/assetlinks.json",
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ ),
+ ),
+ ).thenReturn(response)
+
+ val source = AssetDescriptor.Web("https://firefox.com")
+ assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList())
+ }
+
+ @Test
+ fun `return empty list if response does not have JSON content type`() {
+ val response = Response(
+ url = "https://firefox.com/.well-known/assetlinks.json",
+ status = 200,
+ headers = MutableHeaders(
+ CONTENT_TYPE to CONTENT_TYPE_FORM_URLENCODED,
+ ),
+ body = mock(),
+ )
+
+ `when`(
+ httpClient.fetch(
+ Request(
+ url = "https://firefox.com/.well-known/assetlinks.json",
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ ),
+ ),
+ ).thenReturn(response)
+
+ val source = AssetDescriptor.Web("https://firefox.com")
+ assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList())
+ }
+
+ @Test
+ fun `return empty list if response is not valid JSON`() {
+ val response = Response(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ status = 200,
+ headers = jsonHeaders,
+ body = stringBody("not-json"),
+ )
+
+ `when`(
+ httpClient.fetch(
+ Request(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ ),
+ ),
+ ).thenReturn(response)
+
+ val source = AssetDescriptor.Web("http://firefox.com")
+ assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList())
+ }
+
+ @Test
+ fun `return empty list if response is an empty JSON array`() {
+ val response = Response(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ status = 200,
+ headers = jsonHeaders,
+ body = stringBody("[]"),
+ )
+
+ `when`(
+ httpClient.fetch(
+ Request(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ ),
+ ),
+ ).thenReturn(response)
+
+ val source = AssetDescriptor.Web("http://firefox.com")
+ assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList())
+ }
+
+ @Test
+ fun `parses example asset links file`() {
+ val response = Response(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ status = 200,
+ headers = jsonHeaders,
+ body = stringBody(
+ """
+ [{
+ "relation": [
+ "delegate_permission/common.handle_all_urls",
+ "delegate_permission/common.use_as_origin"
+ ],
+ "target": {
+ "namespace": "web",
+ "site": "https://www.google.com"
+ }
+ },{
+ "relation": ["delegate_permission/common.handle_all_urls"],
+ "target": {
+ "namespace": "android_app",
+ "package_name": "org.digitalassetlinks.sampleapp",
+ "sha256_cert_fingerprints": [
+ "10:39:38:EE:45:37:E5:9E:8E:E7:92:F6:54:50:4F:B8:34:6F:C6:B3:46:D0:BB:C4:41:5F:C3:39:FC:FC:8E:C1"
+ ]
+ }
+ },{
+ "relation": ["delegate_permission/common.handle_all_urls"],
+ "target": {
+ "namespace": "android_app",
+ "package_name": "org.digitalassetlinks.sampleapp2",
+ "sha256_cert_fingerprints": ["AA", "BB"]
+ }
+ }]
+ """,
+ ),
+ )
+ `when`(
+ httpClient.fetch(
+ Request(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ ),
+ ),
+ ).thenReturn(response)
+
+ val source = AssetDescriptor.Web("http://firefox.com")
+ assertEquals(
+ listOf(
+ Statement(
+ relation = Relation.HANDLE_ALL_URLS,
+ target = AssetDescriptor.Web("https://www.google.com"),
+ ),
+ Statement(
+ relation = Relation.USE_AS_ORIGIN,
+ target = AssetDescriptor.Web("https://www.google.com"),
+ ),
+ Statement(
+ relation = Relation.HANDLE_ALL_URLS,
+ target = AssetDescriptor.Android(
+ packageName = "org.digitalassetlinks.sampleapp",
+ sha256CertFingerprint = "10:39:38:EE:45:37:E5:9E:8E:E7:92:F6:54:50:4F:B8:34:6F:C6:B3:46:D0:BB:C4:41:5F:C3:39:FC:FC:8E:C1",
+ ),
+ ),
+ Statement(
+ relation = Relation.HANDLE_ALL_URLS,
+ target = AssetDescriptor.Android(
+ packageName = "org.digitalassetlinks.sampleapp2",
+ sha256CertFingerprint = "AA",
+ ),
+ ),
+ Statement(
+ relation = Relation.HANDLE_ALL_URLS,
+ target = AssetDescriptor.Android(
+ packageName = "org.digitalassetlinks.sampleapp2",
+ sha256CertFingerprint = "BB",
+ ),
+ ),
+ ),
+ listFetcher.listStatements(source).toList(),
+ )
+ }
+
+ @Test
+ fun `resolves include statements`() {
+ `when`(
+ httpClient.fetch(
+ Request(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ ),
+ ),
+ ).thenReturn(
+ Response(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ status = 200,
+ headers = jsonHeaders,
+ body = stringBody(
+ """
+ [{
+ "relation": ["delegate_permission/common.use_as_origin"],
+ "target": {
+ "namespace": "web",
+ "site": "https://www.google.com"
+ }
+ },{
+ "include": "https://example.com/includedstatements.json"
+ }]
+ """,
+ ),
+ ),
+ )
+ `when`(
+ httpClient.fetch(
+ Request(
+ url = "https://example.com/includedstatements.json",
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ ),
+ ),
+ ).thenReturn(
+ Response(
+ url = "https://example.com/includedstatements.json",
+ status = 200,
+ headers = jsonHeaders,
+ body = stringBody(
+ """
+ [{
+ "relation": ["delegate_permission/common.use_as_origin"],
+ "target": {
+ "namespace": "web",
+ "site": "https://www.example.com"
+ }
+ }]
+ """,
+ ),
+ ),
+ )
+
+ val source = AssetDescriptor.Web("http://firefox.com")
+ assertEquals(
+ listOf(
+ Statement(
+ relation = Relation.USE_AS_ORIGIN,
+ target = AssetDescriptor.Web("https://www.google.com"),
+ ),
+ Statement(
+ relation = Relation.USE_AS_ORIGIN,
+ target = AssetDescriptor.Web("https://www.example.com"),
+ ),
+ ),
+ listFetcher.listStatements(source).toList(),
+ )
+ }
+
+ @Test
+ fun `no infinite loops`() {
+ `when`(
+ httpClient.fetch(
+ Request(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ connectTimeout = TIMEOUT,
+ readTimeout = TIMEOUT,
+ ),
+ ),
+ ).thenReturn(
+ Response(
+ url = "http://firefox.com/.well-known/assetlinks.json",
+ status = 200,
+ headers = jsonHeaders,
+ body = stringBody(
+ """
+ [{
+ "relation": ["delegate_permission/common.use_as_origin"],
+ "target": {
+ "namespace": "web",
+ "site": "https://example.com"
+ }
+ },{
+ "include": "http://firefox.com/.well-known/assetlinks.json"
+ }]
+ """,
+ ),
+ ),
+ )
+
+ val source = AssetDescriptor.Web("http://firefox.com")
+ assertEquals(
+ listOf(
+ Statement(
+ relation = Relation.USE_AS_ORIGIN,
+ target = AssetDescriptor.Web("https://example.com"),
+ ),
+ ),
+ listFetcher.listStatements(source).toList(),
+ )
+ }
+
+ private fun stringBody(data: String) = Response.Body(ByteArrayInputStream(data.toByteArray()))
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt
new file mode 100644
index 0000000000..4498ab56a1
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.service.digitalassetlinks.local
+
+import mozilla.components.service.digitalassetlinks.AssetDescriptor
+import mozilla.components.service.digitalassetlinks.Relation
+import mozilla.components.service.digitalassetlinks.Statement
+import mozilla.components.service.digitalassetlinks.StatementListFetcher
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class StatementRelationCheckerTest {
+
+ @Test
+ fun `checks list lazily`() {
+ var numYields = 0
+ val target = AssetDescriptor.Web("https://mozilla.org")
+ val listFetcher = object : StatementListFetcher {
+ override fun listStatements(source: AssetDescriptor.Web) = sequence {
+ numYields = 1
+ yield(
+ Statement(
+ relation = Relation.USE_AS_ORIGIN,
+ target = target,
+ ),
+ )
+ numYields = 2
+ yield(
+ Statement(
+ relation = Relation.USE_AS_ORIGIN,
+ target = target,
+ ),
+ )
+ }
+ }
+
+ val checker = StatementRelationChecker(listFetcher)
+ assertEquals(0, numYields)
+
+ assertTrue(checker.checkRelationship(mock(), Relation.USE_AS_ORIGIN, target))
+ assertEquals(1, numYields)
+
+ // Sanity check that the mock can yield twice
+ numYields = 0
+ listFetcher.listStatements(mock()).toList()
+ assertEquals(2, numYields)
+ }
+
+ @Test
+ fun `fails if relation does not match`() {
+ val target = AssetDescriptor.Android("com.test", "AA:BB")
+ val listFetcher = object : StatementListFetcher {
+ override fun listStatements(source: AssetDescriptor.Web) = sequenceOf(
+ Statement(
+ relation = Relation.USE_AS_ORIGIN,
+ target = target,
+ ),
+ )
+ }
+
+ val checker = StatementRelationChecker(listFetcher)
+ assertFalse(checker.checkRelationship(mock(), Relation.HANDLE_ALL_URLS, target))
+ }
+
+ @Test
+ fun `fails if target does not match`() {
+ val target = AssetDescriptor.Web("https://mozilla.org")
+ val listFetcher = object : StatementListFetcher {
+ override fun listStatements(source: AssetDescriptor.Web) = sequenceOf(
+ Statement(
+ relation = Relation.HANDLE_ALL_URLS,
+ target = AssetDescriptor.Web("https://mozilla.com"),
+ ),
+ Statement(
+ relation = Relation.HANDLE_ALL_URLS,
+ target = AssetDescriptor.Web("http://mozilla.org"),
+ ),
+ )
+ }
+
+ val checker = StatementRelationChecker(listFetcher)
+ assertFalse(checker.checkRelationship(mock(), Relation.HANDLE_ALL_URLS, target))
+ }
+}
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..1f0955d450
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28