diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f1224497..92e3b017 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,14 +6,11 @@ on: branches: - main - dev - pull_request: - branches: - - main - - dev permissions: contents: write issues: write + packages: write pull-requests: write jobs: @@ -35,13 +32,7 @@ jobs: - name: Build with Gradle env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./gradlew generatePatchesFiles clean - - # - name: Upload patches jar - # uses: actions/upload-artifact@v4 - # with: - # name: patches - # path: build/libs/*.jar + run: ./gradlew buildAndroid clean - name: Setup Node.js uses: actions/setup-node@v4 @@ -52,14 +43,14 @@ jobs: - name: Install dependencies run: npm install + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + fingerprint: ${{ vars.GPG_FINGERPRINT }} + - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm exec semantic-release - - - name: Run apk build - run: | - curl -X POST https://api.github.com/repos/crimera/piko-builds/dispatches \ - -H 'Accept: application/vnd.github+json' \ - -H "Authorization: Bearer ${{ secrets.ACCESS_TOKEN }}" \ - -d '{"event_type": "build"}' diff --git a/.github/workflows/test_pull_request.yml b/.github/workflows/test_pull_request.yml new file mode 100644 index 00000000..51fdd5bf --- /dev/null +++ b/.github/workflows/test_pull_request.yml @@ -0,0 +1,32 @@ +name: Test pull request + +on: + workflow_dispatch: + pull_request: + branches: + - main + - dev + +permissions: + contents: read + +jobs: + release: + name: Test pull request + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Make sure the release step uses its own credentials: + # https://github.com/cycjimmy/semantic-release-action#private-packages + persist-credentials: false + fetch-depth: 0 + + - name: Cache Gradle + uses: burrunan/gradle-cache-action@v1 + + - name: Build with Gradle + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew buildAndroid clean diff --git a/.gitignore b/.gitignore index 899b7cc6..291fa32c 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,8 @@ node_modules/ # Ignore IDEA files .idea/ /src/main/kotlin/crimera/patches/twitter/test* -bin/ \ No newline at end of file +bin/ + +*.bak + +/local.properties diff --git a/.releaserc b/.releaserc index aee230e1..27ee4ccf 100644 --- a/.releaserc +++ b/.releaserc @@ -7,7 +7,9 @@ } ], "plugins": [ - ["@semantic-release/commit-analyzer", { + [ + "@semantic-release/commit-analyzer", + { "releaseRules": [ { "type": "fix", "release": "patch" }, { "type": "feat", "release": "minor" }, @@ -39,10 +41,8 @@ "@semantic-release/git", { "assets": [ - "README.md", "CHANGELOG.md", - "gradle.properties", - "patches.json" + "gradle.properties" ] } ], @@ -51,20 +51,17 @@ { "assets": [ { - "path": "build/libs/*.jar" - }, - { - "path": "patches.json" + "path": "patches/build/libs/patches-!(*sources*|*javadoc*).rvp?(.asc)" } ], - successComment: false + "successComment": false } ], [ "@saithodev/semantic-release-backmerge", { - backmergeBranches: [{"from": "main", "to": "dev"}], - clearWorkspace: true + "backmergeBranches": [{"from": "main", "to": "dev"}], + "clearWorkspace": true } ] ] diff --git a/CHANGELOG.md b/CHANGELOG.md index eeb5349f..8a35d0ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +## [2.0.0-dev.4](https://github.com/crimera/piko/compare/v2.0.0-dev.3...v2.0.0-dev.4) (2025-10-08) + +### Updates + +* **Translations:** Update `Polish` ([43da4b5](https://github.com/crimera/piko/commit/43da4b5ebc4b12d155d8f81495bbd6a09b1f44ad)) + +## [2.0.0-dev.3](https://github.com/crimera/piko/compare/v2.0.0-dev.2...v2.0.0-dev.3) (2025-10-04) + +### Refactors + +* **Twitter:** Correct ja strings replacement in BBT patch ([ee200a5](https://github.com/crimera/piko/commit/ee200a5c5ae14e1a935903b5de9e7ecf7f03e0f9)) +* **Twitter:** fix `Remove promoted trends` ([0e1d4ed](https://github.com/crimera/piko/commit/0e1d4ed2ac090451172f2bed985310061b987428)) +* **Twitter:** handle piko resources programmatically ([3cc0f07](https://github.com/crimera/piko/commit/3cc0f0715c6ad62c14d6cd13e91f0965886e1534)) +* **Twitter:** refactor `Bring back twitter` patch ([f24cb72](https://github.com/crimera/piko/commit/f24cb72309f648493b209fffb1e7d19bd5c705f6)) +* **Twitter:** remove old comments ([1655283](https://github.com/crimera/piko/commit/165528374dadeab472a5f7d28bc60be12a09bbe0)) + +## [2.0.0-dev.2](https://github.com/crimera/piko/compare/v2.0.0-dev.1...v2.0.0-dev.2) (2025-09-27) + +### Bug Fixes + +* **Twitter:** Fix remove ads on replies ([8fda05e](https://github.com/crimera/piko/commit/8fda05ec94ffc7a48dfc1eb3a17bb14f6328ca6f)) +* **Universal:** Set version code as int ([ee83df7](https://github.com/crimera/piko/commit/ee83df7f8f18cf4cf2306b0a662a845ed565e106)) + +### Refactors + +* **Twitter:** potential fix for resource compilation failing in manager ([fa0dfac](https://github.com/crimera/piko/commit/fa0dfac2b959ba9a958bb458cc4572f28ff94902)) + +## [2.0.0-dev.1](https://github.com/crimera/piko/compare/v1.59.0...v2.0.0-dev.1) (2025-09-24) + +### ⚠ BREAKING CHANGES + +* Various APIs have been changed or removed. + +### Bug Fixes + +* some patch has compatibleWith and dependsOn in execute block ([dd25a6c](https://github.com/crimera/piko/commit/dd25a6cd43354b8d6f4cb60d7798c539b06a6e50)) +* wrong index number on multiple medias ([d088d03](https://github.com/crimera/piko/commit/d088d03a5dc743e1e50f403d14b71bc59b38571b)) + +### Features + +* Migrate ci ([c737ca8](https://github.com/crimera/piko/commit/c737ca8edef074ff1beff17db7d27e5f26c17dc1)) + ## [1.59.0](https://github.com/crimera/piko/compare/v1.58.1...v1.59.0) (2025-09-11) ### Features diff --git a/README.md b/README.md index de3d51ad..17b23ae8 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,8 @@ Revanced Cli -🚨 Use [Revanced Cli v4.6.0](https://github.com/ReVanced/revanced-cli/releases/tag/v4.6.0) for building. -The latest Cli is not compatible. - -Download [crimera/piko](https://github.com/crimera/piko/releases) patches and [crimera/revanced-integrations](https://github.com/crimera/revanced-integrations/releases). - ```sh -java -jar cli.jar patch \ - -b piko.jar \ - -m integrations.apk \ - -o out.apk input.apk +java -jar cli.jar patch -p piko.rvp input.apk ```

