diff --git a/library/java/net/openid/appauth/Utils.java b/library/java/net/openid/appauth/Utils.java
index c78ede27..6f2cdc5a 100644
--- a/library/java/net/openid/appauth/Utils.java
+++ b/library/java/net/openid/appauth/Utils.java
@@ -22,7 +22,7 @@
 /**
  * Utility class for common operations.
  */
-class Utils {
+public class Utils {
     private static final int INITIAL_READ_BUFFER_SIZE = 1024;
 
     private Utils() {
diff --git a/library/java/net/openid/appauth/app2app/CertificateFingerprintEncoding.java b/library/java/net/openid/appauth/app2app/CertificateFingerprintEncoding.java
new file mode 100644
index 00000000..6b0ae4b3
--- /dev/null
+++ b/library/java/net/openid/appauth/app2app/CertificateFingerprintEncoding.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.openid.appauth.app2app;
+
+import android.util.Base64;
+import androidx.annotation.NonNull;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import java.util.HashSet;
+import java.util.Set;
+
+final class CertificateFingerprintEncoding {
+
+    private static final int DECIMAL = 10;
+    private static final int HEXADECIMAL = 16;
+    private static final int HALF_BYTE = 4;
+
+    private CertificateFingerprintEncoding() {}
+
+    /**
+     * This method takes the certificate fingerprints from the '/.well-known/assetlinks.json' file
+     * and decodes it in the correct way to compare the hashes with the ones found on the device.
+     */
+    @NonNull
+    protected static Set<String> certFingerprintsToDecodedString(
+            @NonNull JSONArray certFingerprints) {
+        Set<String> hashes = new HashSet<>();
+
+        for (int i = 0; i < certFingerprints.length(); i++) {
+            try {
+                byte[] byteArray = hexStringToByteArray(certFingerprints.get(i).toString());
+                String str = Base64.encodeToString(byteArray, DECIMAL);
+                hashes.add(str);
+            } catch (JSONException e) {
+                e.printStackTrace();
+            }
+        }
+
+        return hashes;
+    }
+
+    /**
+     * This method converts a hex string that is separated by colons into a ByteArray.
+     *
+     * <p>Example hexString: 4F:69:88:01:...
+     */
+    @NonNull
+    private static byte[] hexStringToByteArray(@NonNull String hexString) {
+        String[] hexValues = hexString.split(":");
+        byte[] byteArray = new byte[hexValues.length];
+        String str;
+        int tmp = 0;
+
+        for (int i = 0; i < hexValues.length; ++i) {
+            str = hexValues[i];
+            tmp = 0;
+            tmp = hexValue(str.charAt(0));
+            tmp <<= HALF_BYTE;
+            tmp |= hexValue(str.charAt(1));
+            byteArray[i] = (byte) tmp;
+        }
+
+        return byteArray;
+    }
+
+    /** Converts a single hex digit into its decimal value. */
+    private static int hexValue(char hexChar) {
+        int digit = Character.digit(hexChar, HEXADECIMAL);
+        if (digit < 0) {
+            throw new IllegalArgumentException("Invalid hex char " + hexChar);
+        } else {
+            return digit;
+        }
+    }
+}
diff --git a/library/java/net/openid/appauth/app2app/README.md b/library/java/net/openid/appauth/app2app/README.md
new file mode 100644
index 00000000..7711da9c
--- /dev/null
+++ b/library/java/net/openid/appauth/app2app/README.md
@@ -0,0 +1,5 @@
+# App2App Redirection
+
+Further information about the ``app2app`` package
+can be found [here](https://github.com/oauthstuff/app2app-evolution/blob/master/AppAuth-Integration.md)
+and [here](https://github.com/oauthstuff/app2app-evolution).
diff --git a/library/java/net/openid/appauth/app2app/RedirectSession.java b/library/java/net/openid/appauth/app2app/RedirectSession.java
new file mode 100644
index 00000000..72fcfa2b
--- /dev/null
+++ b/library/java/net/openid/appauth/app2app/RedirectSession.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.openid.appauth.app2app;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.annotation.NonNull;
+
+import org.json.JSONArray;
+
+import java.util.Set;
+
+/** Class to hold all important information to perform a secure redirection. */
+class RedirectSession {
+
+    private Context mContext;
+    private Uri mUri;
+    private String mBasePackageName = "";
+    private Set<String> mBaseCertFingerprints;
+    private JSONArray mAssetLinksFile = null;
+
+    protected RedirectSession(@NonNull Context context, @NonNull Uri uri) {
+        this.mContext = context;
+        this.mUri = uri;
+    }
+
+    @NonNull
+    protected Context getContext() {
+        return mContext;
+    }
+
+    protected void setContext(@NonNull Context context) {
+        this.mContext = context;
+    }
+
+    @NonNull
+    protected Uri getUri() {
+        return mUri;
+    }
+
+    protected void setUri(@NonNull Uri uri) {
+        this.mUri = uri;
+    }
+
+    @NonNull
+    protected String getBasePackageName() {
+        return mBasePackageName;
+    }
+
+    protected void setBasePackageName(@NonNull String basePackageName) {
+        this.mBasePackageName = basePackageName;
+    }
+
+    protected Set<String> getBaseCertFingerprints() {
+        return mBaseCertFingerprints;
+    }
+
+    protected void setBaseCertFingerprints(Set<String> baseCertFingerprints) {
+        this.mBaseCertFingerprints = baseCertFingerprints;
+    }
+
+    public JSONArray getAssetLinksFile() {
+        return mAssetLinksFile;
+    }
+
+    public void setAssetLinksFile(JSONArray assetLinksFile) {
+        this.mAssetLinksFile = assetLinksFile;
+    }
+}
diff --git a/library/java/net/openid/appauth/app2app/SecureRedirection.java b/library/java/net/openid/appauth/app2app/SecureRedirection.java
new file mode 100644
index 00000000..0f155879
--- /dev/null
+++ b/library/java/net/openid/appauth/app2app/SecureRedirection.java
@@ -0,0 +1,325 @@
+/*
+ * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.openid.appauth.app2app;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.pm.Signature;
+import android.content.pm.SigningInfo;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.util.Pair;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import androidx.browser.customtabs.CustomTabsIntent;
+
+import net.openid.appauth.Utils;
+import net.openid.appauth.browser.BrowserAllowList;
+import net.openid.appauth.browser.BrowserDescriptor;
+import net.openid.appauth.browser.BrowserSelector;
+import net.openid.appauth.browser.VersionedBrowserMatcher;
+import net.openid.appauth.connectivity.DefaultConnectionBuilder;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public final class SecureRedirection {
+
+    private SecureRedirection() {}
+
+    /**
+     * This method redirects an user securely from one app to another with a given URL. For this to
+     * work it is required that the "/.well-known/assetlinks.json" file is correctly set up for this
+     * domain and that the target app has an intent-filter for this URL.
+     */
+    public static void secureRedirection(@NonNull Context context, @NonNull Uri uri) {
+        getAssetLinksFile(new RedirectSession(context, uri));
+    }
+
+    /** This function retrieves the '/.well-known/assetlinks.json' file from the given domain. */
+    private static void getAssetLinksFile(@NonNull final RedirectSession redirectSession) {
+        new DownloadAssetLinksFile().execute(redirectSession);
+    }
+
+    private static class DownloadAssetLinksFile
+            extends AsyncTask<RedirectSession, Void, RedirectSession> {
+
+        @Override
+        protected RedirectSession doInBackground(RedirectSession... redirectSessions) {
+            RedirectSession redirectSession = redirectSessions[0];
+            Uri uri =
+                    Uri.parse(
+                            redirectSession.getUri().getScheme()
+                                    + "://"
+                                    + redirectSession.getUri().getHost()
+                                    + ":"
+                                    + redirectSession.getUri().getPort()
+                                    + "/.well-known/assetlinks.json");
+
+            InputStream is = null;
+            try {
+                HttpURLConnection conn = DefaultConnectionBuilder.INSTANCE.openConnection(uri);
+                conn.setRequestMethod("GET");
+                conn.setDoInput(true);
+                conn.connect();
+
+                is = conn.getInputStream();
+                JSONArray response = new JSONArray(Utils.readInputStream(is));
+                redirectSession.setAssetLinksFile(response);
+
+            } catch (IOException e) {
+                redirectSession.setAssetLinksFile(null);
+            } catch (JSONException e) {
+                redirectSession.setAssetLinksFile(null);
+            } finally {
+                Utils.closeQuietly(is);
+            }
+            return redirectSession;
+        }
+
+        @Override
+        protected void onPostExecute(RedirectSession redirectSession) {
+            if (redirectSession.getAssetLinksFile() != null) {
+                JSONArray baseCertFingerprints =
+                        findInstalledApp(redirectSession, redirectSession.getAssetLinksFile());
+
+                redirectSession.setBaseCertFingerprints(
+                        CertificateFingerprintEncoding.certFingerprintsToDecodedString(
+                                baseCertFingerprints));
+
+                doRedirection(redirectSession);
+            } else {
+                System.err.println(
+                        "Failed to fetch '/.well-known/assetlinks.json' from domain "
+                                + "'${redirectSession.uri.host}'\nError: ${error}");
+                redirectToWeb(redirectSession.getContext(), redirectSession.getUri());
+            }
+        }
+    }
+
+    /**
+     * Find a suitable installed app to open the URI and return the signing certificate fingerprints
+     * for this app. If no such app is found, the signing certificate fingerprints array and the
+     * package name will be empty.
+     *
+     * @param redirectSession
+     * @param assetLinks
+     * @return
+     */
+    @NonNull
+    private static JSONArray findInstalledApp(
+            @NonNull RedirectSession redirectSession, @NonNull JSONArray assetLinks) {
+        Pair<Set<String>, Map<String, JSONArray>> basePair =
+                getBaseValuesFromAssetLinksFile(assetLinks);
+        Set<String> foundPackageNames = getPackageNamesForIntent(redirectSession);
+
+        // Intersect the set of installed apps with the set of apps
+        // defined in the '/.well-known/assetlinks.json' file.
+        basePair.first.retainAll(foundPackageNames);
+
+        if (basePair.first.iterator().hasNext()) {
+            redirectSession.setBasePackageName(basePair.first.iterator().next());
+        } else {
+            redirectSession.setBasePackageName("");
+        }
+
+        JSONArray returnValue = basePair.second.get(redirectSession.getBasePackageName());
+        if (returnValue != null) {
+            return returnValue;
+        }
+        return new JSONArray();
+    }
+
+    /**
+     * Extract the package names and the certificate fingerprints from the
+     * '/.well-known/assetlinks.json' file.
+     *
+     * @param assetLinks
+     * @return
+     */
+    @NonNull
+    private static Pair<Set<String>, Map<String, JSONArray>> getBaseValuesFromAssetLinksFile(
+            @NonNull JSONArray assetLinks) {
+        Set<String> basePackageNames = new HashSet<>();
+        Map<String, JSONArray> baseCertFingerprints = new HashMap<>();
+        try {
+            for (int i = 0; i < assetLinks.length(); i++) {
+                JSONObject jsonObject = (JSONObject) assetLinks.get(i);
+                JSONObject target = (JSONObject) jsonObject.get("target");
+                String basePackageName = target.get("package_name").toString();
+                JSONArray baseCertFingerprint = (JSONArray) target.get("sha256_cert_fingerprints");
+
+                basePackageNames.add(basePackageName);
+                baseCertFingerprints.put(basePackageName, baseCertFingerprint);
+            }
+        } catch (JSONException exception) {
+            exception.printStackTrace();
+        }
+
+        return new Pair<>(basePackageNames, baseCertFingerprints);
+    }
+
+    /**
+     * This method uses the Android Package Manager to find all apps that have an intent-filter for
+     * the given URI.
+     *
+     * @param redirectSession
+     * @return
+     */
+    @NonNull
+    private static Set<String> getPackageNamesForIntent(@NonNull RedirectSession redirectSession) {
+        /*
+           Source: https://stackoverflow.com/questions/11904158/can-i-disable-an-option-when-i-call-intent-action-view
+        */
+        Intent intent = new Intent(Intent.ACTION_VIEW, redirectSession.getUri());
+        intent.addCategory(Intent.CATEGORY_DEFAULT);
+        intent.addCategory(Intent.CATEGORY_BROWSABLE);
+
+        List<ResolveInfo> infos =
+                redirectSession
+                        .getContext()
+                        .getPackageManager()
+                        .queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER);
+
+        Set<String> packageNames = new HashSet<>();
+        for (ResolveInfo info : infos) {
+            packageNames.add(info.activityInfo.packageName);
+        }
+        return packageNames;
+    }
+
+    /**
+     * This method checks whether the legit app is installed and either redirect the user to this
+     * app or to the default browser.
+     */
+    private static void doRedirection(@NonNull RedirectSession redirectSession) {
+        if (!redirectSession.getBasePackageName().isEmpty() && isAppLegit(redirectSession)) {
+            Intent redirectIntent = new Intent(Intent.ACTION_VIEW, redirectSession.getUri());
+            redirectIntent.setPackage(redirectSession.getBasePackageName());
+            redirectSession.getContext().startActivity(redirectIntent);
+        } else {
+            redirectToWeb(redirectSession.getContext(), redirectSession.getUri());
+        }
+    }
+
+    /**
+     * This method take a packageName and the signing certificate hash of this package to validate
+     * whether the correct app is installed on the device.
+     */
+    private static boolean isAppLegit(@NonNull RedirectSession redirectSession) {
+        Set<String> foundCertFingerprints = getSigningCertificates(redirectSession);
+        if (foundCertFingerprints != null) {
+            return matchHashes(redirectSession.getBaseCertFingerprints(), foundCertFingerprints);
+        }
+        return false;
+    }
+
+    /**
+     * This method retrieves the signing certificate of an app from the Android Package Manager. If
+     * the app is not installed this method returns null.
+     */
+    private static Set<String> getSigningCertificates(@NonNull RedirectSession redirectSession) {
+        try {
+            Signature[] signatures;
+            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
+                SigningInfo signingInfo =
+                        redirectSession
+                                .getContext()
+                                .getPackageManager()
+                                .getPackageInfo(
+                                        redirectSession.getBasePackageName(),
+                                        PackageManager.GET_SIGNING_CERTIFICATES)
+                                .signingInfo;
+                signatures = signingInfo.getSigningCertificateHistory();
+            } else {
+                signatures =
+                        redirectSession
+                                .getContext()
+                                .getPackageManager()
+                                .getPackageInfo(
+                                        redirectSession.getBasePackageName(),
+                                        PackageManager.GET_SIGNATURES)
+                                .signatures;
+            }
+            return BrowserDescriptor.generateSignatureHashes(
+                    signatures, BrowserDescriptor.DIGEST_SHA_256);
+        } catch (PackageManager.NameNotFoundException excepetion) {
+            return null;
+        }
+    }
+
+    /**
+     * This function checks whether the two sets contain the same strings independent of their
+     * order.
+     */
+    @VisibleForTesting
+    public static boolean matchHashes(
+            @NonNull Set<String> certHashes0, @NonNull Set<String> certHashes1) {
+        return certHashes0.containsAll(certHashes1) && certHashes0.size() == certHashes1.size();
+    }
+
+    /**
+     * This method uses the BrowserSelector class to find the user's default browser and validated
+     * the integrity of this browser. It then opens the given uri in an Android Custom Tab.
+     */
+    public static void redirectToWeb(@NonNull Context context, @NonNull Uri uri) {
+        redirectToWeb(context, uri, 0, Color.WHITE);
+    }
+
+    /**
+     * This method uses the BrowserSelector class to find the user's default browser and validated
+     * the integrity of this browser. It then opens the given uri in an Android Custom Tab.
+     */
+    public static void redirectToWeb(
+            @NonNull Context context, @NonNull Uri uri, int additionalFlags, int toolbarColor) {
+        CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
+        builder.setToolbarColor(toolbarColor);
+        CustomTabsIntent customTabsIntent = builder.build();
+
+        BrowserDescriptor browserDescriptor =
+                BrowserSelector.select(
+                        context,
+                        new BrowserAllowList(
+                                VersionedBrowserMatcher.CHROME_CUSTOM_TAB,
+                                VersionedBrowserMatcher.CHROME_BROWSER,
+                                VersionedBrowserMatcher.FIREFOX_CUSTOM_TAB,
+                                VersionedBrowserMatcher.FIREFOX_BROWSER,
+                                VersionedBrowserMatcher.SAMSUNG_CUSTOM_TAB,
+                                VersionedBrowserMatcher.SAMSUNG_BROWSER));
+
+        if (browserDescriptor != null) {
+            customTabsIntent
+                    .intent
+                    .setPackage(browserDescriptor.packageName)
+                    .setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY | additionalFlags);
+            customTabsIntent.launchUrl(context, uri);
+        } else {
+            Toast.makeText(context, "Could not find a browser", Toast.LENGTH_SHORT).show();
+        }
+    }
+}
diff --git a/library/java/net/openid/appauth/app2app/package-info.java b/library/java/net/openid/appauth/app2app/package-info.java
new file mode 100644
index 00000000..8390f501
--- /dev/null
+++ b/library/java/net/openid/appauth/app2app/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This package provides methods to securely redirect a user from one app to another
+ * in an app2app OAuth 2.0 flow.
+ */
+package net.openid.appauth.app2app;
diff --git a/library/java/net/openid/appauth/browser/BrowserDescriptor.java b/library/java/net/openid/appauth/browser/BrowserDescriptor.java
index d9f85f5a..e1a77985 100644
--- a/library/java/net/openid/appauth/browser/BrowserDescriptor.java
+++ b/library/java/net/openid/appauth/browser/BrowserDescriptor.java
@@ -24,43 +24,36 @@
 import java.util.HashSet;
 import java.util.Set;
 
-/**
- * Represents a browser that may be used for an authorization flow.
- */
+/** Represents a browser that may be used for an authorization flow. */
 public class BrowserDescriptor {
 
     // See: http://stackoverflow.com/a/2816747
     private static final int PRIME_HASH_FACTOR = 92821;
 
-    private static final String DIGEST_SHA_512 = "SHA-512";
+    public static final String DIGEST_SHA_256 = "SHA-256";
+    public static final String DIGEST_SHA_512 = "SHA-512";
 
-    /**
-     * The package name of the browser app.
-     */
+    /** The package name of the browser app. */
     public final String packageName;
 
     /**
-     * The set of {@link android.content.pm.Signature signatures} of the browser app,
-     * which have been hashed with SHA-512, and Base-64 URL-safe encoded.
+     * The set of {@link android.content.pm.Signature signatures} of the browser app, which have
+     * been hashed with SHA-512, and Base-64 URL-safe encoded.
      */
     public final Set<String> signatureHashes;
 
-    /**
-     * The version string of the browser app.
-     */
+    /** The version string of the browser app. */
     public final String version;
 
-    /**
-     * Whether it is intended that the browser will be used via a custom tab.
-     */
+    /** Whether it is intended that the browser will be used via a custom tab. */
     public final Boolean useCustomTab;
 
     /**
-     * Creates a description of a browser from a {@link PackageInfo} object returned from the
-     * {@link android.content.pm.PackageManager}. The object is expected to include the
-     * signatures of the app, which can be retrieved with the
-     * {@link android.content.pm.PackageManager#GET_SIGNATURES GET_SIGNATURES} flag when
-     * calling {@link android.content.pm.PackageManager#getPackageInfo(String, int)}.
+     * Creates a description of a browser from a {@link PackageInfo} object returned from the {@link
+     * android.content.pm.PackageManager}. The object is expected to include the signatures of the
+     * app, which can be retrieved with the {@link android.content.pm.PackageManager#GET_SIGNATURES
+     * GET_SIGNATURES} flag when calling {@link
+     * android.content.pm.PackageManager#getPackageInfo(String, int)}.
      */
     public BrowserDescriptor(@NonNull PackageInfo packageInfo, boolean useCustomTab) {
         this(
@@ -72,19 +65,16 @@ public BrowserDescriptor(@NonNull PackageInfo packageInfo, boolean useCustomTab)
 
     /**
      * Creates a description of a browser from the core properties that are frequently used to
-     * decide whether a browser can be used for an authorization flow. In most cases, it is
-     * more convenient to use the other variant of the constructor that consumes a
-     * {@link PackageInfo} object provided by the package manager.
+     * decide whether a browser can be used for an authorization flow. In most cases, it is more
+     * convenient to use the other variant of the constructor that consumes a {@link PackageInfo}
+     * object provided by the package manager.
      *
-     * @param packageName
-     *     The Android package name of the browser.
-     * @param signatureHashes
-     *     The set of SHA-512, Base64 url safe encoded signatures for the app. This can be
-     *     generated for a signature by calling {@link #generateSignatureHash(Signature)}.
-     * @param version
-     *     The version name of the browser.
-     * @param useCustomTab
-     *     Whether it is intended to use the browser as a custom tab.
+     * @param packageName The Android package name of the browser.
+     * @param signatureHashes The set of SHA-512, Base64 url safe encoded signatures for the app.
+     *     This can be generated for a signature by calling {@link
+     *     #generateSignatureHash(Signature)}.
+     * @param version The version name of the browser.
+     * @param useCustomTab Whether it is intended to use the browser as a custom tab.
      */
     public BrowserDescriptor(
             @NonNull String packageName,
@@ -98,16 +88,12 @@ public BrowserDescriptor(
     }
 
     /**
-     * Creates a copy of this browser descriptor, changing the intention to use it as a custom
-     * tab to the specified value.
+     * Creates a copy of this browser descriptor, changing the intention to use it as a custom tab
+     * to the specified value.
      */
     @NonNull
     public BrowserDescriptor changeUseCustomTab(boolean newUseCustomTabValue) {
-        return new BrowserDescriptor(
-                packageName,
-                signatureHashes,
-                version,
-                newUseCustomTabValue);
+        return new BrowserDescriptor(packageName, signatureHashes, version, newUseCustomTabValue);
     }
 
     @Override
@@ -141,30 +127,38 @@ public int hashCode() {
         return hash;
     }
 
-    /**
-     * Generates a SHA-512 hash, Base64 url-safe encoded, from a {@link Signature}.
-     */
+    /** Generates a SHA-* hash, Base64 url-safe encoded, from a {@link Signature}. */
     @NonNull
-    public static String generateSignatureHash(@NonNull Signature signature) {
+    public static String generateSignatureHash(
+            @NonNull Signature signature, @NonNull String digestSha) {
         try {
-            MessageDigest digest = MessageDigest.getInstance(DIGEST_SHA_512);
+            MessageDigest digest = MessageDigest.getInstance(digestSha);
             byte[] hashBytes = digest.digest(signature.toByteArray());
             return Base64.encodeToString(hashBytes, Base64.URL_SAFE | Base64.NO_WRAP);
         } catch (NoSuchAlgorithmException e) {
-            throw new IllegalStateException(
-                    "Platform does not support" + DIGEST_SHA_512 + " hashing");
+            throw new IllegalStateException("Platform does not support" + digestSha + " hashing");
         }
     }
 
     /**
-     * Generates a set of SHA-512, Base64 url-safe encoded signature hashes from the provided
-     * array of signatures.
+     * Generates a set of SHA-512, Base64 url-safe encoded signature hashes from the provided array
+     * of signatures.
      */
     @NonNull
     public static Set<String> generateSignatureHashes(@NonNull Signature[] signatures) {
+        return generateSignatureHashes(signatures, DIGEST_SHA_512);
+    }
+
+    /**
+     * Generates a set of SHA-*, Base64 url-safe encoded signature hashes from the provided array of
+     * signatures.
+     */
+    @NonNull
+    public static Set<String> generateSignatureHashes(
+            @NonNull Signature[] signatures, @NonNull String digestSha) {
         Set<String> signatureHashes = new HashSet<>();
         for (Signature signature : signatures) {
-            signatureHashes.add(generateSignatureHash(signature));
+            signatureHashes.add(generateSignatureHash(signature, digestSha));
         }
 
         return signatureHashes;
diff --git a/library/javatests/net/openid/appauth/app2app/CertificateFingerprintEncodingTest.java b/library/javatests/net/openid/appauth/app2app/CertificateFingerprintEncodingTest.java
new file mode 100644
index 00000000..c410d5c5
--- /dev/null
+++ b/library/javatests/net/openid/appauth/app2app/CertificateFingerprintEncodingTest.java
@@ -0,0 +1,40 @@
+package net.openid.appauth.app2app;
+
+import net.openid.appauth.BuildConfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.json.JSONArray;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.Set;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(constants = BuildConfig.class, sdk = 16)
+public class CertificateFingerprintEncodingTest {
+
+    @Test
+    public void testCertFingerprintsToDecodedString0() {
+        JSONArray jsonArray = new JSONArray();
+        jsonArray.put("98:C7:E1:43:9C:A9:C9:68:27:FE:47:16:9A:C0:60:2A:61:5B:88:2F:CC:4E:AB:66:47:8E:67:E6:2A:93:F8:68");
+
+        Set<String> hashes = CertificateFingerprintEncoding.certFingerprintsToDecodedString(jsonArray);
+
+        assertThat(hashes.size()).isEqualTo(1);
+        assertThat(hashes.contains("mMfhQ5ypyWgn_kcWmsBgKmFbiC_MTqtmR45n5iqT-Gg=")).isTrue();
+    }
+
+    @Test
+    public void testCertFingerprintsToDecodedString1() {
+        JSONArray jsonArray = new JSONArray();
+        jsonArray.put("58:27:63:4A:F5:D5:07:7C:DE:4B:94:27:60:B0:C7:CD:33:8D:93:13:02:8D:0B:E0:0F:C5:26:F4:88:39:F1:D5");
+
+        Set<String> hashes = CertificateFingerprintEncoding.certFingerprintsToDecodedString(jsonArray);
+
+        assertThat(hashes.size()).isEqualTo(1);
+        assertThat(hashes.contains("WCdjSvXVB3zeS5QnYLDHzTONkxMCjQvgD8Um9Ig58dU=")).isTrue();
+    }
+}
diff --git a/library/javatests/net/openid/appauth/app2app/SecureRedirectionTest.java b/library/javatests/net/openid/appauth/app2app/SecureRedirectionTest.java
new file mode 100644
index 00000000..0d190e3c
--- /dev/null
+++ b/library/javatests/net/openid/appauth/app2app/SecureRedirectionTest.java
@@ -0,0 +1,52 @@
+package net.openid.appauth.app2app;
+
+import net.openid.appauth.BuildConfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(constants = BuildConfig.class, sdk = 16)
+public class SecureRedirectionTest {
+
+    @Test
+    public void testMatchHashesTrue0() {
+        Set<String> set0 = Stream.of("foo", "bar", "baz", "qux", "corge").collect(Collectors.toCollection(HashSet::new));
+        Set<String> set1 = Stream.of("baz", "bar", "foo", "corge", "qux").collect(Collectors.toCollection(HashSet::new));
+
+        assertThat(SecureRedirection.matchHashes(set0, set1)).isTrue();
+    }
+
+    @Test
+    public void testMatchHashesTrue1() {
+        Set<String> set0 = Stream.of("foo").collect(Collectors.toCollection(HashSet::new));
+        Set<String> set1 = Stream.of("foo").collect(Collectors.toCollection(HashSet::new));
+
+        assertThat(SecureRedirection.matchHashes(set0, set1)).isTrue();
+    }
+
+    @Test
+    public void testMatchHashesFalse0() {
+        Set<String> set0 = Stream.of("foo", "bar", "baz", "qux", "corge").collect(Collectors.toCollection(HashSet::new));
+        Set<String> set1 = Stream.of("baz", "fred", "foo", "corge", "qux").collect(Collectors.toCollection(HashSet::new));
+
+        assertThat(SecureRedirection.matchHashes(set0, set1)).isFalse();
+    }
+
+    @Test
+    public void testMatchHashesFalse1() {
+        Set<String> set0 = Stream.of("foo", "bar", "baz", "qux", "corge").collect(Collectors.toCollection(HashSet::new));
+        Set<String> set1 = Stream.of("baz", "foo", "corge", "qux").collect(Collectors.toCollection(HashSet::new));
+
+        assertThat(SecureRedirection.matchHashes(set0, set1)).isFalse();
+    }
+}