From e85645528336162e16acf89f7b9f029762972c72 Mon Sep 17 00:00:00 2001
From: oSumAtrIX <johan.melkonyan1@web.de>
Date: Fri, 6 Sep 2024 12:27:02 +0400
Subject: [PATCH] feat: Add `Check environment` patch (#683)

Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
---
 .../integrations/shared/GmsCoreSupport.java   |  21 +-
 .../revanced/integrations/shared/Logger.java  |  31 +-
 .../revanced/integrations/shared/Utils.java   |  81 ++++
 .../integrations/shared/checks/Check.java     | 164 ++++++++
 .../shared/checks/CheckEnvironmentPatch.java  | 369 ++++++++++++++++++
 .../integrations/shared/checks/PatchInfo.java |  33 ++
 ...WatchHistoryDomainNameResolutionPatch.java |   8 +-
 .../announcements/AnnouncementsPatch.java     |  15 +-
 .../youtube/settings/Settings.java            |  27 +-
 9 files changed, 703 insertions(+), 46 deletions(-)
 create mode 100644 app/src/main/java/app/revanced/integrations/shared/checks/Check.java
 create mode 100644 app/src/main/java/app/revanced/integrations/shared/checks/CheckEnvironmentPatch.java
 create mode 100644 app/src/main/java/app/revanced/integrations/shared/checks/PatchInfo.java

diff --git a/app/src/main/java/app/revanced/integrations/shared/GmsCoreSupport.java b/app/src/main/java/app/revanced/integrations/shared/GmsCoreSupport.java
index a0275fb3..cdd474a9 100644
--- a/app/src/main/java/app/revanced/integrations/shared/GmsCoreSupport.java
+++ b/app/src/main/java/app/revanced/integrations/shared/GmsCoreSupport.java
@@ -54,18 +54,15 @@ public class GmsCoreSupport {
                                                       String dialogMessageRef,
                                                       String positiveButtonStringRef,
                                                       DialogInterface.OnClickListener onPositiveClickListener) {
-        // Use a delay to allow the activity to finish initializing.
-        // Otherwise, if device is in dark mode the dialog is shown with wrong color scheme.
-        Utils.runOnMainThreadDelayed(() -> {
-            new AlertDialog.Builder(context)
-                    .setIconAttribute(android.R.attr.alertDialogIcon)
-                    .setTitle(str("gms_core_dialog_title"))
-                    .setMessage(str(dialogMessageRef))
-                    .setPositiveButton(str(positiveButtonStringRef), onPositiveClickListener)
-                    // Allow using back button to skip the action, just in case the check can never be satisfied.
-                    .setCancelable(true)
-                    .show();
-        }, 100);
+        // Do not set cancelable to false, to allow using back button to skip the action,
+        // just in case the check can never be satisfied.
+        var dialog = new AlertDialog.Builder(context)
+                .setIconAttribute(android.R.attr.alertDialogIcon)
+                .setTitle(str("gms_core_dialog_title"))
+                .setMessage(str(dialogMessageRef))
+                .setPositiveButton(str(positiveButtonStringRef), onPositiveClickListener)
+                .create();
+        Utils.showDialog(context, dialog);
     }
 
     /**
diff --git a/app/src/main/java/app/revanced/integrations/shared/Logger.java b/app/src/main/java/app/revanced/integrations/shared/Logger.java
index 25885050..b3729ef7 100644
--- a/app/src/main/java/app/revanced/integrations/shared/Logger.java
+++ b/app/src/main/java/app/revanced/integrations/shared/Logger.java
@@ -1,24 +1,21 @@
 package app.revanced.integrations.shared;
 
-import static app.revanced.integrations.shared.settings.BaseSettings.DEBUG;
-import static app.revanced.integrations.shared.settings.BaseSettings.DEBUG_STACKTRACE;
-import static app.revanced.integrations.shared.settings.BaseSettings.DEBUG_TOAST_ON_ERROR;
-
 import android.util.Log;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import app.revanced.integrations.shared.settings.BaseSettings;
 
 import java.io.PrintWriter;
 import java.io.StringWriter;
 
-import app.revanced.integrations.shared.settings.BaseSettings;
+import static app.revanced.integrations.shared.settings.BaseSettings.*;
 
 public class Logger {
 
     /**
      * Log messages using lambdas.
      */
+    @FunctionalInterface
     public interface LogMessage {
         @NonNull
         String buildMessageString();
@@ -59,19 +56,33 @@ public class Logger {
      * so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
      */
     public static void printDebug(@NonNull LogMessage message) {
+        printDebug(message, null);
+    }
+
+    /**
+     * Logs debug messages under the outer class name of the code calling this method.
+     * Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()}
+     * so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
+     */
+    public static void printDebug(@NonNull LogMessage message, @Nullable Exception ex) {
         if (DEBUG.get()) {
-            var messageString = message.buildMessageString();
+            String logMessage = message.buildMessageString();
+            String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
 
             if (DEBUG_STACKTRACE.get()) {
-                var builder = new StringBuilder(messageString);
+                var builder = new StringBuilder(logMessage);
                 var sw = new StringWriter();
                 new Throwable().printStackTrace(new PrintWriter(sw));
 
                 builder.append('\n').append(sw);
-                messageString = builder.toString();
+                logMessage = builder.toString();
             }
 
-            Log.d(REVANCED_LOG_PREFIX + message.findOuterClassSimpleName(), messageString);
+            if (ex == null) {
+                Log.d(logTag, logMessage);
+            } else {
+                Log.d(logTag, logMessage, ex);
+            }
         }
     }
 
diff --git a/app/src/main/java/app/revanced/integrations/shared/Utils.java b/app/src/main/java/app/revanced/integrations/shared/Utils.java
index 21a97a9a..22ed1e06 100644
--- a/app/src/main/java/app/revanced/integrations/shared/Utils.java
+++ b/app/src/main/java/app/revanced/integrations/shared/Utils.java
@@ -1,6 +1,10 @@
 package app.revanced.integrations.shared;
 
 import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
 import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageInfo;
@@ -8,6 +12,7 @@ import android.content.pm.PackageManager;
 import android.content.res.Resources;
 import android.net.ConnectivityManager;
 import android.os.Build;
+import android.os.Bundle;
 import android.os.Handler;
 import android.os.Looper;
 import android.preference.Preference;
@@ -380,6 +385,82 @@ public class Utils {
         return false;
     }
 
+    /**
+     * Ignore this class. It must be public to satisfy Android requirement.
+     */
+    @SuppressWarnings("deprecation")
+    public static class DialogFragmentWrapper extends DialogFragment {
+
+        private Dialog dialog;
+        @Nullable
+        private DialogFragmentOnStartAction onStartAction;
+
+        @Override
+        public void onSaveInstanceState(Bundle outState) {
+            // Do not call super method to prevent state saving.
+        }
+
+        @NonNull
+        @Override
+        public Dialog onCreateDialog(Bundle savedInstanceState) {
+            return dialog;
+        }
+
+        @Override
+        public void onStart() {
+            try {
+                super.onStart();
+
+                if (onStartAction != null) {
+                    onStartAction.onStart((AlertDialog) getDialog());
+                }
+            } catch (Exception ex) {
+                Logger.printException(() -> "onStart failure: " + dialog.getClass().getSimpleName(), ex);
+            }
+        }
+    }
+
+    /**
+     * Interface for {@link #showDialog(Activity, AlertDialog, boolean, DialogFragmentOnStartAction)}.
+     */
+    @FunctionalInterface
+    public interface DialogFragmentOnStartAction {
+        void onStart(AlertDialog dialog);
+    }
+
+    public static void showDialog(Activity activity, AlertDialog dialog) {
+        showDialog(activity, dialog, true, null);
+    }
+
+    /**
+     * Utility method to allow showing an AlertDialog on top of other alert dialogs.
+     * Calling this will always display the dialog on top of all other dialogs
+     * previously called using this method.
+     * <br>
+     * Be aware the on start action can be called multiple times for some situations,
+     * such as the user switching apps without dismissing the dialog then switching back to this app.
+     *<br>
+     * This method is only useful during app startup and multiple patches may show their own dialog,
+     * and the most important dialog can be called last (using a delay) so it's always on top.
+     *<br>
+     * For all other situations it's better to not use this method and
+     * call {@link AlertDialog#show()} on the dialog.
+     */
+    @SuppressWarnings("deprecation")
+    public static void showDialog(Activity activity,
+                                  AlertDialog dialog,
+                                  boolean isCancelable,
+                                  @Nullable DialogFragmentOnStartAction onStartAction) {
+        verifyOnMainThread();
+
+        DialogFragmentWrapper fragment = new DialogFragmentWrapper();
+        fragment.dialog = dialog;
+        fragment.onStartAction = onStartAction;
+        fragment.setCancelable(isCancelable);
+
+        fragment.show(activity.getFragmentManager(), null);
+    }
+
     /**
      * Safe to call from any thread
      */
diff --git a/app/src/main/java/app/revanced/integrations/shared/checks/Check.java b/app/src/main/java/app/revanced/integrations/shared/checks/Check.java
new file mode 100644
index 00000000..a9497d5b
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/shared/checks/Check.java
@@ -0,0 +1,164 @@
+package app.revanced.integrations.shared.checks;
+
+import static android.text.Html.FROM_HTML_MODE_COMPACT;
+import static app.revanced.integrations.shared.StringRef.str;
+import static app.revanced.integrations.shared.Utils.DialogFragmentOnStartAction;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.text.Html;
+import android.widget.Button;
+
+import androidx.annotation.Nullable;
+
+import java.util.Collection;
+
+import app.revanced.integrations.shared.Logger;
+import app.revanced.integrations.shared.Utils;
+import app.revanced.integrations.youtube.settings.Settings;
+
+abstract class Check {
+    private static final int NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING = 2;
+
+    private static final int SECONDS_BEFORE_SHOWING_IGNORE_BUTTON = 15;
+    private static final int SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON = 10;
+
+    private static final Uri GOOD_SOURCE = Uri.parse("https://revanced.app");
+
+    /**
+     * @return If the check conclusively passed or failed. A null value indicates it neither passed nor failed.
+     */
+    @Nullable
+    protected abstract Boolean check();
+
+    protected abstract String failureReason();
+
+    /**
+     * Specifies a sorting order for displaying the checks that failed.
+     * A lower value indicates to show first before other checks.
+     */
+    public abstract int uiSortingValue();
+
+    /**
+     * For debugging and development only.
+     * Forces all checks to be performed and the check failed dialog to be shown.
+     * Can be enabled by importing settings text with {@link Settings#CHECK_ENVIRONMENT_WARNINGS_ISSUED}
+     * set to -1.
+     */
+    static boolean debugAlwaysShowWarning() {
+        final boolean alwaysShowWarning = Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get() < 0;
+        if (alwaysShowWarning) {
+            Logger.printInfo(() -> "Debug forcing environment check warning to show");
+        }
+
+        return alwaysShowWarning;
+    }
+
+    static boolean shouldRun() {
+        return Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get()
+                < NUMBER_OF_TIMES_TO_IGNORE_WARNING_BEFORE_DISABLING;
+    }
+
+    static void disableForever() {
+        Logger.printInfo(() -> "Environment checks disabled forever");
+
+        Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(Integer.MAX_VALUE);
+    }
+
+    @SuppressLint("NewApi")
+    static void issueWarning(Activity activity, Collection<Check> failedChecks) {
+        final var reasons = new StringBuilder();
+
+        reasons.append("<ul>");
+        for (var check : failedChecks) {
+            // Add a non breaking space to fix bullet points spacing issue.
+            reasons.append("<li>&nbsp;").append(check.failureReason());
+        }
+        reasons.append("</ul>");
+
+        var message = Html.fromHtml(
+                str("revanced_check_environment_failed_message", reasons.toString()),
+                FROM_HTML_MODE_COMPACT
+        );
+
+        Utils.runOnMainThreadDelayed(() -> {
+            AlertDialog alert = new AlertDialog.Builder(activity)
+                    .setCancelable(false)
+                    .setIconAttribute(android.R.attr.alertDialogIcon)
+                    .setTitle(str("revanced_check_environment_failed_title"))
+                    .setMessage(message)
+                    .setPositiveButton(
+                            " ",
+                            (dialog, which) -> {
+                                final var intent = new Intent(Intent.ACTION_VIEW, GOOD_SOURCE);
+                                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+                                activity.startActivity(intent);
+
+                                // Shutdown to prevent the user from navigating back to this app,
+                                // which is no longer showing a warning dialog.
+                                activity.finishAffinity();
+                                System.exit(0);
+                            }
+                    ).setNegativeButton(
+                            " ",
+                            (dialog, which) -> {
+                                // Cleanup data if the user incorrectly imported a huge negative number.
+                                final int current = Math.max(0, Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get());
+                                Settings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(current + 1);
+
+                                dialog.dismiss();
+                            }
+                    ).create();
+
+            Utils.showDialog(activity, alert, false, new DialogFragmentOnStartAction() {
+                boolean hasRun;
+                @Override
+                public void onStart(AlertDialog dialog) {
+                    // Only run this once, otherwise if the user changes to a different app
+                    // then changes back, this handler will run again and disable the buttons.
+                    if (hasRun) {
+                        return;
+                    }
+                    hasRun = true;
+
+                    var openWebsiteButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
+                    openWebsiteButton.setEnabled(false);
+
+                    var dismissButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE);
+                    dismissButton.setEnabled(false);
+
+                    getCountdownRunnable(dismissButton, openWebsiteButton).run();
+                }
+            });
+        }, 1000); // Use a delay, so this dialog is shown on top of any other startup dialogs.
+    }
+
+    private static Runnable getCountdownRunnable(Button dismissButton, Button openWebsiteButton) {
+        return new Runnable() {
+            private int secondsRemaining = SECONDS_BEFORE_SHOWING_IGNORE_BUTTON;
+
+            @Override
+            public void run() {
+                Utils.verifyOnMainThread();
+
+                if (secondsRemaining > 0) {
+                    if (secondsRemaining - SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON == 0) {
+                        openWebsiteButton.setText(str("revanced_check_environment_dialog_open_official_source_button"));
+                        openWebsiteButton.setEnabled(true);
+                    }
+
+                    secondsRemaining--;
+
+                    Utils.runOnMainThreadDelayed(this, 1000);
+                } else {
+                    dismissButton.setText(str("revanced_check_environment_dialog_ignore_button"));
+                    dismissButton.setEnabled(true);
+                }
+            }
+        };
+    }
+}
diff --git a/app/src/main/java/app/revanced/integrations/shared/checks/CheckEnvironmentPatch.java b/app/src/main/java/app/revanced/integrations/shared/checks/CheckEnvironmentPatch.java
new file mode 100644
index 00000000..a782c7b2
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/shared/checks/CheckEnvironmentPatch.java
@@ -0,0 +1,369 @@
+package app.revanced.integrations.shared.checks;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.util.Base64;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.integrations.shared.Logger;
+import app.revanced.integrations.shared.Utils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.*;
+
+import static app.revanced.integrations.shared.StringRef.str;
+import static app.revanced.integrations.shared.checks.Check.debugAlwaysShowWarning;
+import static app.revanced.integrations.shared.checks.PatchInfo.Build.*;
+import static app.revanced.integrations.shared.checks.PatchInfo.PATCH_TIME;
+
+/**
+ * This class is used to check if the app was patched by the user
+ * and not downloaded pre-patched, because pre-patched apps are difficult to trust.
+ * <br>
+ * Various indicators help to detect if the app was patched by the user.
+ */
+@SuppressWarnings("unused")
+public final class CheckEnvironmentPatch {
+    private static final boolean DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG = debugAlwaysShowWarning();
+
+    private enum InstallationType {
+        /**
+         * CLI patching, manual installation of a previously patched using adb,
+         * or root installation if stock app is first installed using adb.
+         */
+        ADB((String) null),
+        ROOT_MOUNT_ON_APP_STORE("com.android.vending"),
+        MANAGER("app.revanced.manager.flutter",
+                "app.revanced.manager",
+                "app.revanced.manager.debug");
+
+        @Nullable
+        static InstallationType installTypeFromPackageName(@Nullable String packageName) {
+            for (InstallationType type : values()) {
+                for (String installPackageName : type.packageNames) {
+                    if (Objects.equals(installPackageName, packageName)) {
+                        return type;
+                    }
+                }
+            }
+
+            return null;
+        }
+
+        /**
+         * Array elements can be null.
+         */
+        final String[] packageNames;
+
+        InstallationType(String... packageNames) {
+            this.packageNames = packageNames;
+        }
+    }
+
+    /**
+     * Check if the app is installed by the manager, the app store, or through adb/CLI.
+     * <br>
+     * Does not conclusively
+     * If the app is installed by the manager or the app store, it is likely, the app was patched using the manager,
+     * or installed manually via ADB (in the case of ReVanced CLI for example).
+     * <br>
+     * If the app is not installed by the manager or the app store, then the app was likely downloaded pre-patched
+     * and installed by the browser or another unknown app.
+     */
+    private static class CheckExpectedInstaller extends Check {
+        @Nullable
+        InstallationType installerFound;
+
+        @NonNull
+        @Override
+        protected Boolean check() {
+            final var context = Utils.getContext();
+
+            final var installerPackageName =
+                    context.getPackageManager().getInstallerPackageName(context.getPackageName());
+
+            Logger.printInfo(() -> "Installed by: " + installerPackageName);
+
+            installerFound = InstallationType.installTypeFromPackageName(installerPackageName);
+            final boolean passed = (installerFound != null);
+
+            Logger.printInfo(() -> passed
+                    ? "Apk was not installed from an unknown source"
+                    : "Apk was installed from an unknown source");
+
+            return passed;
+        }
+
+        @Override
+        protected String failureReason() {
+            return str("revanced_check_environment_manager_not_expected_installer");
+        }
+
+        @Override
+        public int uiSortingValue() {
+            return -100; // Show first.
+        }
+    }
+
+    /**
+     * Check if the build properties are the same as during the patch.
+     * <br>
+     * If the build properties are the same as during the patch, it is likely, the app was patched on the same device.
+     * <br>
+     * If the build properties are different, the app was likely downloaded pre-patched or patched on another device.
+     */
+    private static class CheckWasPatchedOnSameDevice extends Check {
+        @SuppressLint({"NewApi", "HardwareIds"})
+        @Override
+        protected Boolean check() {
+            if (PATCH_BOARD.isEmpty()) {
+                // Did not patch with Manager, and cannot conclusively say where this was from.
+                Logger.printInfo(() -> "APK does not contain a hardware signature and cannot compare to current device");
+                return null;
+            }
+
+            //noinspection deprecation
+            final var passed = buildFieldEqualsHash("BOARD", Build.BOARD, PATCH_BOARD) &
+                    buildFieldEqualsHash("BOOTLOADER", Build.BOOTLOADER, PATCH_BOOTLOADER) &
+                    buildFieldEqualsHash("BRAND", Build.BRAND, PATCH_BRAND) &
+                    buildFieldEqualsHash("CPU_ABI", Build.CPU_ABI, PATCH_CPU_ABI) &
+                    buildFieldEqualsHash("CPU_ABI2", Build.CPU_ABI2, PATCH_CPU_ABI2) &
+                    buildFieldEqualsHash("DEVICE", Build.DEVICE, PATCH_DEVICE) &
+                    buildFieldEqualsHash("DISPLAY", Build.DISPLAY, PATCH_DISPLAY) &
+                    buildFieldEqualsHash("FINGERPRINT", Build.FINGERPRINT, PATCH_FINGERPRINT) &
+                    buildFieldEqualsHash("HARDWARE", Build.HARDWARE, PATCH_HARDWARE) &
+                    buildFieldEqualsHash("HOST", Build.HOST, PATCH_HOST) &
+                    buildFieldEqualsHash("ID", Build.ID, PATCH_ID) &
+                    buildFieldEqualsHash("MANUFACTURER", Build.MANUFACTURER, PATCH_MANUFACTURER) &
+                    buildFieldEqualsHash("MODEL", Build.MODEL, PATCH_MODEL) &
+                    buildFieldEqualsHash("ODM_SKU", Build.ODM_SKU, PATCH_ODM_SKU) &
+                    buildFieldEqualsHash("PRODUCT", Build.PRODUCT, PATCH_PRODUCT) &
+                    buildFieldEqualsHash("RADIO", Build.RADIO, PATCH_RADIO) &
+                    buildFieldEqualsHash("SKU", Build.SKU, PATCH_SKU) &
+                    buildFieldEqualsHash("SOC_MANUFACTURER", Build.SOC_MANUFACTURER, PATCH_SOC_MANUFACTURER) &
+                    buildFieldEqualsHash("SOC_MODEL", Build.SOC_MODEL, PATCH_SOC_MODEL) &
+                    buildFieldEqualsHash("TAGS", Build.TAGS, PATCH_TAGS) &
+                    buildFieldEqualsHash("TYPE", Build.TYPE, PATCH_TYPE) &
+                    buildFieldEqualsHash("USER", Build.USER, PATCH_USER);
+
+            Logger.printInfo(() -> passed
+                    ? "Device hardware signature matches current device"
+                    : "Device hardware signature does not match current device");
+
+            return passed;
+        }
+
+        @Override
+        protected String failureReason() {
+            return str("revanced_check_environment_not_same_patching_device");
+        }
+
+        @Override
+        public int uiSortingValue() {
+            return 0; // Show in the middle.
+        }
+    }
+
+    /**
+     * Check if the app was installed within the last 30 minutes after being patched.
+     * <br>
+     * If the app was installed within the last 30 minutes, it is likely, the app was patched by the user.
+     * <br>
+     * If the app was installed much later than the patch time, it is likely the app was
+     * downloaded pre-patched or the user waited too long to install the app.
+     */
+    private static class CheckIsNearPatchTime extends Check {
+        /**
+         * How soon after patching the app must be first launched.
+         */
+        static final int THRESHOLD_FOR_PATCHING_RECENTLY = 30 * 60 * 1000;  // 30 minutes.
+
+        /**
+         * How soon after installation or updating the app to check the patch time.
+         * If the install/update is older than this, this entire check is ignored
+         * to prevent showing any errors if the user clears the app data after installation.
+         */
+        static final int THRESHOLD_FOR_RECENT_INSTALLATION = 12 * 60 * 60 * 1000;  // 12 hours.
+
+        static final long DURATION_SINCE_PATCHING = System.currentTimeMillis() - PATCH_TIME;
+
+        @Override
+        protected Boolean check() {
+            Logger.printInfo(() -> "Installed: " + (DURATION_SINCE_PATCHING / 1000) + " seconds after patching");
+
+            // Also verify patched time is not in the future.
+            if (DURATION_SINCE_PATCHING < 0) {
+                // Patch time is in the future and clearly wrong.
+                return false;
+            }
+
+            if (DURATION_SINCE_PATCHING < THRESHOLD_FOR_PATCHING_RECENTLY) {
+                // App is recently patched and this installation is new or recently updated.
+                return true;
+            }
+
+            // Verify the app install/update is recent,
+            // to prevent showing errors if the user later clears the app data.
+            try {
+                Context context = Utils.getContext();
+                PackageManager packageManager = context.getPackageManager();
+                PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0);
+
+                // Duration since initial install or last update, which ever is sooner.
+                final long durationSinceInstallUpdate = System.currentTimeMillis() - packageInfo.lastUpdateTime;
+                Logger.printInfo(() -> "App was installed/updated: "
+                        + (durationSinceInstallUpdate / (60 * 60 * 1000)) + " hours ago");
+
+                if (durationSinceInstallUpdate > THRESHOLD_FOR_RECENT_INSTALLATION) {
+                    Logger.printInfo(() -> "Ignoring install time check since install/update was over "
+                            + THRESHOLD_FOR_RECENT_INSTALLATION + " hours ago");
+                    return null;
+                }
+            } catch (PackageManager.NameNotFoundException ex) {
+                Logger.printException(() -> "Package name not found exception", ex); // Will never happen.
+            }
+
+            // Was patched between 30 minutes and 12 hours ago.
+            // This can only happen if someone installs the app then waits 30+ minutes to launch,
+            // or they clear the app data within 12 hours after installation.
+            return false;
+        }
+
+        @Override
+        protected String failureReason() {
+            if (DURATION_SINCE_PATCHING < 0) {
+                // Could happen if the user has their device clock incorrectly set in the past,
+                // but assume that isn't the case and the apk was patched on a device with the wrong system time.
+                return str("revanced_check_environment_not_near_patch_time_invalid");
+            }
+
+            // If patched over 1 day ago, show how old this pre-patched apk is.
+            // Showing the age can help convey it's better to patch yourself and know it's the latest.
+            final long oneDay = 24 * 60 * 60 * 1000;
+            final long daysSincePatching = DURATION_SINCE_PATCHING / oneDay;
+            if (daysSincePatching > 1) { // Use over 1 day to avoid singular vs plural strings.
+                return str("revanced_check_environment_not_near_patch_time_days", daysSincePatching);
+            }
+
+            return str("revanced_check_environment_not_near_patch_time");
+        }
+
+        @Override
+        public int uiSortingValue() {
+            return 100; // Show last.
+        }
+    }
+
+    /**
+     * Injection point.
+     */
+    public static void check(Activity context) {
+        // If the warning was already issued twice, or if the check was successful in the past,
+        // do not run the checks again.
+        if (!Check.shouldRun() && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+            Logger.printDebug(() -> "Environment checks are disabled");
+            return;
+        }
+
+        Utils.runOnBackgroundThread(() -> {
+            try {
+                Logger.printInfo(() -> "Running environment checks");
+                List<Check> failedChecks = new ArrayList<>();
+
+                CheckWasPatchedOnSameDevice sameHardware = new CheckWasPatchedOnSameDevice();
+                Boolean hardwareCheckPassed = sameHardware.check();
+                if (hardwareCheckPassed != null) {
+                    if (hardwareCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+                        // Patched on the same device using Manager,
+                        // and no further checks are needed.
+                        Check.disableForever();
+                        return;
+                    }
+
+                    failedChecks.add(sameHardware);
+                }
+
+                CheckIsNearPatchTime nearPatchTime = new CheckIsNearPatchTime();
+                Boolean timeCheckPassed = nearPatchTime.check();
+                if (timeCheckPassed != null) {
+                    if (timeCheckPassed && !DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+                        if (failedChecks.isEmpty()) {
+                            // Recently patched and installed. No further checks are needed.
+                            // Stopping here also prevents showing warnings if patching and installing with Termux.
+                            Check.disableForever();
+                            return;
+                        }
+                    } else {
+                        failedChecks.add(nearPatchTime);
+                    }
+                }
+
+                CheckExpectedInstaller installerCheck = new CheckExpectedInstaller();
+                // If the installer package is Manager but this code is reached,
+                // that means it must not be the right Manager otherwise the hardware hash
+                // signatures would be present and this check would not have run.
+                final boolean isManagerInstall = installerCheck.installerFound == InstallationType.MANAGER;
+                if (!installerCheck.check() || isManagerInstall) {
+                    failedChecks.add(installerCheck);
+
+                    if (isManagerInstall) {
+                        // If using Manager and reached here, then this must
+                        // have been patched on a different device.
+                        failedChecks.add(sameHardware);
+                    }
+                }
+
+                if (DEBUG_ALWAYS_SHOW_CHECK_FAILED_DIALOG) {
+                    // Show all failures for debugging layout.
+                    failedChecks = Arrays.asList(
+                            sameHardware,
+                            nearPatchTime,
+                            installerCheck
+                    );
+                }
+
+                if (failedChecks.isEmpty()) {
+                    Check.disableForever();
+                    return;
+                }
+
+                //noinspection ComparatorCombinators
+                Collections.sort(failedChecks, (o1, o2) -> o1.uiSortingValue() - o2.uiSortingValue());
+
+                Check.issueWarning(
+                        context,
+                        failedChecks
+                );
+            } catch (Exception ex) {
+                Logger.printException(() -> "check failure", ex);
+            }
+        });
+    }
+
+    private static boolean buildFieldEqualsHash(String buildFieldName, String buildFieldValue, @Nullable String hash) {
+        try {
+            final var sha1 = MessageDigest.getInstance("SHA-1")
+                    .digest(buildFieldValue.getBytes(StandardCharsets.UTF_8));
+
+            // Must be careful to use same base64 encoding Kotlin uses.
+            String runtimeHash = new String(Base64.encode(sha1, Base64.NO_WRAP), StandardCharsets.ISO_8859_1);
+            final boolean equals = runtimeHash.equals(hash);
+            if (!equals) {
+                Logger.printInfo(() -> "Hashes do not match. " + buildFieldName + ": '" + buildFieldValue
+                        + "' runtimeHash: '" + runtimeHash + "' patchTimeHash: '" + hash + "'");
+            }
+
+            return equals;
+        } catch (NoSuchAlgorithmException ex) {
+            Logger.printException(() -> "buildFieldEqualsHash failure", ex); // Will never happen.
+
+            return false;
+        }
+    }
+}
diff --git a/app/src/main/java/app/revanced/integrations/shared/checks/PatchInfo.java b/app/src/main/java/app/revanced/integrations/shared/checks/PatchInfo.java
new file mode 100644
index 00000000..6ebf4d8f
--- /dev/null
+++ b/app/src/main/java/app/revanced/integrations/shared/checks/PatchInfo.java
@@ -0,0 +1,33 @@
+package app.revanced.integrations.shared.checks;
+
+// Fields are set by the patch. Do not modify.
+// Fields are not final, because the compiler is inlining them.
+final class PatchInfo {
+    static long PATCH_TIME = 0L;
+
+    final static class Build {
+        static String PATCH_BOARD = "";
+        static String PATCH_BOOTLOADER = "";
+        static String PATCH_BRAND = "";
+        static String PATCH_CPU_ABI = "";
+        static String PATCH_CPU_ABI2 = "";
+        static String PATCH_DEVICE = "";
+        static String PATCH_DISPLAY = "";
+        static String PATCH_FINGERPRINT = "";
+        static String PATCH_HARDWARE = "";
+        static String PATCH_HOST = "";
+        static String PATCH_ID = "";
+        static String PATCH_MANUFACTURER = "";
+        static String PATCH_MODEL = "";
+        static String PATCH_ODM_SKU = "";
+        static String PATCH_PRODUCT = "";
+        static String PATCH_RADIO = "";
+        static String PATCH_SERIAL = "";
+        static String PATCH_SKU = "";
+        static String PATCH_SOC_MANUFACTURER = "";
+        static String PATCH_SOC_MODEL = "";
+        static String PATCH_TAGS = "";
+        static String PATCH_TYPE = "";
+        static String PATCH_USER = "";
+    }
+}
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java
index 48c8fd8c..da294d72 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/CheckWatchHistoryDomainNameResolutionPatch.java
@@ -55,7 +55,7 @@ public class CheckWatchHistoryDomainNameResolutionPatch {
                 }
 
                 Utils.runOnMainThread(() -> {
-                    var alertDialog = new android.app.AlertDialog.Builder(context)
+                    var alert = new android.app.AlertDialog.Builder(context)
                             .setTitle(str("revanced_check_watch_history_domain_name_dialog_title"))
                             .setMessage(Html.fromHtml(str("revanced_check_watch_history_domain_name_dialog_message")))
                             .setIconAttribute(android.R.attr.alertDialogIcon)
@@ -64,9 +64,9 @@ public class CheckWatchHistoryDomainNameResolutionPatch {
                             }).setNegativeButton(str("revanced_check_watch_history_domain_name_dialog_ignore"), (dialog, which) -> {
                                 Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.save(false);
                                 dialog.dismiss();
-                            })
-                            .setCancelable(false)
-                            .show();
+                            }).create();
+
+                    Utils.showDialog(context, alert, false, null);
                 });
             } catch (Exception ex) {
                 Logger.printException(() -> "checkDnsResolver failure", ex);
diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/announcements/AnnouncementsPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/announcements/AnnouncementsPatch.java
index eec599ec..225dc206 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/patches/announcements/AnnouncementsPatch.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/patches/announcements/AnnouncementsPatch.java
@@ -1,6 +1,7 @@
 package app.revanced.integrations.youtube.patches.announcements;
 
 import android.app.Activity;
+import android.app.AlertDialog;
 import android.os.Build;
 import android.text.Html;
 import android.text.method.LinkMovementMethod;
@@ -103,8 +104,6 @@ public final class AnnouncementsPatch {
                 // Do not show the announcement, if the last announcement id is the same as the current one.
                 if (Settings.ANNOUNCEMENT_LAST_ID.get() == id) return;
 
-
-
                 int finalId = id;
                 final var finalTitle = title;
                 final var finalMessage = Html.fromHtml(message, FROM_HTML_MODE_COMPACT);
@@ -112,7 +111,7 @@ public final class AnnouncementsPatch {
 
                 Utils.runOnMainThread(() -> {
                     // Show the announcement.
-                    var alertDialog = new android.app.AlertDialog.Builder(context)
+                    var alert = new AlertDialog.Builder(context)
                             .setTitle(finalTitle)
                             .setMessage(finalMessage)
                             .setIcon(finalLevel.icon)
@@ -123,11 +122,13 @@ public final class AnnouncementsPatch {
                                 dialog.dismiss();
                             })
                             .setCancelable(false)
-                            .show();
+                            .create();
 
-                    // Make links clickable.
-                    ((TextView)alertDialog.findViewById(android.R.id.message))
-                            .setMovementMethod(LinkMovementMethod.getInstance());
+                    Utils.showDialog(context, alert, false, (AlertDialog dialog) -> {
+                        // Make links clickable.
+                        ((TextView) dialog.findViewById(android.R.id.message))
+                                .setMovementMethod(LinkMovementMethod.getInstance());
+                    });
                 });
             } catch (Exception e) {
                 final var message = "Failed to get announcement";
diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java
index 8708d579..5a40ed0f 100644
--- a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java
+++ b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java
@@ -1,18 +1,5 @@
 package app.revanced.integrations.youtube.settings;
 
-import static java.lang.Boolean.FALSE;
-import static java.lang.Boolean.TRUE;
-import static app.revanced.integrations.shared.settings.Setting.*;
-import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType;
-import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_1;
-import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_3;
-import static app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch.ClientType;
-import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*;
-
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Set;
-
 import app.revanced.integrations.shared.Logger;
 import app.revanced.integrations.shared.settings.*;
 import app.revanced.integrations.shared.settings.preference.SharedPrefCategory;
@@ -24,6 +11,19 @@ import app.revanced.integrations.youtube.patches.spoof.SpoofAppVersionPatch;
 import app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch;
 import app.revanced.integrations.youtube.sponsorblock.SponsorBlockSettings;
 
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import static app.revanced.integrations.shared.settings.Setting.*;
+import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType;
+import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_1;
+import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_3;
+import static app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch.ClientType;
+import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*;
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+
 @SuppressWarnings("deprecation")
 public class Settings extends BaseSettings {
     // Video
@@ -264,6 +264,7 @@ public class Settings extends BaseSettings {
     public static final IntegerSetting ANNOUNCEMENT_LAST_ID = new IntegerSetting("revanced_announcement_last_id", -1);
     public static final BooleanSetting CHECK_WATCH_HISTORY_DOMAIN_NAME = new BooleanSetting("revanced_check_watch_history_domain_name", TRUE, false, false);
     public static final BooleanSetting REMOVE_TRACKING_QUERY_PARAMETER = new BooleanSetting("revanced_remove_tracking_query_parameter", TRUE);
+    public static final IntegerSetting CHECK_ENVIRONMENT_WARNINGS_ISSUED = new IntegerSetting("revanced_check_environment_warnings_issued", 0, true, false);
 
     // Debugging
     /**