diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 0000000..2aaab3e
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,96 @@
+name: Android CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+ release:
+ types: [ published ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ # Setup
+ - uses: actions/checkout@v3
+ with:
+ submodules: true
+ - name: set up JDK 11
+ uses: actions/setup-java@v3
+ with:
+ java-version: '11'
+ distribution: 'temurin'
+ cache: gradle
+
+ # Build
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ - name: Build with Gradle and run tests
+ run: ./gradlew build
+
+ # Upload libraries to workflow artifacts
+ - uses: actions/upload-artifact@v3
+ if: ${{ github.event_name != 'pull_request' }}
+ with:
+ name: libraries
+ path: digitalassetlinks/build/outputs/aar/*.aar
+ if-no-files-found: error
+ retention-days: 5
+
+ # Upload javadoc to workflow artifacts
+ - uses: actions/upload-artifact@v3
+ if: ${{ github.event_name != 'pull_request' }}
+ with:
+ name: digitalassetlinks-javadoc
+ path: |
+ digitalassetlinks/build/docs/*
+ !digitalassetlinks/build/docs/**/*.zip
+ if-no-files-found: error
+ retention-days: 1
+
+ # Note: only runs on 'push' events
+ publish-to-gh-pages:
+ runs-on: ubuntu-latest
+ needs: build
+ concurrency: publish-to-gh-pages
+ if: ${{ github.event_name == 'push' }}
+
+ steps:
+ - name: Update digitalassetlinks javadoc
+ uses: solana-mobile/gha-commit-artifact-to-branch@v1
+ with:
+ token: ${{ secrets.UPDATE_GITHUB_PAGES_TOKEN }}
+ branch: gh-pages
+ artifact-name: digitalassetlinks-javadoc
+ dest: digitalassetlinks
+ commit-message: 'Update digitalassetlinks javadoc'
+
+ # Note: only runs on 'release' events
+ upload-to-release:
+ runs-on: ubuntu-latest
+ needs: build
+ permissions:
+ contents: write # needed for uploading files to releases
+ if: ${{ github.event_name == 'release' }}
+
+ steps:
+ - uses: actions/download-artifact@v3
+ with:
+ name: libraries
+ path: libraries
+ - uses: actions/download-artifact@v3
+ with:
+ name: digitalassetlinks-javadoc
+ path: digitalassetlinks-javadoc
+ - name: Compress digitalassetlinks javadoc
+ run: tar -cvzf digitalassetlinks-javadoc/digitalassetlinks-javadoc.tgz -C digitalassetlinks-javadoc javadoc
+ - name: Upload files to release
+ run: gh release -R ${GITHUB_REPOSITORY} upload ${TAG} ${FILES}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ github.event.release.tag_name }}
+ FILES: |
+ libraries/*.aar
+ digitalassetlinks-javadoc/digitalassetlinks-javadoc.tgz
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..757125b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,78 @@
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+# Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea
+
+# Keystore files
+# Uncomment the following lines if you do not want to check your keystore files in.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
+
+# OSX
+.DS_Store
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..786cf69
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "digitalassetlinks/external/digitalassetlinks"]
+ path = digitalassetlinks/external/digitalassetlinks
+ url = https://github.com/google/digitalassetlinks.git
+ branch = master
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..9cadac3
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '7.2.1' apply false
+ id 'com.android.library' version '7.2.1' apply false
+ id 'org.jetbrains.kotlin.android' version '1.7.10' apply false
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
\ No newline at end of file
diff --git a/digitalassetlinks/.gitignore b/digitalassetlinks/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/digitalassetlinks/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/digitalassetlinks/build.gradle b/digitalassetlinks/build.gradle
new file mode 100644
index 0000000..9cad924
--- /dev/null
+++ b/digitalassetlinks/build.gradle
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+plugins {
+ id 'com.android.library'
+ id 'com.google.protobuf' version "0.8.19"
+}
+
+android {
+ compileSdk 31
+
+ defaultConfig {
+ minSdk 23
+ targetSdk 31
+
+ consumerProguardFiles "consumer-rules.pro"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+
+ sourceSets {
+ test {
+ proto {
+ srcDir 'external/digitalassetlinks/compatibility-tests/v1'
+ }
+ }
+ }
+}
+
+protobuf {
+ protoc {
+ def archSuffix = ''
+ if (osdetector.os == "osx") {
+ // Workaround: there are no published protoc binaries for Apple Silicon-based macs.
+ // Apply a manual architecture to the artifact to select the osx-x86_64 variant, which
+ // runs fine with Rosetta.
+ archSuffix = ':osx-x86_64'
+ }
+ artifact = "com.google.protobuf:protoc:3.21.2$archSuffix" // Download from repositories
+ }
+ generateProtoTasks {
+ all().each { task ->
+ task.builtins {
+ java { }
+ }
+ }
+ }
+}
+
+// Define JavaDoc build task for all variants
+android.libraryVariants.all { variant ->
+ task("generate${variant.name.capitalize()}Javadoc", type: Javadoc) {
+ description "Generates Javadoc for $variant.name."
+ source = variant.javaCompileProvider.get().source
+ classpath = project.files(android.getBootClasspath().join(File.pathSeparator))
+ classpath += files(variant.javaCompileProvider.get().classpath)
+ options.links("https://docs.oracle.com/javase/8/docs/api/");
+ options.links("https://d.android.com/reference/");
+ exclude '**/BuildConfig.java'
+ exclude '**/R.java'
+ }
+}
+
+// Build JavaDoc when making a release build
+tasks.whenTaskAdded { task ->
+ if (task.name == 'assembleRelease') {
+ task.dependsOn("generateReleaseJavadoc")
+ }
+}
+
+dependencies {
+ implementation 'androidx.annotation:annotation:1.4.0'
+ implementation 'androidx.collection:collection:1.2.0'
+ testImplementation 'com.google.protobuf:protobuf-java:3.21.2'
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.mockito:mockito-inline:4.6.1'
+ testImplementation 'org.robolectric:robolectric:4.8.1'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+ androidTestImplementation 'androidx.test:rules:1.4.0'
+ androidTestImplementation 'androidx.test:runner:1.4.0'
+ androidTestImplementation 'junit:junit:4.13.2'
+}
\ No newline at end of file
diff --git a/digitalassetlinks/consumer-rules.pro b/digitalassetlinks/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/digitalassetlinks/external/digitalassetlinks b/digitalassetlinks/external/digitalassetlinks
new file mode 160000
index 0000000..d893749
--- /dev/null
+++ b/digitalassetlinks/external/digitalassetlinks
@@ -0,0 +1 @@
+Subproject commit d893749ee3c84b31f72b552a7ed333789c3235d3
diff --git a/digitalassetlinks/proguard-rules.pro b/digitalassetlinks/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/digitalassetlinks/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/digitalassetlinks/src/androidTest/AndroidManifest.xml b/digitalassetlinks/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..31bb646
--- /dev/null
+++ b/digitalassetlinks/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/digitalassetlinks/src/androidTest/java/com/solana/digitalassetlinks/DigitalAssetLinksIntegrationTests.java b/digitalassetlinks/src/androidTest/java/com/solana/digitalassetlinks/DigitalAssetLinksIntegrationTests.java
new file mode 100644
index 0000000..882550e
--- /dev/null
+++ b/digitalassetlinks/src/androidTest/java/com/solana/digitalassetlinks/DigitalAssetLinksIntegrationTests.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+package com.solana.digitalassetlinks;
+
+import static org.junit.Assert.*;
+
+import android.content.pm.PackageManager;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.net.URI;
+
+/** NOTE: these tests are not hermetic - they use the state of the emulator, plus the network */
+@RunWith(AndroidJUnit4.class)
+public class DigitalAssetLinksIntegrationTests {
+ @Test
+ public void testGmsPackageIsVerifiedByWwwAndroidCom()
+ throws AndroidAppPackageVerifier.CouldNotVerifyPackageException {
+ final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager();
+ assertNotNull(pm);
+
+ final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm);
+ final boolean verified = verifier.verify(
+ "com.google.android.gms", URI.create("https://www.android.com"));
+ assertTrue(verified);
+ }
+
+ @Test
+ public void testGmsPackageIsNotVerifiedByDeveloperAndroidCom()
+ throws AndroidAppPackageVerifier.CouldNotVerifyPackageException {
+ final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager();
+ assertNotNull(pm);
+
+ final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm);
+ final boolean verified = verifier.verify(
+ "com.google.android.gms", URI.create("https://developer.android.com"));
+ assertFalse(verified);
+ }
+
+ @Test
+ public void testGmsPackageIsNotVerifiedByWwwSolanaCom() {
+ final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager();
+ assertNotNull(pm);
+
+ final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm);
+ assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class,
+ () -> verifier.verify(
+ "com.google.android.gms", URI.create("https://www.solana.com")));
+ // NOTE: verifier throws, rather than returns false, because www.solana.com does not host
+ // any Digital Asset Links content.
+ }
+
+ @Test
+ public void testGmsPackageIsNotVerifiedByHttpWwwAndroidCom() {
+ final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager();
+ assertNotNull(pm);
+
+ final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm);
+ assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class,
+ () -> verifier.verify(
+ "com.google.android.gms", URI.create("http://www.android.com")));
+ }
+
+ @Test
+ public void testCamera2PackageIsNotVerifiedByWwwAndroidCom() throws AndroidAppPackageVerifier.CouldNotVerifyPackageException {
+ final PackageManager pm = ApplicationProvider.getApplicationContext().getPackageManager();
+ assertNotNull(pm);
+
+ final AndroidAppPackageVerifier verifier = new AndroidAppPackageVerifier(pm);
+ final boolean verified = verifier.verify(
+ "com.android.camera2", URI.create("https://www.android.com"));
+ assertFalse(verified);
+ }
+}
diff --git a/digitalassetlinks/src/main/AndroidManifest.xml b/digitalassetlinks/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c49b6b4
--- /dev/null
+++ b/digitalassetlinks/src/main/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AndroidAppPackageVerifier.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AndroidAppPackageVerifier.java
new file mode 100644
index 0000000..268ff74
--- /dev/null
+++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AndroidAppPackageVerifier.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+package com.solana.digitalassetlinks;
+
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.Signature;
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.net.URI;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
+/**
+ * Verifier for Android app packages using
+ * Digital Asset Links
+ */
+public class AndroidAppPackageVerifier extends URISourceVerifier {
+ private final PackageManager mPackageManager;
+
+ /**
+ * Construct a new {@link AndroidAppPackageVerifier}
+ * @param pm the {@link PackageManager} from which to look up app package details
+ */
+ public AndroidAppPackageVerifier(@NonNull PackageManager pm) {
+ mPackageManager = pm;
+ }
+
+ /**
+ * Verify that the specified app package is covered by a reliable statement in the Asset Links
+ * JSON for the provided {@link URI}.
+ *
NOTE: this method performs blocking network activity; it should not be invoked on the
+ * UI thread of an app. A running instance of this function can be cancelled (from another
+ * thread using the {@link #cancel()} method.
+ * @param packageName the android app package name (e.g. com.solana.example)
+ * @param uri the URI with which this app package should be verified. This should be a URI that
+ * establishes a trust link of interest to the app. For example, if the use case is a
+ * Solana Pay transaction request, the URI might refer to the site which is generating the
+ * transaction.
+ * @return true if the package was verified using the specified URI, or false if it was not
+ * verified
+ * @throws CouldNotVerifyPackageException if the package verification process was not
+ * successful. Note that this doesn't necessarily imply that the package was not verified,
+ * but rather that a relationship could not be established.
+ */
+ public boolean verify(@NonNull String packageName, @NonNull URI uri)
+ throws CouldNotVerifyPackageException {
+ if (!"https".equalsIgnoreCase(uri.getScheme())) {
+ throw new CouldNotVerifyPackageException(
+ "Android app packages can only be verified with secure source URIs");
+ }
+
+ // Check that mPackageName is present and visible to this app
+ final int flags;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ flags = PackageManager.GET_SIGNING_CERTIFICATES;
+ } else {
+ flags = PackageManager.GET_SIGNATURES;
+ }
+ final PackageInfo packageInfo;
+ try {
+ packageInfo = mPackageManager.getPackageInfo(packageName, flags);
+ } catch (PackageManager.NameNotFoundException e) {
+ throw new CouldNotVerifyPackageException("Failed reading signatures for package " + packageName, e);
+ }
+
+ // Query the set of signatures we're looking for
+ final byte[][] packageCertSha256Fingerprints;
+ final boolean[] signatureMask;
+ final boolean requireAllSignatures;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ if (packageInfo.signingInfo.hasMultipleSigners()) {
+ packageCertSha256Fingerprints = convertDEREncodedCertificatesToSHA256Fingerprints(
+ packageInfo.signingInfo.getApkContentsSigners());
+ signatureMask = new boolean[packageCertSha256Fingerprints.length];
+ requireAllSignatures = true;
+ } else {
+ packageCertSha256Fingerprints = convertDEREncodedCertificatesToSHA256Fingerprints(
+ packageInfo.signingInfo.getSigningCertificateHistory());
+ signatureMask = new boolean[1];
+ requireAllSignatures = false;
+ }
+ } else {
+ packageCertSha256Fingerprints = convertDEREncodedCertificatesToSHA256Fingerprints(
+ packageInfo.signatures);
+ signatureMask = new boolean[packageCertSha256Fingerprints.length];
+ requireAllSignatures = true;
+ }
+
+ // Create and configure an AssetLinksJSONParser object
+ final StatementMatcher androidAppMatcher = StatementMatcher
+ .createAndroidAppStatementMatcher(
+ AssetLinksGrammar.GRAMMAR_RELATION_HANDLE_ALL_URLS,
+ packageName,
+ null);
+ final AssetLinksJSONParser.StatementMatcherCallback androidAppMatcherCallback =
+ (matcher, o) -> {
+ final JSONObject target = o.getJSONObject(AssetLinksGrammar.GRAMMAR_TARGET); // mandatory field
+ final JSONArray certFingerprints = target.getJSONArray(AssetLinksGrammar.GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS); // mandatory field
+ final int numCertFingerprints = certFingerprints.length();
+ for (int i = 0; i < numCertFingerprints; i++) {
+ final String certFingerprint = certFingerprints.getString(i);
+ final byte[] fp = convertSHA256CertFingerprintStringToByteArray(certFingerprint);
+ for (int j = 0; j < packageCertSha256Fingerprints.length; j++) {
+ final byte[] packageFp = packageCertSha256Fingerprints[j];
+ if (Arrays.equals(fp, packageFp)) {
+ signatureMask[requireAllSignatures ? j : 0] = true;
+ }
+ }
+ }
+ };
+
+ // Do URI source verification with the Android app package target matcher
+ try {
+ // NOTE: ignore the return value of verify; it conveys whether there were acceptable
+ // parsing errors that do not affect the validity of the verification.
+ verify(uri, new StatementMatcherWithCallback(androidAppMatcher, androidAppMatcherCallback));
+ } catch (CouldNotVerifyException e) {
+ throw new CouldNotVerifyPackageException("Could not verify package " + packageName, e);
+ } catch (IllegalArgumentException e) {
+ throw new CouldNotVerifyPackageException("Source URI not a valid HTTP or HTTPS URL: " + uri, e);
+ }
+
+ // No need to check parser.isComplete() or parser.isError() here; the first is handled by
+ // waiting for parser.onDocumentLoaded(...) to return null, and the second by terminating on
+ // a caught exception.
+
+ // Step 6: Check the verification state produced by the Android App statement matcher
+ boolean result = true;
+ for (boolean b : signatureMask) {
+ if (!b) {
+ result = false;
+ break;
+ }
+ }
+
+ return result;
+ }
+
+ @NonNull
+ private static byte[][] convertDEREncodedCertificatesToSHA256Fingerprints(
+ @NonNull Signature[] derEncodedCerts) {
+ final MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException e) {
+ throw new UnsupportedOperationException("SHA-256 message digest algorithm not found", e);
+ }
+ final byte[][] sha256CertFingerprints = new byte[derEncodedCerts.length][];
+ for (int i = 0; i < derEncodedCerts.length; i++) {
+ sha256CertFingerprints[i] = digest.digest(derEncodedCerts[i].toByteArray());
+ }
+ return sha256CertFingerprints;
+ }
+
+ @NonNull
+ private static byte[] convertSHA256CertFingerprintStringToByteArray(
+ @NonNull String sha256CertFingerprint) {
+ // NOTE: the format of the certificate fingerprint string is already checked against
+ // AssetLinksGrammer.SHA256_CERT_FINGERPRINT_PATTERN by AssetLinksJSONParser
+ final byte[] fp = new byte[32];
+ for (int i = 0; i < 32; i++) {
+ final int j = i * 3;
+ String s = sha256CertFingerprint.substring(j, j + 2);
+ try {
+ fp[i] = (byte)Short.parseShort(s, 16);
+ } catch (NumberFormatException nfe) {
+ throw new IllegalArgumentException("Error while parsing hex substring", nfe);
+ }
+ }
+ return fp;
+ }
+
+ /**
+ * This exception indicates that the verification process could not be completed (for e.g., due
+ * to a network error). It indicates that no trust relationship between the app package and the
+ * {@link URI} was established, but does not necessarily imply that the app package is not
+ * trusted.
+ */
+ public static class CouldNotVerifyPackageException extends CouldNotVerifyException {
+ public CouldNotVerifyPackageException(String message) { super(message); }
+ public CouldNotVerifyPackageException(String message, Throwable t) { super(message, t); }
+ }
+}
\ No newline at end of file
diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksGrammar.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksGrammar.java
new file mode 100644
index 0000000..2c12b3a
--- /dev/null
+++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksGrammar.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+package com.solana.digitalassetlinks;
+
+import androidx.annotation.NonNull;
+
+import java.net.URI;
+
+/**
+ * The defined grammar for Digital Asset Links
+ */
+public final class AssetLinksGrammar {
+ public static final int MAX_ASSET_LINKS_URIS = 11; // 1 source URI + max 10 include statements
+
+ public static final String GRAMMAR_INCLUDE = "include";
+
+ public static final String GRAMMAR_RELATION = "relation";
+ public static final String GRAMMAR_TARGET = "target";
+ public static final String GRAMMAR_NAMESPACE = "namespace";
+
+ public static final String GRAMMAR_DELEGATE_PERMISSION_PREFIX = "delegate_permission";
+ public static final String GRAMMAR_RELATION_HANDLE_ALL_URLS = GRAMMAR_DELEGATE_PERMISSION_PREFIX + "/common.handle_all_urls";
+ public static final String GRAMMER_RELATION_GET_LOGIN_CREDS = GRAMMAR_DELEGATE_PERMISSION_PREFIX + "/common.get_login_creds";
+
+ public static final String GRAMMAR_NAMESPACE_WEB = "web";
+ public static final String GRAMMAR_WEB_SITE = "site";
+
+ public static final String GRAMMAR_NAMESPACE_ANDROID_APP = "android_app";
+ public static final String GRAMMAR_ANDROID_APP_PACKAGE_NAME = "package_name";
+ public static final String GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS = "sha256_cert_fingerprints";
+
+ public static final String RELATION_PATTERN = "^[a-z0-9_.]+/[a-z0-9_.]+$";
+ public static final String PACKAGE_NAME_PATTERN = "^(?:[a-zA-Z0-9_]+\\.)+[a-zA-Z0-9_]+$";
+ public static final String SHA256_CERT_FINGERPRINT_PATTERN = "^(?:[0-9A-F]{2}:){31}[0-9A-F]{2}$";
+
+ /** Tests if the specified URI is valid for {@link #GRAMMAR_WEB_SITE} */
+ public static boolean isValidSiteURI(@NonNull URI uri) {
+ return (uri.isAbsolute() &&
+ ("http".equalsIgnoreCase(uri.getScheme()) ||
+ "https".equalsIgnoreCase(uri.getScheme())) &&
+ (uri.getRawUserInfo() == null || uri.getRawUserInfo().isEmpty()) &&
+ (uri.getRawPath() == null || uri.getRawPath().isEmpty()) &&
+ (uri.getRawQuery() == null || uri.getRawQuery().isEmpty()) &&
+ (uri.getRawFragment() == null || uri.getRawFragment().isEmpty()) &&
+ (uri.getPort() == -1 || (uri.getPort() >= 1 && uri.getPort() <= 65535)));
+ }
+
+ private AssetLinksGrammar() {}
+}
diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksJSONParser.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksJSONParser.java
new file mode 100644
index 0000000..04ec91b
--- /dev/null
+++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/AssetLinksJSONParser.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+package com.solana.digitalassetlinks;
+
+import android.util.Log;
+import android.util.Pair;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A Digital Asset Links JSON parser
+ */
+public class AssetLinksJSONParser {
+ private static final String TAG = AssetLinksJSONParser.class.getSimpleName();
+
+ private final ArrayList mURIs = new ArrayList<>(1);
+ private int mLoadingIndex = -1;
+
+ private final ArrayList> mStatementMatchers = new ArrayList<>();
+
+ private boolean mIsSourceURISecure;
+
+ private boolean mIsError;
+ private boolean mIsWarning;
+
+ /**
+ * Construct a new {@link AssetLinksJSONParser}
+ */
+ public AssetLinksJSONParser() {}
+
+ /**
+ * Retrieve all URIs referenecd by this Asset Links tree. If {@link #isComplete()}, this set
+ * represents the full Asset Links tree derived from the first entry in the returned list.
+ * @return a {@link List} of {@link URI}s in the order discovered. The first entry is the source
+ * {@link URI} for this Asset Links tree.
+ */
+ @NonNull
+ public List getURIs() {
+ return Collections.unmodifiableList(mURIs);
+ }
+
+ /**
+ * Whether parsing has completed successfully (including the full tree of include directives).
+ * If {@link #isError()} is true, this will always return false.
+ * @return true if parsing completed successfully, else false
+ */
+ public boolean isComplete() {
+ return !mIsError && (mLoadingIndex == mURIs.size());
+ }
+
+ /**
+ * Whether an error was encountered during parsing. If this is true, no more parsing can occur,
+ * and any {@link StatementMatcherCallback} results should be ignored.
+ * @return true if an error was encountered during parsing, else false
+ */
+ public boolean isError() {
+ return mIsError;
+ }
+
+ /**
+ * Whether any non-fatal parsing errors were encountered during parsing. If this is true, the
+ * parsing can still complete successfully, and the verification results can still be trusted.
+ * NOTE: this exists primarily to validate the behavior of this parser with the Asset Links
+ * compatibility test suite.
+ * @return true if any non-fatal parsing errors were encountered, else false
+ */
+ public boolean isWarning() { return mIsWarning; }
+
+ /**
+ * Add a {@link StatementMatcher} and corresponding {@link StatementMatcherCallback} to this
+ * {@link AssetLinksJSONParser}. All statement matchers should be added before
+ * {@link #start(URI)} is called.
+ * @param statementMatcher the {@link StatementMatcher} to add
+ * @param callback the {@link StatementMatcherCallback} to invoke when a match occurs
+ */
+ public void addStatementMatcher(@NonNull StatementMatcher statementMatcher,
+ @NonNull StatementMatcherCallback callback) {
+ mStatementMatchers.add(Pair.create(statementMatcher, callback));
+ }
+
+ /**
+ * Start this {@link AssetLinksJSONParser} for the specified Asset Links {@link URI}
+ * @param sourceURI the initial Asset Links {@link URI}. It must be an absolute {@link URI}.
+ * @return the first {@link URI} to fetch for {@link #onDocumentLoaded(URI, String)}
+ */
+ @NonNull
+ public URI start(@NonNull URI sourceURI) {
+ if (!sourceURI.isAbsolute()) {
+ throw new IllegalArgumentException("Source URI must be absolute");
+ }
+
+ if (mLoadingIndex != -1) {
+ throw new IllegalStateException("Already started");
+ }
+
+ mLoadingIndex = 0;
+ mURIs.add(sourceURI);
+ mIsSourceURISecure = !"http".equalsIgnoreCase(sourceURI.getScheme());
+ return sourceURI;
+ }
+
+ /**
+ * This method should be invoked with the document contents for the {@link URI}s returned from
+ * {@link #start(URI)} and {@link #onDocumentLoaded(URI, String)}. It will continue to return
+ * {@link URI}s until the entire Asset Links include tree has been parsed, or until a parsing
+ * error occurs.
+ * @param documentURI the {@link URI} for document
+ * @param document a {@link String} containing the contents of document, interpreted as a UTF-8
+ * string
+ * @return the next {@link URI} to fetch, or null if parsing is complete
+ * @throws IllFormattedStatementException if parsing fails due to a semantically invalid
+ * document
+ * @throws TooManyIncludesException if parsing fails due to exceeding the limit on the size of
+ * the Asset Links include tree
+ * @throws MatcherException if any registered {@link StatementMatcher} throws an exception
+ */
+ @Nullable
+ public URI onDocumentLoaded(@NonNull URI documentURI, @NonNull String document)
+ throws IllFormattedStatementException, TooManyIncludesException, MatcherException {
+ if (mIsError) {
+ throw new IllegalStateException("AssetLinksJSONParser already in the error state");
+ }
+
+ try {
+ final URI expectedURI = mURIs.get(mLoadingIndex);
+ if (!documentURI.equals(expectedURI)) {
+ throw new IllegalArgumentException("Document URI does not match expected value: expected=" + expectedURI + ", actual=" + documentURI);
+ }
+
+ final ArrayList statementList = new ArrayList<>();
+ final ArrayList includeList = new ArrayList<>();
+
+ boolean doPostParsing = true;
+ try {
+ parseAssetLinksDocument(documentURI, document, statementList, includeList);
+ } catch (IllFormattedStatementException e) {
+ if (mLoadingIndex == 0) {
+ throw e;
+ }
+ Log.w(TAG, "Parsing error on an included statement list; discarding and continuing processing");
+ doPostParsing = false;
+ mIsWarning = true;
+ }
+
+ if (doPostParsing) {
+ // Process include directives
+ if (includeList.removeAll(mURIs)) {
+ Log.w(TAG, "Discarded include URIs to avoid recursion");
+ mIsWarning = true;
+ }
+ mURIs.addAll(includeList);
+ if (mURIs.size() > AssetLinksGrammar.MAX_ASSET_LINKS_URIS) {
+ throw new TooManyIncludesException();
+ }
+
+ // Dispatch statements to matchers
+ for (JSONObject statement : statementList) {
+ for (Pair pair : mStatementMatchers) {
+ if (pair.first.compare(statement)) {
+ try {
+ pair.second.onMatch(pair.first, statement);
+ } catch (Exception e) {
+ throw new MatcherException("Matcher failed, terminating parsing", e);
+ }
+ }
+ }
+ }
+ }
+
+ mLoadingIndex++;
+ return (isComplete() ? null : mURIs.get(mLoadingIndex));
+ } catch (Exception e) {
+ mIsError = true;
+ throw e;
+ }
+ }
+
+ private void parseAssetLinksDocument(@NonNull URI documentURI,
+ @NonNull String document,
+ @NonNull ArrayList statementList,
+ @NonNull ArrayList includeList)
+ throws IllFormattedStatementException {
+ try {
+ final JSONArray statements = new JSONArray(document); // mandatory top-level array
+ final int numStatements = statements.length();
+ for (int i = 0; i < numStatements; i++) {
+ final JSONObject statement = statements.getJSONObject(i); // mandatory object
+
+ if (statement.has(AssetLinksGrammar.GRAMMAR_INCLUDE)) {
+ parseIncludeStatement(documentURI, statement, includeList);
+ } else if (statement.has(AssetLinksGrammar.GRAMMAR_RELATION)) {
+ parseAssetLinkStatement(statement, statementList);
+ } else if (statement.has(AssetLinksGrammar.GRAMMAR_TARGET)) {
+ // Has target but no relation - error!
+ throw new IllFormattedStatementException("statement has target without relation");
+ } else {
+ // Some other JSON object; it has none of include, relation, or target. Skip it.
+ Log.w(TAG, "Skipping an unknown JSON object in the statement list");
+ mIsWarning = true;
+ }
+ }
+ } catch (JSONException e) {
+ throw new IllFormattedStatementException("Error while parsing Asset Links JSON", e);
+ }
+ }
+
+ private void parseIncludeStatement(@NonNull URI documentURI,
+ @NonNull JSONObject statement,
+ @NonNull ArrayList includeList)
+ throws JSONException, IllFormattedStatementException {
+ if (statement.has(AssetLinksGrammar.GRAMMAR_RELATION) ||
+ statement.has(AssetLinksGrammar.GRAMMAR_TARGET)) {
+ throw new IllFormattedStatementException("Include statement should not contain relation or target keys");
+ }
+
+ final URI includeURI;
+ try {
+ includeURI = URI.create(statement.getString(AssetLinksGrammar.GRAMMAR_INCLUDE));
+ } catch (IllegalArgumentException e) {
+ throw new IllFormattedStatementException("Include statement URI is not well-formed", e);
+ }
+ final URI resolvedURI = documentURI.resolve(includeURI);
+
+ if (mIsSourceURISecure && !"https".equalsIgnoreCase(resolvedURI.getScheme())) {
+ throw new IllFormattedStatementException("Include statement must reference a secure location when Asset Links source URI is secure; URI=" + resolvedURI);
+ }
+
+ includeList.add(resolvedURI);
+ }
+
+ private void parseAssetLinkStatement(@NonNull JSONObject statement,
+ @NonNull ArrayList statementList)
+ throws JSONException, IllFormattedStatementException {
+ final JSONArray relations = statement.getJSONArray(AssetLinksGrammar.GRAMMAR_RELATION);
+ if (relations.length() == 0) {
+ throw new IllFormattedStatementException("relation array must contain at least one relation");
+ }
+ for (int i = 0; i < relations.length(); i++) {
+ final String relation = relations.getString(i);
+ if (!relation.matches(AssetLinksGrammar.RELATION_PATTERN)) {
+ throw new IllFormattedStatementException("relation does not match expected format");
+ }
+ }
+ final JSONObject target = statement.getJSONObject(AssetLinksGrammar.GRAMMAR_TARGET);
+ final String namespace = target.getString(AssetLinksGrammar.GRAMMAR_NAMESPACE);
+ if (AssetLinksGrammar.GRAMMAR_NAMESPACE_WEB.equals(namespace)) {
+ validateWebTarget(target);
+ } else if (AssetLinksGrammar.GRAMMAR_NAMESPACE_ANDROID_APP.equals(namespace)) {
+ validateAndroidAppTarget(target);
+ }
+
+ statementList.add(statement);
+ }
+
+ private void validateWebTarget(@NonNull JSONObject webTarget)
+ throws JSONException, IllFormattedStatementException {
+ final String site = webTarget.getString(AssetLinksGrammar.GRAMMAR_WEB_SITE);
+ final URI uri;
+ try {
+ uri = URI.create(site);
+ } catch (IllegalArgumentException e) {
+ throw new IllFormattedStatementException("site URL format is invalid: " + site, e);
+ }
+
+ if (!AssetLinksGrammar.isValidSiteURI(uri)) {
+ throw new IllFormattedStatementException("site URL format is invalid: " + site);
+ }
+ }
+
+ private void validateAndroidAppTarget(@NonNull JSONObject androidAppTarget)
+ throws JSONException, IllFormattedStatementException {
+ final String packageName = androidAppTarget.getString("package_name");
+ if (!packageName.matches(AssetLinksGrammar.PACKAGE_NAME_PATTERN)) {
+ throw new IllFormattedStatementException("package_name is invalid: " + packageName);
+ }
+
+ final JSONArray certs = androidAppTarget.getJSONArray(
+ AssetLinksGrammar.GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS);
+ if (certs.length() == 0) {
+ throw new IllFormattedStatementException("At least one certificate must be present");
+ }
+ for (int i = 0; i < certs.length(); i++) {
+ final String cert = certs.getString(i);
+ if (!cert.matches(AssetLinksGrammar.SHA256_CERT_FINGERPRINT_PATTERN)) {
+ throw new IllFormattedStatementException("Ill-formatted certificate fingerprint: " + cert);
+ }
+ }
+ }
+
+ /** A callback to be invoked when a {@link StatementMatcher} match occurs */
+ public interface StatementMatcherCallback {
+ void onMatch(@NonNull StatementMatcher matcher, @NonNull JSONObject o) throws JSONException;
+ }
+
+ /** The base type for all {@link AssetLinksJSONParser} exceptions */
+ public static abstract class AssetLinksJSONParserException extends Exception {
+ protected AssetLinksJSONParserException() {}
+ protected AssetLinksJSONParserException(String message) { super(message); }
+ protected AssetLinksJSONParserException(String message, Throwable t) { super(message, t); }
+ }
+
+ /** Thrown when the Asset Links JSON is not well-formed */
+ public static class IllFormattedStatementException extends AssetLinksJSONParserException {
+ public IllFormattedStatementException(String message) { super(message); }
+ public IllFormattedStatementException(String message, Throwable t) { super(message, t); }
+ }
+
+ /** Thrown when the number of includes in the Asset Links tree exceeds the allowed limit */
+ public static class TooManyIncludesException extends AssetLinksJSONParserException {}
+
+ /** Thrown when a matcher generates an Exception during processing */
+ public static class MatcherException extends AssetLinksJSONParserException {
+ public MatcherException(String message, Throwable t) { super(message, t); }
+ }
+}
diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/StatementMatcher.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/StatementMatcher.java
new file mode 100644
index 0000000..d55a0fc
--- /dev/null
+++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/StatementMatcher.java
@@ -0,0 +1,374 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+package com.solana.digitalassetlinks;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.collection.ArrayMap;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A {@link StatementMatcher} is used to test a set of conditions against a
+ * Digital Asset Links statement
+ */
+public class StatementMatcher {
+ @Nullable private final String mRelation;
+ @Nullable private final String mTargetNamespace;
+ @NonNull private final ArrayMap mTargetKeyValues;
+
+ /**
+ * Construct a new {@link StatementMatcher}
+ * @param relation if specified, the relation string condition
+ * @param targetNamespace if specified, the target namespace condition
+ * @param targetKeyValues if specified, a map of target object names and corresponding value
+ * conditions
+ */
+ protected StatementMatcher(@Nullable String relation,
+ @Nullable String targetNamespace,
+ @NonNull ArrayMap targetKeyValues) {
+ if (relation != null && !relation.matches(AssetLinksGrammar.RELATION_PATTERN)) {
+ throw new IllegalArgumentException("relation does not match the expected format");
+ }
+
+ this.mRelation = relation;
+ this.mTargetNamespace = targetNamespace;
+ this.mTargetKeyValues = new ArrayMap<>(targetKeyValues);
+ }
+
+ /**
+ * Apply this {@link StatementMatcher} against the specified
+ * @param o the Asset Links statement object against which to perform matching
+ * @return true if all conditions of this {@link StatementMatcher} are satisfied, else false
+ * @throws IllegalArgumentException if o is not a well-formed Asset Links statement object
+ */
+ public boolean compare(@NonNull JSONObject o) {
+ try {
+ if (mRelation != null) {
+ final JSONArray relations = o.getJSONArray(AssetLinksGrammar.GRAMMAR_RELATION); // mandatory field
+ final int numRelations = relations.length();
+ if (numRelations == 0) {
+ throw new IllegalArgumentException("At least one relation must be present");
+ }
+ boolean relationMatched = false;
+ for (int i = 0; i < numRelations; i++) {
+ final String relation = relations.getString(i);
+ if (mRelation.equals(relation)) {
+ relationMatched = true;
+ break;
+ }
+ }
+ if (!relationMatched) {
+ return false;
+ }
+ }
+
+ if (mTargetNamespace != null || !mTargetKeyValues.isEmpty()) {
+ final JSONObject target = o.getJSONObject(AssetLinksGrammar.GRAMMAR_TARGET); // mandatory field
+
+ if (mTargetNamespace != null) {
+ final String namespace = target.getString(AssetLinksGrammar.GRAMMAR_NAMESPACE); // mandatory field
+ if (!mTargetNamespace.equals(namespace)) {
+ return false;
+ }
+ }
+
+ for (Map.Entry entry : mTargetKeyValues.entrySet()) {
+ if (!target.has(entry.getKey())) {
+ return false;
+ }
+
+ final Object valueObj = entry.getValue();
+ if (valueObj instanceof String) {
+ final String value = (String)valueObj;
+ final String targetValue = target.getString(entry.getKey());
+ if (!value.equals(targetValue)) {
+ return false;
+ }
+ } else if (valueObj instanceof AllowMatchInArray) {
+ final String value = ((AllowMatchInArray)valueObj).value;
+ final Object targetValueObj = target.get(entry.getKey());
+ if (targetValueObj instanceof JSONArray) {
+ final JSONArray targetValues = (JSONArray) targetValueObj;
+ boolean any = false;
+ for (int i = 0; i < targetValues.length(); i++) {
+ final String targetValue = targetValues.getString(i);
+ if (value.equals(targetValue)) {
+ any = true;
+ break;
+ }
+ }
+ if (!any) {
+ return false;
+ }
+ } else {
+ if (!value.equals(targetValueObj.toString())) {
+ return false;
+ }
+ }
+ } else if (valueObj instanceof URI) {
+ final URI value = canonicalizeURI((URI)valueObj);
+ final String targetValue = target.getString(entry.getKey());
+ try {
+ final URI targetURI = canonicalizeURI(URI.create(targetValue));
+ if (!value.equals(targetURI)) {
+ return false;
+ }
+ } catch (IllegalArgumentException e) {
+ // target value not a URI; skip it. Matchers aren't responsible for
+ // syntax validation of the model.
+ }
+ }
+ }
+ }
+ } catch (JSONException e) {
+ throw new IllegalArgumentException("Asset Links JSON object '" + o + "' is not well-formed", e);
+ }
+
+ return true;
+ }
+
+ // Removes default port numbers and the trailing FQDN '.' for comparison purposes
+ @NonNull
+ private URI canonicalizeURI(@NonNull URI uri) {
+ final boolean hasExplicitDefaultPort;
+ final String scheme = uri.getScheme();
+ final int port = uri.getPort();
+ if ("http".equals(scheme)) {
+ hasExplicitDefaultPort = (port == 80);
+ } else if ("https".equals(scheme)) {
+ hasExplicitDefaultPort = (port == 443);
+ } else {
+ hasExplicitDefaultPort = false;
+ }
+
+ final String host = uri.getHost();
+ final boolean isFullyQualifiedDomainName = (host != null && host.length() > 1
+ && host.charAt(host.length() - 1) == '.');
+
+ if (!hasExplicitDefaultPort && !isFullyQualifiedDomainName) {
+ return uri;
+ }
+
+ try {
+ return new URI(scheme,
+ uri.getUserInfo(),
+ isFullyQualifiedDomainName ? host.substring(0, host.length() - 1) : host,
+ hasExplicitDefaultPort ? -1 : port,
+ uri.getPath(),
+ uri.getQuery(),
+ uri.getFragment());
+ } catch (URISyntaxException e) {
+ throw new RuntimeException("Impossible when only modifying the port");
+ }
+ }
+
+ /**
+ * Create a {@link StatementMatcher} for the {@link AssetLinksGrammar#GRAMMAR_NAMESPACE_WEB}
+ * namespace
+ * @param relation the {@link AssetLinksGrammar#GRAMMAR_RELATION} to match
+ * @param site if non-null, the {@link AssetLinksGrammar#GRAMMAR_WEB_SITE} to match. This will
+ * be validated against the rules for the web domain.
+ * @return a {@link StatementMatcher}
+ * @throws IllegalArgumentException if site is non-null and fails validation
+ */
+ public static StatementMatcher createWebStatementMatcher(@NonNull String relation,
+ @Nullable URI site) {
+ final Builder builder = StatementMatcher.newBuilder()
+ .setRelation(relation)
+ .setTargetNamespace(AssetLinksGrammar.GRAMMAR_NAMESPACE_WEB);
+ if (site != null) {
+ if (!AssetLinksGrammar.isValidSiteURI(site)) {
+ throw new IllegalArgumentException("site is not a valid site URI");
+ }
+ builder.setTargetKeyValue(AssetLinksGrammar.GRAMMAR_WEB_SITE, site);
+ }
+ return builder.build();
+ }
+
+ /**
+ * Create a {@link StatementMatcher} for the
+ * {@link AssetLinksGrammar#GRAMMAR_NAMESPACE_ANDROID_APP} namespace
+ * @param relation the {@link AssetLinksGrammar#GRAMMAR_RELATION} to match
+ * @param packageName if non-null, the
+ * {@link AssetLinksGrammar#GRAMMAR_ANDROID_APP_PACKAGE_NAME} to match. This will be
+ * validated against the rules for the android_app domain.
+ * @param sha256CertFingerprint if non-null, the
+ * {@link AssetLinksGrammar#GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS} to match. This
+ * will be validated against the rules for the android_app domain.
+ * @return a {@link StatementMatcher}
+ * @throws IllegalArgumentException if packageName is non-null and fails validation, or if
+ * sha256CertFingerprint is non-null and fails validation.
+ */
+ public static StatementMatcher createAndroidAppStatementMatcher(@NonNull String relation,
+ @Nullable String packageName,
+ @Nullable String sha256CertFingerprint) {
+ final Builder builder = StatementMatcher.newBuilder()
+ .setRelation(relation)
+ .setTargetNamespace(AssetLinksGrammar.GRAMMAR_NAMESPACE_ANDROID_APP);
+ if (packageName != null) {
+ if (!packageName.matches(AssetLinksGrammar.PACKAGE_NAME_PATTERN)) {
+ throw new IllegalArgumentException("Invalid Android app package name");
+ }
+ builder.setTargetKeyValue(
+ AssetLinksGrammar.GRAMMAR_ANDROID_APP_PACKAGE_NAME, packageName);
+ }
+ if (sha256CertFingerprint != null) {
+ if (!sha256CertFingerprint.matches(AssetLinksGrammar.SHA256_CERT_FINGERPRINT_PATTERN)) {
+ throw new IllegalArgumentException("Invalid Android app certificate fingerprint");
+ }
+ builder.setTargetKeyValue(
+ AssetLinksGrammar.GRAMMAR_ANDROID_APP_SHA256_CERT_FINGERPRINTS,
+ sha256CertFingerprint, true);
+ }
+ return builder.build();
+ }
+
+ /**
+ * Create a new {@link Builder}
+ * @return a new {@link Builder}
+ */
+ @NonNull
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ StatementMatcher that = (StatementMatcher) o;
+ return Objects.equals(mRelation, that.mRelation) &&
+ Objects.equals(mTargetNamespace, that.mTargetNamespace) &&
+ mTargetKeyValues.equals(that.mTargetKeyValues);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mRelation, mTargetNamespace, mTargetKeyValues);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "StatementMatcher{" +
+ "mRelation='" + mRelation + '\'' +
+ ", mTargetNamespace='" + mTargetNamespace + '\'' +
+ ", mKeyValues=" + mTargetKeyValues +
+ '}';
+ }
+
+ /**
+ * Implements the Builder pattern for {@link StatementMatcher}
+ */
+ public static class Builder {
+ private String mRelation;
+ private String mTargetNamespace;
+ private final ArrayMap mKeyValues = new ArrayMap<>();
+
+ /**
+ * Construct a new {@link Builder}
+ */
+ public Builder() {}
+
+ /**
+ * Sets the relation condition for this {@link Builder}
+ * @param relation the relation condition for the {@link StatementMatcher}
+ * @return this {@link Builder}
+ */
+ @NonNull
+ public Builder setRelation(@Nullable String relation) {
+ mRelation = relation;
+ return this;
+ }
+
+ /**
+ * Sets the target namespace condition for this {@link Builder}
+ * @param targetNamespace the target namespace condition for the {@link StatementMatcher}
+ * @return this {@link Builder}
+ */
+ @NonNull
+ public Builder setTargetNamespace(@Nullable String targetNamespace) {
+ mTargetNamespace = targetNamespace;
+ return this;
+ }
+
+ /**
+ * Sets a target String key-value condition for this {@link Builder}
+ * @param key the target name for which to apply this condition
+ * @param value the target String value of this condition
+ * @return this {@link Builder}
+ */
+ @NonNull
+ public Builder setTargetKeyValue(@NonNull String key, @Nullable String value) {
+ return setTargetKeyValue(key, value, false);
+ }
+
+ /**
+ * Sets a target String key-value condition for this {@link Builder}
+ * @param key the target name for which to apply this condition
+ * @param value the target String value of this condition
+ * @param allowMatchInArray whether this match is allowed to occur within an array of values
+ * for key
+ * @return this {@link Builder}
+ */
+ @NonNull
+ public Builder setTargetKeyValue(@NonNull String key,
+ @Nullable String value,
+ boolean allowMatchInArray) {
+ if (value != null) {
+ if (allowMatchInArray) {
+ mKeyValues.put(key, new AllowMatchInArray(value));
+ } else {
+ mKeyValues.put(key, value);
+ }
+ } else {
+ mKeyValues.remove(key);
+ }
+ return this;
+ }
+
+ /**
+ * Sets a target {@link URI} key-value condition for this {@link Builder}
+ * @param key the target name for which to apply this condition
+ * @param value the target {@link URI} value of this condition
+ * @return this {@link Builder}
+ */
+ @NonNull
+ public Builder setTargetKeyValue(@NonNull String key, @Nullable URI value) {
+ if (value != null) {
+ mKeyValues.put(key, value);
+ } else {
+ mKeyValues.remove(key);
+ }
+ return this;
+ }
+
+ /**
+ * Construct a new {@link StatementMatcher} from the current state of this {@link Builder}
+ * @return a new {@link StatementMatcher}
+ */
+ @NonNull
+ public StatementMatcher build() {
+ return new StatementMatcher(mRelation, mTargetNamespace, mKeyValues);
+ }
+ }
+
+ private static final class AllowMatchInArray {
+ @NonNull
+ public final String value;
+
+ public AllowMatchInArray(@NonNull String value) {
+ this.value = value;
+ }
+ }
+}
diff --git a/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/URISourceVerifier.java b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/URISourceVerifier.java
new file mode 100644
index 0000000..38a4a3d
--- /dev/null
+++ b/digitalassetlinks/src/main/java/com/solana/digitalassetlinks/URISourceVerifier.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+package com.solana.digitalassetlinks;
+
+import androidx.annotation.NonNull;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+public abstract class URISourceVerifier {
+ private static final int HTTP_TIMEOUT_MS = 1000; // 1.0s
+ private static final int MAX_DOCUMENT_LENGTH = 50 * 1024; // 100KB (2 bytes per char)
+
+ private final AtomicBoolean mVerificationInProgress = new AtomicBoolean(false);
+ private final AtomicReference mHttpURLConnection = new AtomicReference<>(null);
+
+ /**
+ * Given an HTTP or HTTPS absolute {@link URI}, constructs the corresponding well-known Asset
+ * Links {@link URI}. For e.g., https://www.solana.com/example will return
+ * https://www.solana.com/.well-known/assetlinks.json.
+ * @param baseURI the base HTTP or HTTPS {@link URI}
+ * @return the corresponding well-known Asset Links {@link URI}
+ * @throws IllegalArgumentException if baseURI is not an hierarchical HTTP or HTTPS URI
+ */
+ @NonNull
+ public static URI getWellKnownAssetLinksURI(@NonNull URI baseURI) {
+ if (!baseURI.isAbsolute() || baseURI.isOpaque()) {
+ throw new IllegalArgumentException("Source URI must be absolute and hierarchical");
+ }
+
+ final String scheme = baseURI.getScheme();
+ if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) {
+ throw new IllegalArgumentException("Source URI must be HTTP or HTTPS");
+ }
+
+ try {
+ return new URI(baseURI.getScheme(), baseURI.getAuthority(), "/.well-known/assetlinks.json", null, null);
+ } catch (URISyntaxException e) {
+ throw new IllegalArgumentException("Unable to construct well-known URI", e);
+ }
+ }
+
+ /**
+ * Load the Asset Links tree from sourceURI, verify its contents, and run the provided
+ * {@link StatementMatcher}s with accompanying
+ * {@link AssetLinksJSONParser.StatementMatcherCallback}s against it. This function does not
+ * establish any relations between the source URI and any target; sub-classes should employ
+ * suitable {@link StatementMatcher}s to establish those relationships.
+ * @param sourceURI the {@link URI} from which to begin Asset Links parsing
+ * @param matchers the set of {@link StatementMatcherWithCallback}s to match against the Asset
+ * Links tree
+ * @return true if verification completed without any warnings, or false if non-fatal warnings
+ * occurred during parsing. This value does not imply that verification failed; just
+ * whether there were syntax or parsing errors that do not impact the validity of the
+ * verification result. It is primarily of interest for automated testing.
+ * @throws IllegalArgumentException if sourceURI is not a HTTP or HTTPS hierarchical URI
+ * @throws CouldNotVerifyException if verification could not be performed for any reason (such
+ * as malformed Asset Links files, network unavailability, too many includes, etc).
+ */
+ protected boolean verify(@NonNull URI sourceURI, StatementMatcherWithCallback... matchers)
+ throws CouldNotVerifyException {
+ if (!mVerificationInProgress.compareAndSet(false, true)) {
+ throw new IllegalStateException("verification already in progress");
+ }
+
+ // Create and configure an AssetLinksJSONParser object
+ final AssetLinksJSONParser parser = new AssetLinksJSONParser();
+ for (StatementMatcherWithCallback matcher : matchers) {
+ parser.addStatementMatcher(matcher.statementMatcher, matcher.callback);
+ }
+
+ // Derive the initial Asset Links JSON URI
+ final URI assetLinksURI = getWellKnownAssetLinksURI(sourceURI);
+
+ // Start the AssetLinksJSONParser, and respond to document requests
+ URI loadDocumentURI = parser.start(assetLinksURI);
+ try {
+ while (loadDocumentURI != null) {
+ throwIfCancelled();
+
+ final String document;
+ try {
+ document = loadDocument(loadDocumentURI.toURL());
+ } catch (IOException | UnsupportedOperationException e) {
+ throw new CouldNotVerifyException("Failed loading an Asset Links document " + loadDocumentURI, e);
+ }
+
+ throwIfCancelled();
+
+ loadDocumentURI = parser.onDocumentLoaded(loadDocumentURI, document);
+ }
+ } catch (AssetLinksJSONParser.IllFormattedStatementException |
+ AssetLinksJSONParser.TooManyIncludesException |
+ AssetLinksJSONParser.MatcherException e) {
+ throw new CouldNotVerifyException("Asset Links statement list processing failed", e);
+ }
+
+ mVerificationInProgress.set(false);
+ return !parser.isWarning();
+ }
+
+ /**
+ * Attempt to cancel a running {@link #verify(URI, StatementMatcherWithCallback...)} for this
+ * {@link URISourceVerifier}. This will return immediately, however the verification may
+ * continue for a short amount of time (until the next cancellation point).
+ */
+ public void cancel() {
+ mVerificationInProgress.set(false);
+ final HttpURLConnection URIConnection = mHttpURLConnection.get();
+ if (URIConnection != null) {
+ URIConnection.disconnect();
+ }
+ }
+
+ private void throwIfCancelled() throws CouldNotVerifyException {
+ if (!mVerificationInProgress.get()) {
+ throw new CouldNotVerifyException("Asset Links verification cancelled");
+ }
+ }
+
+ // N.B. protected only so compatibility test suite harnesses can override this to provide mock
+ // URL objects
+ @NonNull
+ protected String loadDocument(@NonNull URL documentURL) throws IOException {
+ final HttpURLConnection URIConnection = (HttpURLConnection)documentURL.openConnection();
+ mHttpURLConnection.set(URIConnection);
+
+ try {
+ URIConnection.setConnectTimeout(HTTP_TIMEOUT_MS);
+ URIConnection.setReadTimeout(HTTP_TIMEOUT_MS);
+ URIConnection.setInstanceFollowRedirects(false);
+ URIConnection.connect();
+ final int responseCode = URIConnection.getResponseCode();
+ if (responseCode != HttpURLConnection.HTTP_OK) {
+ throw new UnsupportedOperationException("Asset Links document fetch failed; actual=" + responseCode + ", expected=" + HttpURLConnection.HTTP_OK);
+ }
+ final String mimeType = URIConnection.getContentType();
+ if (!"application/json".equals(mimeType)) {
+ throw new UnsupportedOperationException("Asset Links document type incorrect; actual=" + mimeType + ", expected=application/json");
+ }
+ StringBuilder documentBuilder = new StringBuilder();
+ final InputStream rawInputStream = URIConnection.getInputStream();
+ final InputStreamReader charInputStream = new InputStreamReader(rawInputStream, StandardCharsets.UTF_8);
+ final char[] buf = new char[2048];
+ while (true) {
+ final int count = charInputStream.read(buf);
+ if (count == -1) break;
+ documentBuilder.append(buf, 0, count);
+ final int documentLength = documentBuilder.length();
+ if (documentLength > MAX_DOCUMENT_LENGTH) {
+ throw new UnsupportedOperationException("Asset Links document content too long; actual=" + documentLength + ", max=" + MAX_DOCUMENT_LENGTH);
+ }
+ }
+ return documentBuilder.toString();
+ } finally {
+ URIConnection.disconnect();
+ mHttpURLConnection.set(null);
+ }
+ }
+
+ /**
+ * A data class wrapper to bind a {@link StatementMatcher} and a
+ * {@link AssetLinksJSONParser.StatementMatcherCallback} together, for matching against an
+ * Asset Links tree.
+ */
+ protected static final class StatementMatcherWithCallback {
+ @NonNull
+ public final StatementMatcher statementMatcher;
+
+ @NonNull
+ public final AssetLinksJSONParser.StatementMatcherCallback callback;
+
+ public StatementMatcherWithCallback(@NonNull StatementMatcher statementMatcher,
+ @NonNull AssetLinksJSONParser.StatementMatcherCallback callback) {
+ this.statementMatcher = statementMatcher;
+ this.callback = callback;
+ }
+ }
+
+ /** Indicates that the Asset Links tree could not be verified for any reason */
+ public static class CouldNotVerifyException extends Exception {
+ public CouldNotVerifyException(String message) { super(message); }
+ public CouldNotVerifyException(String message, Throwable t) { super(message, t); }
+ }
+}
diff --git a/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/AndroidAppPackageVerifierUnitTests.java b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/AndroidAppPackageVerifierUnitTests.java
new file mode 100644
index 0000000..339a5c4
--- /dev/null
+++ b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/AndroidAppPackageVerifierUnitTests.java
@@ -0,0 +1,267 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+package com.solana.digitalassetlinks;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+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.annotation.NonNull;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk={ RobolectricConfig.MIN_SDK, Build.VERSION_CODES.P, RobolectricConfig.CUR_SDK }) // SigningInfo introduced in P
+public class AndroidAppPackageVerifierUnitTests {
+ // SHA256(0x01) == "4B:F5:12:2F:34:45:54:C5:3B:DE:2E:BB:8C:D2:B7:E3:D1:60:0A:D6:31:C3:85:A5:D7:CC:E2:3C:77:85:45:9A"
+ private static final byte[] CERT_1 = new byte[] { 0x01 };
+
+ // SHA256(0x02) == "DB:C1:B4:C9:00:FF:E4:8D:57:5B:5D:A5:C6:38:04:01:25:F6:5D:B0:FE:3E:24:49:4B:76:EA:98:64:57:D9:86"
+ private static final byte[] CERT_2 = new byte[] { 0x02 };
+
+ // SHA256(0x03) == "08:4F:ED:08:B9:78:AF:4D:7D:19:6A:74:46:A8:6B:58:00:9E:63:6B:61:1D:B1:62:11:B6:5A:9A:AD:FF:29:C5"
+ private static final byte[] CERT_3 = new byte[] { 0x03 };
+
+ private static final String ANDROID_APP_STATEMENT_LIST_CERTS_2_3 =
+ "[{\"relation\": [\"delegate_permission/common.handle_all_urls\"], " +
+ "\"target\": {" +
+ "\"namespace\": \"android_app\", " +
+ "\"package_name\": \"com.test.sample\", " +
+ "\"sha256_cert_fingerprints\": [" +
+ "\"DB:C1:B4:C9:00:FF:E4:8D:57:5B:5D:A5:C6:38:04:01:25:F6:5D:B0:FE:3E:24:49:4B:76:EA:98:64:57:D9:86\", " + // SHA256(CERT_2)
+ "\"08:4F:ED:08:B9:78:AF:4D:7D:19:6A:74:46:A8:6B:58:00:9E:63:6B:61:1D:B1:62:11:B6:5A:9A:AD:FF:29:C5\"" + // SHA256(CERT_3)
+ "]}}]";
+
+ @Test
+ public void testAppPackageVerificationSuccess()
+ throws AndroidAppPackageVerifier.CouldNotVerifyPackageException {
+ ArrayList mockWebContent = new ArrayList<>();
+ mockWebContent.add(new MockWebContentServer.Content(
+ URI.create("https://www.test.com/.well-known/assetlinks.json"),
+ HttpURLConnection.HTTP_OK,
+ "application/json",
+ ANDROID_APP_STATEMENT_LIST_CERTS_2_3));
+
+ final PackageManager pm = mockPackageManagerFactory(
+ "com.test.sample", new byte[][] { CERT_2, CERT_1 }, false);
+
+ final AndroidAppPackageVerifierHarness verifier =
+ new AndroidAppPackageVerifierHarness(pm, mockWebContent);
+ boolean verified = verifier.verify("com.test.sample", URI.create("https://www.test.com"));
+ assertTrue(verified);
+ }
+
+ @Test
+ public void testAppPackageVerificationNoAssetLinks() {
+ ArrayList mockWebContent = new ArrayList<>();
+ mockWebContent.add(new MockWebContentServer.Content(
+ URI.create("https://www.test.com/.well-known/assetlinks.json"),
+ HttpURLConnection.HTTP_OK,
+ "application/json",
+ ANDROID_APP_STATEMENT_LIST_CERTS_2_3));
+
+ final PackageManager pm = mockPackageManagerFactory(
+ "com.test.sample", new byte[][] { CERT_2, CERT_1 }, false);
+
+ final AndroidAppPackageVerifierHarness verifier =
+ new AndroidAppPackageVerifierHarness(pm, mockWebContent);
+ assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class,
+ () ->verifier.verify("com.test.sample", URI.create("https://www.other.com")));
+ }
+
+ @Test
+ public void testAppPackageVerificationNoMatchingPackageInAssetLinks()
+ throws AndroidAppPackageVerifier.CouldNotVerifyPackageException {
+ ArrayList mockWebContent = new ArrayList<>();
+ mockWebContent.add(new MockWebContentServer.Content(
+ URI.create("https://www.test.com/.well-known/assetlinks.json"),
+ HttpURLConnection.HTTP_OK,
+ "application/json",
+ ANDROID_APP_STATEMENT_LIST_CERTS_2_3));
+
+ final PackageManager pm = mockPackageManagerFactory(
+ "com.test.other", new byte[][] { CERT_2, CERT_1 }, false);
+
+ final AndroidAppPackageVerifierHarness verifier =
+ new AndroidAppPackageVerifierHarness(pm, mockWebContent);
+ boolean verified = verifier.verify("com.test.other", URI.create("https://www.test.com"));
+ assertFalse(verified);
+ }
+
+ @Test
+ public void testAppPackageVerificationInsecureAssetLinks() {
+ ArrayList mockWebContent = new ArrayList<>();
+ mockWebContent.add(new MockWebContentServer.Content(
+ URI.create("http://www.test.com/.well-known/assetlinks.json"),
+ HttpURLConnection.HTTP_OK,
+ "application/json",
+ ANDROID_APP_STATEMENT_LIST_CERTS_2_3));
+
+ final PackageManager pm = mockPackageManagerFactory(
+ "com.test.sample", new byte[][] { CERT_2, CERT_1 }, false);
+
+ final AndroidAppPackageVerifierHarness verifier =
+ new AndroidAppPackageVerifierHarness(pm, mockWebContent);
+ assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class,
+ () ->verifier.verify("com.test.sample", URI.create("http://www.test.com")));
+ }
+
+ @Test
+ public void testAppPackageVerificationNoMatchingCertificate()
+ throws AndroidAppPackageVerifier.CouldNotVerifyPackageException {
+ ArrayList mockWebContent = new ArrayList<>();
+ mockWebContent.add(new MockWebContentServer.Content(
+ URI.create("https://www.test.com/.well-known/assetlinks.json"),
+ HttpURLConnection.HTTP_OK,
+ "application/json",
+ ANDROID_APP_STATEMENT_LIST_CERTS_2_3));
+
+ final PackageManager pm = mockPackageManagerFactory(
+ "com.test.sample", new byte[][] { CERT_1 }, false);
+
+ final AndroidAppPackageVerifierHarness verifier =
+ new AndroidAppPackageVerifierHarness(pm, mockWebContent);
+ boolean verified = verifier.verify("com.test.sample", URI.create("https://www.test.com"));
+ assertFalse(verified);
+ }
+
+ @Test
+ public void testAppPackageVerificationMultipleSignersSuccess()
+ throws AndroidAppPackageVerifier.CouldNotVerifyPackageException {
+ ArrayList mockWebContent = new ArrayList<>();
+ mockWebContent.add(new MockWebContentServer.Content(
+ URI.create("https://www.test.com/.well-known/assetlinks.json"),
+ HttpURLConnection.HTTP_OK,
+ "application/json",
+ ANDROID_APP_STATEMENT_LIST_CERTS_2_3));
+
+ final PackageManager pm = mockPackageManagerFactory(
+ "com.test.sample", new byte[][] { CERT_2, CERT_3 }, true);
+
+ final AndroidAppPackageVerifierHarness verifier =
+ new AndroidAppPackageVerifierHarness(pm, mockWebContent);
+ boolean verified = verifier.verify("com.test.sample", URI.create("https://www.test.com"));
+ assertTrue(verified);
+ }
+
+ @Test
+ public void testAppPackageVerificationMultipleSignersMissingSigner()
+ throws AndroidAppPackageVerifier.CouldNotVerifyPackageException {
+ ArrayList mockWebContent = new ArrayList<>();
+ mockWebContent.add(new MockWebContentServer.Content(
+ URI.create("https://www.test.com/.well-known/assetlinks.json"),
+ HttpURLConnection.HTTP_OK,
+ "application/json",
+ ANDROID_APP_STATEMENT_LIST_CERTS_2_3));
+
+ final PackageManager pm = mockPackageManagerFactory(
+ "com.test.sample", new byte[][] { CERT_1, CERT_2 }, true);
+
+ final AndroidAppPackageVerifierHarness verifier =
+ new AndroidAppPackageVerifierHarness(pm, mockWebContent);
+ boolean verified = verifier.verify("com.test.sample", URI.create("https://www.test.com"));
+ assertFalse(verified);
+ }
+
+ @Test
+ public void testAppPackageVerificationNoMatchingPackageInPackageManager() {
+ ArrayList mockWebContent = new ArrayList<>();
+ mockWebContent.add(new MockWebContentServer.Content(
+ URI.create("https://www.test.com/.well-known/assetlinks.json"),
+ HttpURLConnection.HTTP_OK,
+ "application/json",
+ ANDROID_APP_STATEMENT_LIST_CERTS_2_3));
+
+ final PackageManager pm = mock(PackageManager.class);
+ try {
+ when(pm.getPackageInfo(eq("com.test.sample"), anyInt()))
+ .thenThrow(new PackageManager.NameNotFoundException());
+ } catch (PackageManager.NameNotFoundException ignored) {}
+
+ final AndroidAppPackageVerifierHarness verifier =
+ new AndroidAppPackageVerifierHarness(pm, mockWebContent);
+ assertThrows(AndroidAppPackageVerifier.CouldNotVerifyPackageException.class,
+ () ->verifier.verify("com.test.sample", URI.create("https://www.test.com")));
+ }
+
+ private static PackageManager mockPackageManagerFactory(@NonNull String packageName,
+ @NonNull byte[][] certificates,
+ boolean multipleSigners) {
+ if (certificates.length == 0) {
+ throw new IllegalArgumentException("at least 1 certificate required");
+ } else if (multipleSigners && certificates.length == 1) {
+ throw new IllegalArgumentException("multipleSigners requires at least 2 certificates");
+ }
+
+ final PackageInfo pi = new PackageInfo();
+ final int piFlags;
+ final Signature[] certs = new Signature[certificates.length];
+ for (int i = 0; i < certificates.length; i++) {
+ certs[i] = new Signature(certificates[i]);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ final SigningInfo si = mock(SigningInfo.class);
+ when(si.hasMultipleSigners()).thenReturn(multipleSigners);
+ when(si.hasPastSigningCertificates()).thenReturn(!multipleSigners && certs.length >= 2);
+ when(si.getSigningCertificateHistory()).thenReturn(multipleSigners ? null : certs);
+ when(si.getApkContentsSigners())
+ .thenReturn(multipleSigners ? certs : new Signature[]{certs[0]});
+
+ piFlags = PackageManager.GET_SIGNING_CERTIFICATES;
+ pi.signingInfo = si;
+ //noinspection deprecation
+ pi.signatures = null;
+ } else {
+ piFlags = PackageManager.GET_SIGNATURES;
+ pi.signatures = (multipleSigners ? certs : new Signature[] { certs[0] });
+ }
+
+ final PackageManager pm = mock(PackageManager.class);
+ try {
+ when(pm.getPackageInfo(eq(packageName), eq(piFlags))).thenReturn(pi);
+ } catch (PackageManager.NameNotFoundException ignored) {}
+
+ return pm;
+ }
+
+ private static class AndroidAppPackageVerifierHarness extends AndroidAppPackageVerifier {
+ @NonNull
+ private final MockWebContentServer server;
+
+ public AndroidAppPackageVerifierHarness(
+ @NonNull PackageManager pm,
+ @NonNull List mockWebContent) {
+ super(pm);
+ server = new MockWebContentServer(mockWebContent);
+ }
+
+ @NonNull
+ @Override
+ protected String loadDocument(@NonNull URL documentURL) throws IOException {
+ try {
+ return super.loadDocument(server.serve(documentURL.toURI()));
+ } catch (URISyntaxException e) {
+ throw new RuntimeException("Test harness error converting URL to URI", e);
+ }
+ }
+ }
+}
diff --git a/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/DigitalAssetLinksCompatibilityTestSuite.java b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/DigitalAssetLinksCompatibilityTestSuite.java
new file mode 100644
index 0000000..a21461e
--- /dev/null
+++ b/digitalassetlinks/src/test/java/com/solana/digitalassetlinks/DigitalAssetLinksCompatibilityTestSuite.java
@@ -0,0 +1,512 @@
+/*
+ * Copyright (c) 2022 Solana Mobile Inc.
+ */
+
+package com.solana.digitalassetlinks;
+
+import static org.junit.Assert.*;
+
+import androidx.annotation.NonNull;
+
+import com.google.digitalassetlinks.v1.MessagesProto;
+import com.google.digitalassetlinks.v1.testproto.TestProto;
+import com.google.protobuf.AbstractMessage;
+import com.google.protobuf.Message;
+import com.google.protobuf.TextFormat;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.Assume;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(ParameterizedRobolectricTestRunner.class)
+@Config(sdk={ RobolectricConfig.MIN_SDK, RobolectricConfig.CUR_SDK })
+public class DigitalAssetLinksCompatibilityTestSuite {
+ // Change this value to false to run against the real hosted Digital Asset Links content
+ private static final boolean RUN_LOCALLY = true;
+
+ private static final File BASE_PATH = new File("external/digitalassetlinks/compatibility-tests/v1");
+
+ private static final File[] SOURCES = new File[] {
+ new File(BASE_PATH, "1000-query-parsing/1000-list-source.pb"),
+ new File(BASE_PATH, "1000-query-parsing/1100-list-relation.pb"),
+ new File(BASE_PATH, "1000-query-parsing/1200-check-source.pb"),
+ new File(BASE_PATH, "1000-query-parsing/1300-check-relation.pb"),
+ new File(BASE_PATH, "1000-query-parsing/1400-check-target.pb"),
+ new File(BASE_PATH, "2000-web-statement-list-parsing/2000-general.pb"),
+ new File(BASE_PATH, "2000-web-statement-list-parsing/2100-relations.pb"),
+ new File(BASE_PATH, "2000-web-statement-list-parsing/2200-web-targets.pb"),
+ new File(BASE_PATH, "2000-web-statement-list-parsing/2300-android-targets.pb"),
+ new File(BASE_PATH, "3000-android-statement-list-parsing/3000-general.pb"),
+ new File(BASE_PATH, "3000-android-statement-list-parsing/3100-relations.pb"),
+ new File(BASE_PATH, "3000-android-statement-list-parsing/3200-web-targets.pb"),
+ new File(BASE_PATH, "3000-android-statement-list-parsing/3300-android-targets.pb"),
+ new File(BASE_PATH, "4000-query-matching/4000-list-source.pb"),
+ new File(BASE_PATH, "4000-query-matching/4100-list-relation.pb"),
+ new File(BASE_PATH, "4000-query-matching/4200-check-source.pb"),
+ new File(BASE_PATH, "4000-query-matching/4300-check-relation.pb"),
+ new File(BASE_PATH, "4000-query-matching/4400-check-target.pb"),
+ new File(BASE_PATH, "5000-include-file-processing/5000-include-file-processing.pb"),
+ };
+
+ @NonNull
+ @ParameterizedRobolectricTestRunner.Parameters(name="{0} -> {1}")
+ public static Iterable