or Revanced Manager @@ -31,15 +23,16 @@ java -jar cli.jar patch \

-Currently, piko patches are not compatible with the latest version of Revanced Manager. -Use [Revanced Manager v1.22.0](https://github.com/ReVanced/revanced-manager/releases/tag/v1.22.0) or [RVX Manager v1.22.2](https://github.com/inotia00/revanced-manager/releases/tag/v1.22.2). + +Starting with piko patches v2.0.0, the latest ReVanced Manager and RVX Manager are supported. To use these patches in ReVanced Manager, follow the steps below or see image: -1. Set `crimera` as the patches organization.
-2. Set `piko` the patches source.
-3. Set `crimera` as integration organization.
-4. Press ok then force stop Revanced Manager before opening it again +1. Turn on "Use alternative sources" setting. +2. Open "Alternative sources". +3. Set `crimera` as the patches organization.
+4. Set `piko` as the patches source.
+5. Press ok then restart Revanced Manager. usage
diff --git a/build.gradle.kts b/build.gradle.kts index 166bf5e9..fcaeeb54 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,120 +1,3 @@ -import org.gradle.kotlin.dsl.support.listFilesOrdered - plugins { - kotlin("jvm") version "2.0.0" - `maven-publish` -} - -group = "crimera" - -repositories { - mavenCentral() - mavenLocal() - google() - maven { url = uri("https://jitpack.io") } -} - -dependencies { - implementation(libs.revanced.patcher) - implementation(libs.smali) - // TODO: Required because build fails without it. Find a way to remove this dependency. - implementation(libs.guava) - // Used in JsonGenerator. - implementation(libs.gson) - - // A dependency to the Android library unfortunately fails the build, which is why this is required. - compileOnly(project("dummy")) -} - -kotlin { - jvmToolchain(11) -} - -tasks.withType(Jar::class) { - manifest { - attributes["Name"] = "Piko" - attributes["Description"] = "Patches for ReVanced." - attributes["Version"] = version - attributes["Timestamp"] = System.currentTimeMillis().toString() - attributes["Source"] = "git@github.com:you/revanced-patches.git" - attributes["Author"] = "crimera" - attributes["Contact"] = "contact@your.homepage" - attributes["Origin"] = "https://your.homepage" - attributes["License"] = "GNU General Public License v3.0" - } -} - -tasks { - register("generateBundle") { - description = "Generate DEX files and add them in the JAR file" - - dependsOn(build) - - doLast { - val d8 = File(System.getenv("ANDROID_HOME")).resolve("build-tools") - .listFilesOrdered().last().resolve("d8").absolutePath - - val artifacts = configurations.archives.get().allArtifacts.files.files.first().absolutePath - val workingDirectory = layout.buildDirectory.dir("libs").get().asFile - - exec { - workingDir = workingDirectory - commandLine = listOf(d8, artifacts) - } - - exec { - workingDir = workingDirectory - commandLine = listOf("zip", "-u", artifacts, "classes.dex") - } - } - } - - register("generatePatchesFiles") { - description = "Generate patches files" - - dependsOn(build) - - classpath = sourceSets["main"].runtimeClasspath - mainClass.set("app.revanced.generator.MainKt") - } - - // Required to run tasks because Gradle semantic-release plugin runs the publish task. - // Tracking: https://github.com/KengoTODA/gradle-semantic-release-plugin/issues/435 - named("publish") { - dependsOn("generateBundle") - dependsOn("generatePatchesFiles") - } -} - -publishing { - publications { - create("revanced-patches-publication") { - from(components["java"]) - - pom { - name = "Piko" - description = "Patches for ReVanced." - url = "https://your.homepage" - - licenses { - license { - name = "GNU General Public License v3.0" - url = "https://www.gnu.org/licenses/gpl-3.0.en.html" - } - } - developers { - developer { - id = "Your ID" - name = "crimera" - email = "contact@your.homepage" - } - } - scm { - connection = "scm:git:git://github.com/crimera/piko.git" - developerConnection = "scm:git:git@github.com:crimera/piko.git" - url = "https://github.com/crimera/piko" - } - } - } - } + alias(libs.plugins.android.library) apply false } diff --git a/dummy/build.gradle.kts b/dummy/build.gradle.kts deleted file mode 100644 index 04b0d269..00000000 --- a/dummy/build.gradle.kts +++ /dev/null @@ -1,9 +0,0 @@ -plugins { - id("java") -} - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(11)) - } -} \ No newline at end of file diff --git a/extensions/proguard-rules.pro b/extensions/proguard-rules.pro new file mode 100644 index 00000000..06a543ce --- /dev/null +++ b/extensions/proguard-rules.pro @@ -0,0 +1,10 @@ +-dontobfuscate +-dontoptimize +-keepattributes * +-keep class app.revanced.** { + *; +} + +-keep class com.google.** { + *; +} diff --git a/extensions/shared/build.gradle.kts b/extensions/shared/build.gradle.kts new file mode 100644 index 00000000..2da2e1e8 --- /dev/null +++ b/extensions/shared/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation(project(":extensions:shared:library")) +} diff --git a/extensions/shared/library/build.gradle.kts b/extensions/shared/library/build.gradle.kts new file mode 100644 index 00000000..4177cb12 --- /dev/null +++ b/extensions/shared/library/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "app.revanced.extension" + compileSdk = 34 + + defaultConfig { + minSdk = 23 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + compileOnly(libs.annotation) +} diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Logger.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Logger.java new file mode 100644 index 00000000..47f6da3e --- /dev/null +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Logger.java @@ -0,0 +1,214 @@ +package app.revanced.extension.shared; + +import static app.revanced.extension.shared.settings.BaseSettings.DEBUG; +import static app.revanced.extension.shared.settings.BaseSettings.DEBUG_STACKTRACE; +import static app.revanced.extension.shared.settings.BaseSettings.DEBUG_TOAST_ON_ERROR; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.preference.LogBufferManager; + +/** + * ReVanced specific logger. Logging is done to standard device log (accessible thru ADB), + * and additionally accessible thru {@link LogBufferManager}. + * + * All methods are thread safe, and are safe to call even + * if {@link Utils#getContext()} is not available. + */ +public class Logger { + + /** + * Log messages using lambdas. + */ + @FunctionalInterface + public interface LogMessage { + /** + * @return Logger string message. This method is only called if logging is enabled. + */ + @NonNull + String buildMessageString(); + } + + private enum LogLevel { + DEBUG, + INFO, + ERROR + } + + /** + * Log tag prefix. Only used for system logging. + */ + private static final String REVANCED_LOG_TAG_PREFIX = "revanced: "; + + private static final String LOGGER_CLASS_NAME = Logger.class.getName(); + + /** + * @return For outer classes, this returns {@link Class#getSimpleName()}. + * For static, inner, or anonymous classes, this returns the simple name of the enclosing class. + *
+ * For example, each of these classes returns 'SomethingView': + * + * com.company.SomethingView + * com.company.SomethingView$StaticClass + * com.company.SomethingView$1 + * + */ + private static String getOuterClassSimpleName(Object obj) { + Class logClass = obj.getClass(); + String fullClassName = logClass.getName(); + final int dollarSignIndex = fullClassName.indexOf('$'); + if (dollarSignIndex < 0) { + return logClass.getSimpleName(); // Already an outer class. + } + + // Class is inner, static, or anonymous. + // Parse the simple name full name. + // A class with no package returns index of -1, but incrementing gives index zero which is correct. + final int simpleClassNameStartIndex = fullClassName.lastIndexOf('.') + 1; + return fullClassName.substring(simpleClassNameStartIndex, dollarSignIndex); + } + + /** + * Internal method to handle logging to Android Log and {@link LogBufferManager}. + * Appends the log message, stack trace (if enabled), and exception (if present) to logBuffer + * with class name but without 'revanced:' prefix. + * + * @param logLevel The log level. + * @param message Log message object. + * @param ex Optional exception. + * @param includeStackTrace If the current stack should be included. + * @param showToast If a toast is to be shown. + */ + private static void logInternal(LogLevel logLevel, LogMessage message, @Nullable Throwable ex, + boolean includeStackTrace, boolean showToast) { + // It's very important that no Settings are used in this method, + // as this code is used when a context is not set and thus referencing + // a setting will crash the app. + String messageString = message.buildMessageString(); + String className = getOuterClassSimpleName(message); + + String logText = messageString; + + // Append exception message if present. + if (ex != null) { + var exceptionMessage = ex.getMessage(); + if (exceptionMessage != null) { + logText += "\nException: " + exceptionMessage; + } + } + + if (includeStackTrace) { + var sw = new StringWriter(); + new Throwable().printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + // Remove the stacktrace elements of this class. + final int loggerIndex = stackTrace.lastIndexOf(LOGGER_CLASS_NAME); + final int loggerBegins = stackTrace.indexOf('\n', loggerIndex); + logText += stackTrace.substring(loggerBegins); + } + + // Do not include "revanced:" prefix in clipboard logs. + String managerToastString = className + ": " + logText; + LogBufferManager.appendToLogBuffer(managerToastString); + + String logTag = REVANCED_LOG_TAG_PREFIX + className; + switch (logLevel) { + case DEBUG: + if (ex == null) Log.d(logTag, logText); + else Log.d(logTag, logText, ex); + break; + case INFO: + if (ex == null) Log.i(logTag, logText); + else Log.i(logTag, logText, ex); + break; + case ERROR: + if (ex == null) Log.e(logTag, logText); + else Log.e(logTag, logText, ex); + break; + } + + if (showToast) { + Utils.showToastLong(managerToastString); + } + } + + private static boolean shouldLogDebug() { + // If the app is still starting up and the context is not yet set, + // then allow debug logging regardless what the debug setting actually is. + return Utils.context == null || DEBUG.get(); + } + + private static boolean shouldShowErrorToast() { + return Utils.context != null && DEBUG_TOAST_ON_ERROR.get(); + } + + private static boolean includeStackTrace() { + return Utils.context != null && DEBUG_STACKTRACE.get(); + } + + /** + * 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(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(LogMessage message, @Nullable Exception ex) { + if (shouldLogDebug()) { + logInternal(LogLevel.DEBUG, message, ex, includeStackTrace(), false); + } + } + + /** + * Logs information messages using the outer class name of the code calling this method. + */ + public static void printInfo(LogMessage message) { + printInfo(message, null); + } + + /** + * Logs information messages using the outer class name of the code calling this method. + */ + public static void printInfo(LogMessage message, @Nullable Exception ex) { + logInternal(LogLevel.INFO, message, ex, includeStackTrace(), false); + } + + /** + * Logs exceptions under the outer class name of the code calling this method. + * Appends the log message, exception (if present), and toast message (if enabled) to logBuffer. + */ + public static void printException(LogMessage message) { + printException(message, null); + } + + /** + * Logs exceptions under the outer class name of the code calling this method. + *

+ * If the calling code is showing it's own error toast, + * instead use {@link #printInfo(LogMessage, Exception)} + * + * @param message log message + * @param ex exception (optional) + */ + public static void printException(LogMessage message, @Nullable Throwable ex) { + logInternal(LogLevel.ERROR, message, ex, includeStackTrace(), shouldShowErrorToast()); + } +} diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringRef.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringRef.java new file mode 100644 index 00000000..4390137d --- /dev/null +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/StringRef.java @@ -0,0 +1,122 @@ +package app.revanced.extension.shared; + +import android.content.Context; +import android.content.res.Resources; + +import androidx.annotation.NonNull; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class StringRef { + private static Resources resources; + private static String packageName; + + // must use a thread safe map, as this class is used both on and off the main thread + private static final Map strings = Collections.synchronizedMap(new HashMap<>()); + + /** + * Returns a cached instance. + * Should be used if the same String could be loaded more than once. + * + * @param id string resource name/id + * @see #sf(String) + */ + @NonNull + public static StringRef sfc(@NonNull String id) { + StringRef ref = strings.get(id); + if (ref == null) { + ref = new StringRef(id); + strings.put(id, ref); + } + return ref; + } + + /** + * Creates a new instance, but does not cache the value. + * Should be used for Strings that are loaded exactly once. + * + * @param id string resource name/id + * @see #sfc(String) + */ + @NonNull + public static StringRef sf(@NonNull String id) { + return new StringRef(id); + } + + /** + * Gets string value by string id, shorthand for sfc(id).toString() + * + * @param id string resource name/id + * @return String value from string.xml + */ + @NonNull + public static String str(@NonNull String id) { + return sfc(id).toString(); + } + + /** + * Gets string value by string id, shorthand for sfc(id).toString() and formats the string + * with given args. + * + * @param id string resource name/id + * @param args the args to format the string with + * @return String value from string.xml formatted with given args + */ + @NonNull + public static String str(@NonNull String id, Object... args) { + return String.format(str(id), args); + } + + /** + * Creates a StringRef object that'll not change it's value + * + * @param value value which toString() method returns when invoked on returned object + * @return Unique StringRef instance, its value will never change + */ + @NonNull + public static StringRef constant(@NonNull String value) { + final StringRef ref = new StringRef(value); + ref.resolved = true; + return ref; + } + + /** + * Shorthand for constant("") + * Its value always resolves to empty string + */ + @NonNull + public static final StringRef empty = constant(""); + + @NonNull + private String value; + private boolean resolved; + + public StringRef(@NonNull String resName) { + this.value = resName; + } + + @Override + @NonNull + public String toString() { + if (!resolved) { + if (resources == null || packageName == null) { + Context context = Utils.getContext(); + resources = context.getResources(); + packageName = context.getPackageName(); + } + resolved = true; + if (resources != null) { + final int identifier = resources.getIdentifier(value, "string", packageName); + if (identifier == 0) + Logger.printException(() -> "Resource not found: " + value); + else + value = resources.getString(identifier); + } else { + Logger.printException(() -> "Could not resolve resources!"); + } + } + return value; + } +} diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java new file mode 100644 index 00000000..f412faa0 --- /dev/null +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java @@ -0,0 +1,1540 @@ +package app.revanced.extension.shared; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import android.net.ConnectivityManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.preference.Preference; +import android.preference.PreferenceGroup; +import android.preference.PreferenceScreen; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.util.DisplayMetrics; +import android.util.Pair; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.Window; +import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.Toolbar; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.text.Bidi; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.Future; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import app.revanced.extension.shared.settings.AppLanguage; +import app.revanced.extension.shared.settings.BaseSettings; +import app.revanced.extension.shared.settings.BooleanSetting; +import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference; + +public class Utils { + + @SuppressLint("StaticFieldLeak") + public static Context context; + private static String versionName; + private static String applicationLabel; + + @ColorInt + private static int darkColor = Color.BLACK; + @ColorInt + private static int lightColor = Color.WHITE; + + @Nullable + private static Boolean isDarkModeEnabled; + + private Utils() { + } // utility class + + + /** + * Injection point. + **/ + public static void load() { + } + /** + * @return The manifest 'Version' entry of the patches.jar used during patching. + */ + @SuppressWarnings("SameReturnValue") + public static String getPatchesReleaseVersion() { + return ""; // Value is replaced during patching. + } + + private static PackageInfo getPackageInfo() throws PackageManager.NameNotFoundException { + final var packageName = Objects.requireNonNull(getContext()).getPackageName(); + + PackageManager packageManager = context.getPackageManager(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return packageManager.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(0) + ); + } + + return packageManager.getPackageInfo( + packageName, + 0 + ); + } + + + /** + * @return The version name of the app, such as 19.11.43 + */ + public static String getAppVersionName() { + if (versionName == null) { + try { + versionName = getPackageInfo().versionName; + } catch (Exception ex) { + Logger.printException(() -> "Failed to get package info", ex); + versionName = "Unknown"; + } + } + + return versionName; + } + + public static String getApplicationName() { + if (applicationLabel == null) { + try { + ApplicationInfo applicationInfo = getPackageInfo().applicationInfo; + applicationLabel = (String) applicationInfo.loadLabel(context.getPackageManager()); + } catch (Exception ex) { + Logger.printException(() -> "Failed to get application name", ex); + applicationLabel = "Unknown"; + } + } + + return applicationLabel; + } + + /** + * Hide a view by setting its layout height and width to 1dp. + * + * @param condition The setting to check for hiding the view. + * @param view The view to hide. + */ + public static void hideViewBy0dpUnderCondition(BooleanSetting condition, View view) { + if (hideViewBy0dpUnderCondition(condition.get(), view)) { + Logger.printDebug(() -> "View hidden by setting: " + condition); + } + } + + /** + * Hide a view by setting its layout height and width to 0dp. + * + * @param condition The setting to check for hiding the view. + * @param view The view to hide. + */ + public static boolean hideViewBy0dpUnderCondition(boolean condition, View view) { + if (condition) { + hideViewByLayoutParams(view); + return true; + } + + return false; + } + + /** + * Hide a view by setting its visibility to GONE. + * + * @param condition The setting to check for hiding the view. + * @param view The view to hide. + */ + public static void hideViewUnderCondition(BooleanSetting condition, View view) { + if (hideViewUnderCondition(condition.get(), view)) { + Logger.printDebug(() -> "View hidden by setting: " + condition); + } + } + + /** + * Hide a view by setting its visibility to GONE. + * + * @param condition The setting to check for hiding the view. + * @param view The view to hide. + */ + public static boolean hideViewUnderCondition(boolean condition, View view) { + if (condition) { + view.setVisibility(View.GONE); + return true; + } + + return false; + } + + public static void hideViewByRemovingFromParentUnderCondition(BooleanSetting condition, View view) { + if (hideViewByRemovingFromParentUnderCondition(condition.get(), view)) { + Logger.printDebug(() -> "View hidden by setting: " + condition); + } + } + + public static boolean hideViewByRemovingFromParentUnderCondition(boolean setting, View view) { + if (setting) { + ViewParent parent = view.getParent(); + if (parent instanceof ViewGroup parentGroup) { + parentGroup.removeView(view); + return true; + } + } + + return false; + } + + /** + * General purpose pool for network calls and other background tasks. + * All tasks run at max thread priority. + */ + private static final ThreadPoolExecutor backgroundThreadPool = new ThreadPoolExecutor( + 3, // 3 threads always ready to go. + Integer.MAX_VALUE, + 10, // For any threads over the minimum, keep them alive 10 seconds after they go idle. + TimeUnit.SECONDS, + new SynchronousQueue<>(), + r -> { // ThreadFactory + Thread t = new Thread(r); + t.setPriority(Thread.MAX_PRIORITY); // Run at max priority. + return t; + }); + + public static void runOnBackgroundThread(Runnable task) { + backgroundThreadPool.execute(task); + } + + public static Future submitOnBackgroundThread(Callable call) { + return backgroundThreadPool.submit(call); + } + + /** + * Simulates a delay by doing meaningless calculations. + * Used for debugging to verify UI timeout logic. + */ + @SuppressWarnings("UnusedReturnValue") + public static long doNothingForDuration(long amountOfTimeToWaste) { + final long timeCalculationStarted = System.currentTimeMillis(); + Logger.printDebug(() -> "Artificially creating delay of: " + amountOfTimeToWaste + "ms"); + + long meaninglessValue = 0; + while (System.currentTimeMillis() - timeCalculationStarted < amountOfTimeToWaste) { + // Could do a thread sleep, but that will trigger an exception if the thread is interrupted. + meaninglessValue += Long.numberOfLeadingZeros((long) Math.exp(Math.random())); + } + // Return the value, otherwise the compiler or VM might optimize and remove the meaningless time wasting work, + // leaving an empty loop that hammers on the System.currentTimeMillis native call. + return meaninglessValue; + } + + public static boolean containsAny(String value, String... targets) { + return indexOfFirstFound(value, targets) >= 0; + } + + public static int indexOfFirstFound(String value, String... targets) { + for (String string : targets) { + if (!string.isEmpty()) { + final int indexOf = value.indexOf(string); + if (indexOf >= 0) return indexOf; + } + } + return -1; + } + + /** + * @return zero, if the resource is not found. + */ + @SuppressLint("DiscouragedApi") + public static int getResourceIdentifier(Context context, String resourceIdentifierName, String type) { + return context.getResources().getIdentifier(resourceIdentifierName, type, context.getPackageName()); + } + + /** + * @return zero, if the resource is not found. + */ + public static int getResourceIdentifier(String resourceIdentifierName, String type) { + return getResourceIdentifier(getContext(), resourceIdentifierName, type); + } + + public static int getResourceInteger(String resourceIdentifierName) throws Resources.NotFoundException { + return getContext().getResources().getInteger(getResourceIdentifier(resourceIdentifierName, "integer")); + } + + public static Animation getResourceAnimation(String resourceIdentifierName) throws Resources.NotFoundException { + return AnimationUtils.loadAnimation(getContext(), getResourceIdentifier(resourceIdentifierName, "anim")); + } + + @ColorInt + public static int getResourceColor(String resourceIdentifierName) throws Resources.NotFoundException { + //noinspection deprecation + return getContext().getResources().getColor(getResourceIdentifier(resourceIdentifierName, "color")); + } + + public static int getResourceDimensionPixelSize(String resourceIdentifierName) throws Resources.NotFoundException { + return getContext().getResources().getDimensionPixelSize(getResourceIdentifier(resourceIdentifierName, "dimen")); + } + + public static float getResourceDimension(String resourceIdentifierName) throws Resources.NotFoundException { + return getContext().getResources().getDimension(getResourceIdentifier(resourceIdentifierName, "dimen")); + } + + public static String[] getResourceStringArray(String resourceIdentifierName) throws Resources.NotFoundException { + return getContext().getResources().getStringArray(getResourceIdentifier(resourceIdentifierName, "array")); + } + + public static String getResourceString(@NonNull String resourceIdentifierName) throws Resources.NotFoundException { + return getContext().getResources().getString(getResourceIdentifier(resourceIdentifierName, "string")); + } + + public interface MatchFilter { + boolean matches(T object); + } + + /** + * Includes sub children. + */ + public static R getChildViewByResourceName(View view, String str) { + var child = view.findViewById(Utils.getResourceIdentifier(str, "id")); + if (child != null) { + //noinspection unchecked + return (R) child; + } + + throw new IllegalArgumentException("View with resource name not found: " + str); + } + + /** + * @param searchRecursively If children ViewGroups should also be + * recursively searched using depth first search. + * @return The first child view that matches the filter. + */ + @Nullable + public static T getChildView(ViewGroup viewGroup, boolean searchRecursively, + MatchFilter filter) { + for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) { + View childAt = viewGroup.getChildAt(i); + + if (filter.matches(childAt)) { + //noinspection unchecked + return (T) childAt; + } + // Must do recursive after filter check, in case the filter is looking for a ViewGroup. + if (searchRecursively && childAt instanceof ViewGroup) { + T match = getChildView((ViewGroup) childAt, true, filter); + if (match != null) return match; + } + } + + return null; + } + + @Nullable + public static ViewParent getParentView(View view, int nthParent) { + ViewParent parent = view.getParent(); + + int currentDepth = 0; + while (++currentDepth < nthParent && parent != null) { + parent = parent.getParent(); + } + + if (currentDepth == nthParent) { + return parent; + } + + final int currentDepthLog = currentDepth; + Logger.printDebug(() -> "Could not find parent view of depth: " + nthParent + + " and instead found at: " + currentDepthLog + " view: " + view); + return null; + } + + public static void restartApp(Context context) { + String packageName = context.getPackageName(); + Intent intent = Objects.requireNonNull(context.getPackageManager().getLaunchIntentForPackage(packageName)); + Intent mainIntent = Intent.makeRestartActivityTask(intent.getComponent()); + // Required for API 34 and later + // Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents + mainIntent.setPackage(packageName); + context.startActivity(mainIntent); + System.exit(0); + } + + public static Context getContext() { + if (context == null) { + Logger.printException(() -> "Context is not set by extension hook, returning null", null); + } + return context; + } + + public static void setContext(Context appContext) { + // Intentionally use logger before context is set, + // to expose any bugs in the 'no context available' logger code. + Logger.printInfo(() -> "Set context: " + appContext); + // Must initially set context to check the app language. + context = appContext; + + AppLanguage language = BaseSettings.REVANCED_LANGUAGE.get(); + if (language != AppLanguage.DEFAULT) { + // Create a new context with the desired language. + Logger.printDebug(() -> "Using app language: " + language); + Configuration config = new Configuration(appContext.getResources().getConfiguration()); + config.setLocale(language.getLocale()); + context = appContext.createConfigurationContext(config); + } + + setThemeLightColor(getThemeColor(getThemeLightColorResourceName(), Color.WHITE)); + setThemeDarkColor(getThemeColor(getThemeDarkColorResourceName(), Color.BLACK)); + + load(); + } + + public static void setClipboard(CharSequence text) { + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context + .getSystemService(Context.CLIPBOARD_SERVICE); + android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text); + clipboard.setPrimaryClip(clip); + } + + public static boolean isTablet() { + return context.getResources().getConfiguration().smallestScreenWidthDp >= 600; + } + + @Nullable + private static Boolean isRightToLeftTextLayout; + + /** + * @return If the device language uses right to left text layout (Hebrew, Arabic, etc). + * If this should match any ReVanced language override then instead use + * {@link #isRightToLeftLocale(Locale)} with {@link BaseSettings#REVANCED_LANGUAGE}. + * This is the default locale of the device, which may differ if + * {@link BaseSettings#REVANCED_LANGUAGE} is set to a different language. + */ + public static boolean isRightToLeftLocale() { + if (isRightToLeftTextLayout == null) { + isRightToLeftTextLayout = isRightToLeftLocale(Locale.getDefault()); + } + return isRightToLeftTextLayout; + } + + public static void shareText(String txt) { + final String appPackageName = context.getPackageName(); + Intent sendIntent = new Intent(); + sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, txt); + sendIntent.setType("text/plain"); + context.startActivity(sendIntent); + } + + /** + * @return If the locale uses right to left text layout (Hebrew, Arabic, etc). + */ + public static boolean isRightToLeftLocale(Locale locale) { + String displayLanguage = locale.getDisplayLanguage(); + return new Bidi(displayLanguage, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isRightToLeft(); + } + + /** + * @return A UTF8 string containing a left-to-right or right-to-left + * character of the device locale. If this should match any ReVanced language + * override then instead use {@link #getTextDirectionString(Locale)} with + * {@link BaseSettings#REVANCED_LANGUAGE}. + */ + public static String getTextDirectionString() { + return getTextDirectionString(isRightToLeftLocale()); + } + + public static String getTextDirectionString(Locale locale) { + return getTextDirectionString(isRightToLeftLocale(locale)); + } + + private static String getTextDirectionString(boolean isRightToLeft) { + return isRightToLeft + ? "\u200F" // u200F = right to left character. + : "\u200E"; // u200E = left to right character. + } + + /** + * @return if the text contains at least 1 number character, + * including any unicode numbers such as Arabic. + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean containsNumber(CharSequence text) { + for (int index = 0, length = text.length(); index < length;) { + final int codePoint = Character.codePointAt(text, index); + if (Character.isDigit(codePoint)) { + return true; + } + index += Character.charCount(codePoint); + } + + return false; + } + + /** + * Ignore this class. It must be public to satisfy Android requirements. + */ + @SuppressWarnings("deprecation") + public static final 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(dialog); + } + } catch (Exception ex) { + Logger.printException(() -> "onStart failure: " + dialog.getClass().getSimpleName(), ex); + } + } + } + + /** + * Interface for {@link #showDialog(Activity, Dialog, boolean, DialogFragmentOnStartAction)}. + */ + @FunctionalInterface + public interface DialogFragmentOnStartAction { + void onStart(Dialog dialog); + } + + public static void showDialog(Activity activity, Dialog dialog) { + showDialog(activity, dialog, true, null); + } + + /** + * Utility method to allow showing a Dialog on top of other dialogs. + * Calling this will always display the dialog on top of all other dialogs + * previously called using this method. + *

+ * 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. + *

+ * 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. + *

+ * For all other situations it's better to not use this method and + * call {@link Dialog#show()} on the dialog. + */ + @SuppressWarnings("deprecation") + public static void showDialog(Activity activity, + Dialog 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. + */ + public static void showToastShort(String messageToToast) { + showToast(messageToToast, Toast.LENGTH_SHORT); + } + + /** + * Safe to call from any thread. + */ + public static void showToastLong(String messageToToast) { + showToast(messageToToast, Toast.LENGTH_LONG); + } + + private static void showToast(String messageToToast, int toastDuration) { + Objects.requireNonNull(messageToToast); + runOnMainThreadNowOrLater(() -> { + Context currentContext = context; + + if (currentContext == null) { + Logger.printException(() -> "Cannot show toast (context is null): " + messageToToast); + } else { + Logger.printDebug(() -> "Showing toast: " + messageToToast); + Toast.makeText(currentContext, messageToToast, toastDuration).show(); + } + }); + } + + /** + * @return The current dark mode as set by any patch. + * Or if none is set, then the system dark mode status is returned. + */ + public static boolean isDarkModeEnabled() { + Boolean isDarkMode = isDarkModeEnabled; + if (isDarkMode != null) { + return isDarkMode; + } + + Configuration config = Resources.getSystem().getConfiguration(); + final int currentNightMode = config.uiMode & Configuration.UI_MODE_NIGHT_MASK; + return currentNightMode == Configuration.UI_MODE_NIGHT_YES; + } + + /** + * Overrides dark mode status as returned by {@link #isDarkModeEnabled()}. + */ + public static void setIsDarkModeEnabled(boolean isDarkMode) { + isDarkModeEnabled = isDarkMode; + Logger.printDebug(() -> "Dark mode status: " + isDarkMode); + } + + public static boolean isLandscapeOrientation() { + final int orientation = Resources.getSystem().getConfiguration().orientation; + return orientation == Configuration.ORIENTATION_LANDSCAPE; + } + + /** + * Automatically logs any exceptions the runnable throws. + * + * @see #runOnMainThreadNowOrLater(Runnable) + */ + public static void runOnMainThread(Runnable runnable) { + runOnMainThreadDelayed(runnable, 0); + } + + /** + * Automatically logs any exceptions the runnable throws. + */ + public static void runOnMainThreadDelayed(Runnable runnable, long delayMillis) { + Runnable loggingRunnable = () -> { + try { + runnable.run(); + } catch (Exception ex) { + Logger.printException(() -> runnable.getClass().getSimpleName() + ": " + ex.getMessage(), ex); + } + }; + new Handler(Looper.getMainLooper()).postDelayed(loggingRunnable, delayMillis); + } + + /** + * If called from the main thread, the code is run immediately. + * If called off the main thread, this is the same as {@link #runOnMainThread(Runnable)}. + */ + public static void runOnMainThreadNowOrLater(Runnable runnable) { + if (isCurrentlyOnMainThread()) { + runnable.run(); + } else { + runOnMainThread(runnable); + } + } + + /** + * @return if the calling thread is on the main thread. + */ + public static boolean isCurrentlyOnMainThread() { + return Looper.getMainLooper().isCurrentThread(); + } + + /** + * @throws IllegalStateException if the calling thread is _off_ the main thread. + */ + public static void verifyOnMainThread() throws IllegalStateException { + if (!isCurrentlyOnMainThread()) { + throw new IllegalStateException("Must call _on_ the main thread"); + } + } + + /** + * @throws IllegalStateException if the calling thread is _on_ the main thread. + */ + public static void verifyOffMainThread() throws IllegalStateException { + if (isCurrentlyOnMainThread()) { + throw new IllegalStateException("Must call _off_ the main thread"); + } + } + + public enum NetworkType { + NONE, + MOBILE, + OTHER, + } + + /** + * Calling extension code must ensure the un-patched app has the permission + * android.permission.ACCESS_NETWORK_STATE, + * otherwise the app will crash if this method is used. + */ + public static boolean isNetworkConnected() { + NetworkType networkType = getNetworkType(); + return networkType == NetworkType.MOBILE + || networkType == NetworkType.OTHER; + } + + /** + * Calling extension code must ensure the un-patched app has the permission + * android.permission.ACCESS_NETWORK_STATE, + * otherwise the app will crash if this method is used. + */ + @SuppressWarnings({"MissingPermission", "deprecation"}) + public static NetworkType getNetworkType() { + Context networkContext = getContext(); + if (networkContext == null) { + return NetworkType.NONE; + } + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + var networkInfo = cm.getActiveNetworkInfo(); + + if (networkInfo == null || !networkInfo.isConnected()) { + return NetworkType.NONE; + } + var type = networkInfo.getType(); + return (type == ConnectivityManager.TYPE_MOBILE) + || (type == ConnectivityManager.TYPE_BLUETOOTH) ? NetworkType.MOBILE : NetworkType.OTHER; + } + + /** + * Hide a view by setting its layout params to 0x0 + * @param view The view to hide. + */ + public static void hideViewByLayoutParams(View view) { + if (view instanceof LinearLayout) { + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(0, 0); + view.setLayoutParams(layoutParams); + } else if (view instanceof FrameLayout) { + FrameLayout.LayoutParams layoutParams2 = new FrameLayout.LayoutParams(0, 0); + view.setLayoutParams(layoutParams2); + } else if (view instanceof RelativeLayout) { + RelativeLayout.LayoutParams layoutParams3 = new RelativeLayout.LayoutParams(0, 0); + view.setLayoutParams(layoutParams3); + } else if (view instanceof Toolbar) { + Toolbar.LayoutParams layoutParams4 = new Toolbar.LayoutParams(0, 0); + view.setLayoutParams(layoutParams4); + } else if (view instanceof ViewGroup) { + ViewGroup.LayoutParams layoutParams5 = new ViewGroup.LayoutParams(0, 0); + view.setLayoutParams(layoutParams5); + } else { + ViewGroup.LayoutParams params = view.getLayoutParams(); + params.width = 0; + params.height = 0; + view.setLayoutParams(params); + } + } + + /** + * Creates a custom dialog with a styled layout, including a title, message, buttons, and an + * optional EditText. The dialog's appearance adapts to the app's dark mode setting, with + * rounded corners and customizable button actions. Buttons adjust dynamically to their text + * content and are arranged in a single row if they fit within 80% of the screen width, + * with the Neutral button aligned to the left and OK/Cancel buttons centered on the right. + * If buttons do not fit, each is placed on a separate row, all aligned to the right. + * + * @param context Context used to create the dialog. + * @param title Title text of the dialog. + * @param message Message text of the dialog (supports Spanned for HTML), or null if replaced by EditText. + * @param editText EditText to include in the dialog, or null if no EditText is needed. + * @param okButtonText OK button text, or null to use the default "OK" string. + * @param onOkClick Action to perform when the OK button is clicked. + * @param onCancelClick Action to perform when the Cancel button is clicked, or null if no Cancel button is needed. + * @param neutralButtonText Neutral button text, or null if no Neutral button is needed. + * @param onNeutralClick Action to perform when the Neutral button is clicked, or null if no Neutral button is needed. + * @param dismissDialogOnNeutralClick If the dialog should be dismissed when the Neutral button is clicked. + * @return The Dialog and its main LinearLayout container. + */ + @SuppressWarnings("ExtractMethodRecommender") + public static Pair createCustomDialog( + Context context, String title, CharSequence message, @Nullable EditText editText, + String okButtonText, Runnable onOkClick, Runnable onCancelClick, + @Nullable String neutralButtonText, @Nullable Runnable onNeutralClick, + boolean dismissDialogOnNeutralClick + ) { + Logger.printDebug(() -> "Creating custom dialog with title: " + title); + + Dialog dialog = new Dialog(context); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar. + + // Preset size constants. + final int dip4 = dipToPixels(4); + final int dip8 = dipToPixels(8); + final int dip16 = dipToPixels(16); + final int dip24 = dipToPixels(24); + + // Create main layout. + LinearLayout mainLayout = new LinearLayout(context); + mainLayout.setOrientation(LinearLayout.VERTICAL); + mainLayout.setPadding(dip24, dip16, dip24, dip24); + // Set rounded rectangle background. + ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape( + createCornerRadii(28), null, null)); + mainBackground.getPaint().setColor(getDialogBackgroundColor()); // Dialog background. + mainLayout.setBackground(mainBackground); + + // Title. + if (!TextUtils.isEmpty(title)) { + TextView titleView = new TextView(context); + titleView.setText(title); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextSize(18); + titleView.setTextColor(getAppForegroundColor()); + titleView.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + layoutParams.setMargins(0, 0, 0, dip16); + titleView.setLayoutParams(layoutParams); + mainLayout.addView(titleView); + } + + // Create content container (message/EditText) inside a ScrollView only if message or editText is provided. + ScrollView contentScrollView = null; + LinearLayout contentContainer; + if (message != null || editText != null) { + contentScrollView = new ScrollView(context); + contentScrollView.setVerticalScrollBarEnabled(false); // Disable the vertical scrollbar. + contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); + if (editText != null) { + ShapeDrawable scrollViewBackground = new ShapeDrawable(new RoundRectShape( + createCornerRadii(10), null, null)); + scrollViewBackground.getPaint().setColor(getEditTextBackground()); + contentScrollView.setPadding(dip8, dip8, dip8, dip8); + contentScrollView.setBackground(scrollViewBackground); + contentScrollView.setClipToOutline(true); + } + LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1.0f // Weight to take available space. + ); + contentScrollView.setLayoutParams(contentParams); + contentContainer = new LinearLayout(context); + contentContainer.setOrientation(LinearLayout.VERTICAL); + contentScrollView.addView(contentContainer); + + // Message (if not replaced by EditText). + if (editText == null) { + TextView messageView = new TextView(context); + messageView.setText(message); // Supports Spanned (HTML). + messageView.setTextSize(16); + messageView.setTextColor(getAppForegroundColor()); + // Enable HTML link clicking if the message contains links. + if (message instanceof Spanned) { + messageView.setMovementMethod(LinkMovementMethod.getInstance()); + } + LinearLayout.LayoutParams messageParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + messageView.setLayoutParams(messageParams); + contentContainer.addView(messageView); + } + + // EditText (if provided). + if (editText != null) { + // Remove EditText from its current parent, if any. + ViewGroup parent = (ViewGroup) editText.getParent(); + if (parent != null) { + parent.removeView(editText); + } + // Style the EditText to match the dialog theme. + editText.setTextColor(getAppForegroundColor()); + editText.setBackgroundColor(Color.TRANSPARENT); + editText.setPadding(0, 0, 0, 0); + LinearLayout.LayoutParams editTextParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + contentContainer.addView(editText, editTextParams); + } + } + + // Button container. + LinearLayout buttonContainer = new LinearLayout(context); + buttonContainer.setOrientation(LinearLayout.VERTICAL); + buttonContainer.removeAllViews(); + LinearLayout.LayoutParams buttonContainerParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + buttonContainerParams.setMargins(0, dip16, 0, 0); + buttonContainer.setLayoutParams(buttonContainerParams); + + // Lists to track buttons. + List