availableClients = List.of(
+ ANDROID_REEL,
+ ANDROID_VR_1_43_32,
+ VISIONOS,
+ ANDROID_VR_1_61_48
+ );
+
+ app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch.setClientsToUse(
+ availableClients, SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get());
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/patches/theme/ThemePatch.java b/extensions/music/src/main/java/app/revanced/extension/music/patches/theme/ThemePatch.java
new file mode 100644
index 0000000000..3f4e396699
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/patches/theme/ThemePatch.java
@@ -0,0 +1,27 @@
+package app.revanced.extension.music.patches.theme;
+
+import app.revanced.extension.shared.theme.BaseThemePatch;
+
+@SuppressWarnings("unused")
+public class ThemePatch extends BaseThemePatch {
+
+ // Color constants used in relation with litho components.
+ private static final int[] DARK_VALUES = {
+ 0xFF212121, // Comments box background.
+ 0xFF030303, // Button container background in album.
+ 0xFF000000, // Button container background in playlist.
+ };
+
+ /**
+ * Injection point.
+ *
+ * Change the color of Litho components.
+ * If the color of the component matches one of the values, return the background color.
+ *
+ * @param originalValue The original color value.
+ * @return The new or original color value.
+ */
+ public static int getValue(int originalValue) {
+ return processColorValue(originalValue, DARK_VALUES, null);
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/MusicActivityHook.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/MusicActivityHook.java
new file mode 100644
index 0000000000..c3874f655c
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/MusicActivityHook.java
@@ -0,0 +1,148 @@
+package app.revanced.extension.music.settings;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.preference.PreferenceFragment;
+import android.view.View;
+import android.widget.Toolbar;
+
+import app.revanced.extension.music.VersionCheckUtils;
+import app.revanced.extension.music.settings.preference.MusicPreferenceFragment;
+import app.revanced.extension.music.settings.search.MusicSearchViewController;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.ResourceType;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseActivityHook;
+
+/**
+ * Hooks GoogleApiActivity to inject a custom {@link MusicPreferenceFragment} with a toolbar and search.
+ */
+public class MusicActivityHook extends BaseActivityHook {
+
+ @SuppressLint("StaticFieldLeak")
+ public static MusicSearchViewController searchViewController;
+
+ /**
+ * How much time has passed since the first launch of the app. Simple check to prevent
+ * forcing bold icons on first launch where the settings menu is partially broken
+ * due to missing icon resources the client has not yet received.
+ *
+ * @see app.revanced.extension.youtube.settings.YouTubeActivityHook#MINIMUM_TIME_AFTER_FIRST_LAUNCH_BEFORE_ALLOWING_BOLD_ICONS
+ */
+ private static final long MINIMUM_TIME_AFTER_FIRST_LAUNCH_BEFORE_ALLOWING_BOLD_ICONS = 30 * 1000; // 30 seconds.
+
+ static {
+ final boolean useBoldIcons = VersionCheckUtils.IS_8_40_OR_GREATER
+ && !Settings.SETTINGS_DISABLE_BOLD_ICONS.get()
+ && (System.currentTimeMillis() - Settings.FIRST_TIME_APP_LAUNCHED.get())
+ > MINIMUM_TIME_AFTER_FIRST_LAUNCH_BEFORE_ALLOWING_BOLD_ICONS;
+
+ Utils.setAppIsUsingBoldIcons(useBoldIcons);
+ }
+
+ /**
+ * Injection point.
+ */
+ @SuppressWarnings("unused")
+ public static void initialize(Activity parentActivity) {
+ // Must touch the Music settings to ensure the class is loaded and
+ // the values can be found when setting the UI preferences.
+ // Logging anything under non debug ensures this is set.
+ Logger.printInfo(() -> "Permanent repeat enabled: " + Settings.PERMANENT_REPEAT.get());
+
+ // YT Music always uses dark mode.
+ Utils.setIsDarkModeEnabled(true);
+
+ BaseActivityHook.initialize(new MusicActivityHook(), parentActivity);
+ }
+
+ /**
+ * Sets the fixed theme for the activity.
+ */
+ @Override
+ protected void customizeActivityTheme(Activity activity) {
+ // Override the default YouTube Music theme to increase start padding of list items.
+ // Custom style located in resources/music/values/style.xml
+ activity.setTheme(Utils.getResourceIdentifierOrThrow(
+ ResourceType.STYLE, "Theme.ReVanced.YouTubeMusic.Settings"));
+ }
+
+ /**
+ * Returns the fixed background color for the toolbar.
+ */
+ @Override
+ protected int getToolbarBackgroundColor() {
+ return Utils.getResourceColor("ytm_color_black");
+ }
+
+ /**
+ * Returns the navigation icon with a color filter applied.
+ */
+ @Override
+ protected Drawable getNavigationIcon() {
+ Drawable navigationIcon = MusicPreferenceFragment.getBackButtonDrawable();
+ navigationIcon.setColorFilter(Utils.getAppForegroundColor(), PorterDuff.Mode.SRC_IN);
+ return navigationIcon;
+ }
+
+ /**
+ * Returns the click listener that finishes the activity when the navigation icon is clicked.
+ */
+ @Override
+ protected View.OnClickListener getNavigationClickListener(Activity activity) {
+ return view -> {
+ if (searchViewController != null && searchViewController.isSearchActive()) {
+ searchViewController.closeSearch();
+ } else {
+ activity.finish();
+ }
+ };
+ }
+
+ /**
+ * Adds search view components to the toolbar for {@link MusicPreferenceFragment}.
+ *
+ * @param activity The activity hosting the toolbar.
+ * @param toolbar The configured toolbar.
+ * @param fragment The PreferenceFragment associated with the activity.
+ */
+ @Override
+ protected void onPostToolbarSetup(Activity activity, Toolbar toolbar, PreferenceFragment fragment) {
+ if (fragment instanceof MusicPreferenceFragment) {
+ searchViewController = MusicSearchViewController.addSearchViewComponents(
+ activity, toolbar, (MusicPreferenceFragment) fragment);
+ }
+ }
+
+ /**
+ * Creates a new {@link MusicPreferenceFragment} for the activity.
+ */
+ @Override
+ protected PreferenceFragment createPreferenceFragment() {
+ return new MusicPreferenceFragment();
+ }
+
+ /**
+ * Injection point.
+ *
+ * Overrides {@link Activity#finish()} of the injection Activity.
+ *
+ * @return if the original activity finish method should be allowed to run.
+ */
+ @SuppressWarnings("unused")
+ public static boolean handleFinish() {
+ return MusicSearchViewController.handleFinish(searchViewController);
+ }
+
+ /**
+ * Injection point.
+ *
+ * Decides whether to use bold icons.
+ */
+ @SuppressWarnings("unused")
+ public static boolean useBoldIcons(boolean original) {
+ return Utils.appIsUsingBoldIcons();
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/Settings.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/Settings.java
new file mode 100644
index 0000000000..7decd29b8a
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/Settings.java
@@ -0,0 +1,41 @@
+package app.revanced.extension.music.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static app.revanced.extension.shared.settings.Setting.parent;
+
+import app.revanced.extension.shared.settings.YouTubeAndMusicSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.EnumSetting;
+import app.revanced.extension.shared.spoof.ClientType;
+
+public class Settings extends YouTubeAndMusicSettings {
+
+ // Ads
+ public static final BooleanSetting HIDE_VIDEO_ADS = new BooleanSetting("revanced_music_hide_video_ads", TRUE, true);
+ public static final BooleanSetting HIDE_GET_PREMIUM_LABEL = new BooleanSetting("revanced_music_hide_get_premium_label", TRUE, true);
+
+ // General
+ public static final BooleanSetting HIDE_CAST_BUTTON = new BooleanSetting("revanced_music_hide_cast_button", TRUE, true);
+ public static final BooleanSetting HIDE_CATEGORY_BAR = new BooleanSetting("revanced_music_hide_category_bar", FALSE, true);
+ public static final BooleanSetting HIDE_HISTORY_BUTTON = new BooleanSetting("revanced_music_hide_history_button", FALSE, true);
+ public static final BooleanSetting HIDE_SEARCH_BUTTON = new BooleanSetting("revanced_music_hide_search_button", FALSE, true);
+ public static final BooleanSetting HIDE_NOTIFICATION_BUTTON = new BooleanSetting("revanced_music_hide_notification_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_HOME_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_home_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_SAMPLES_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_samples_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_EXPLORE_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_explore_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_LIBRARY_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_library_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_UPGRADE_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_upgrade_button", TRUE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_music_hide_navigation_bar", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR_LABEL = new BooleanSetting("revanced_music_hide_navigation_bar_labels", FALSE, true);
+
+ // Player
+ public static final BooleanSetting CHANGE_MINIPLAYER_COLOR = new BooleanSetting("revanced_music_change_miniplayer_color", FALSE, true);
+ public static final BooleanSetting PERMANENT_REPEAT = new BooleanSetting("revanced_music_play_permanent_repeat", FALSE, true);
+
+ // Miscellaneous
+ public static final EnumSetting SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type",
+ ClientType.ANDROID_REEL, true, parent(SPOOF_VIDEO_STREAMS));
+
+ public static final BooleanSetting FORCE_ORIGINAL_AUDIO = new BooleanSetting("revanced_force_original_audio", TRUE, true);
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/preference/MusicPreferenceFragment.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/preference/MusicPreferenceFragment.java
new file mode 100644
index 0000000000..86e5173420
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/preference/MusicPreferenceFragment.java
@@ -0,0 +1,93 @@
+package app.revanced.extension.music.settings.preference;
+
+import android.app.Dialog;
+import android.preference.PreferenceScreen;
+import android.widget.Toolbar;
+
+import app.revanced.extension.music.settings.MusicActivityHook;
+import app.revanced.extension.shared.GmsCoreSupport;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.preference.ToolbarPreferenceFragment;
+
+/**
+ * Preference fragment for ReVanced settings.
+ */
+@SuppressWarnings("deprecation")
+public class MusicPreferenceFragment extends ToolbarPreferenceFragment {
+ /**
+ * The main PreferenceScreen used to display the current set of preferences.
+ */
+ private PreferenceScreen preferenceScreen;
+
+ /**
+ * Initializes the preference fragment.
+ */
+ @Override
+ protected void initialize() {
+ super.initialize();
+
+ try {
+ preferenceScreen = getPreferenceScreen();
+ Utils.sortPreferenceGroups(preferenceScreen);
+ setPreferenceScreenToolbar(preferenceScreen);
+
+ // Clunky work around until preferences are custom classes that manage themselves.
+ // Custom branding only works with non-root install. But the preferences must be
+ // added during patched because of difficulties detecting during patching if it's
+ // a root install. So instead the non-functional preferences are removed during
+ // runtime if the app is mount (root) installation.
+ if (GmsCoreSupport.isPackageNameOriginal()) {
+ removePreferences(
+ BaseSettings.CUSTOM_BRANDING_ICON.key,
+ BaseSettings.CUSTOM_BRANDING_NAME.key);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Called when the fragment starts.
+ */
+ @Override
+ public void onStart() {
+ super.onStart();
+ try {
+ // Initialize search controller if needed
+ if (MusicActivityHook.searchViewController != null) {
+ // Trigger search data collection after fragment is ready.
+ MusicActivityHook.searchViewController.initializeSearchData();
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onStart failure", ex);
+ }
+ }
+
+ /**
+ * Sets toolbar for all nested preference screens.
+ */
+ @Override
+ protected void customizeToolbar(Toolbar toolbar) {
+ MusicActivityHook.setToolbarLayoutParams(toolbar);
+ }
+
+ /**
+ * Perform actions after toolbar setup.
+ */
+ @Override
+ protected void onPostToolbarSetup(Toolbar toolbar, Dialog preferenceScreenDialog) {
+ if (MusicActivityHook.searchViewController != null
+ && MusicActivityHook.searchViewController.isSearchActive()) {
+ toolbar.post(() -> MusicActivityHook.searchViewController.closeSearch());
+ }
+ }
+
+ /**
+ * Returns the preference screen for external access by SearchViewController.
+ */
+ public PreferenceScreen getPreferenceScreenForSearch() {
+ return preferenceScreen;
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchResultsAdapter.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchResultsAdapter.java
new file mode 100644
index 0000000000..65ccd4ea1a
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchResultsAdapter.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.music.settings.search;
+
+import android.content.Context;
+import android.preference.PreferenceScreen;
+
+import app.revanced.extension.shared.settings.search.BaseSearchResultsAdapter;
+import app.revanced.extension.shared.settings.search.BaseSearchViewController;
+import app.revanced.extension.shared.settings.search.BaseSearchResultItem;
+
+import java.util.List;
+
+/**
+ * Music-specific search results adapter.
+ */
+@SuppressWarnings("deprecation")
+public class MusicSearchResultsAdapter extends BaseSearchResultsAdapter {
+
+ public MusicSearchResultsAdapter(Context context, List items,
+ BaseSearchViewController.BasePreferenceFragment fragment,
+ BaseSearchViewController searchViewController) {
+ super(context, items, fragment, searchViewController);
+ }
+
+ @Override
+ protected PreferenceScreen getMainPreferenceScreen() {
+ return fragment.getPreferenceScreenForSearch();
+ }
+}
diff --git a/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchViewController.java b/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchViewController.java
new file mode 100644
index 0000000000..6681a2f027
--- /dev/null
+++ b/extensions/music/src/main/java/app/revanced/extension/music/settings/search/MusicSearchViewController.java
@@ -0,0 +1,71 @@
+package app.revanced.extension.music.settings.search;
+
+import android.app.Activity;
+import android.preference.Preference;
+import android.preference.PreferenceScreen;
+import android.view.View;
+import android.widget.Toolbar;
+
+import app.revanced.extension.music.settings.preference.MusicPreferenceFragment;
+import app.revanced.extension.shared.settings.search.*;
+
+/**
+ * Music-specific search view controller implementation.
+ */
+@SuppressWarnings("deprecation")
+public class MusicSearchViewController extends BaseSearchViewController {
+
+ public static MusicSearchViewController addSearchViewComponents(Activity activity, Toolbar toolbar,
+ MusicPreferenceFragment fragment) {
+ return new MusicSearchViewController(activity, toolbar, fragment);
+ }
+
+ private MusicSearchViewController(Activity activity, Toolbar toolbar, MusicPreferenceFragment fragment) {
+ super(activity, toolbar, new PreferenceFragmentAdapter(fragment));
+ }
+
+ @Override
+ protected BaseSearchResultsAdapter createSearchResultsAdapter() {
+ return new MusicSearchResultsAdapter(activity, filteredSearchItems, fragment, this);
+ }
+
+ @Override
+ protected boolean isSpecialPreferenceGroup(Preference preference) {
+ // Music doesn't have SponsorBlock, so no special groups.
+ return false;
+ }
+
+ @Override
+ protected void setupSpecialPreferenceListeners(BaseSearchResultItem item) {
+ // Music doesn't have special preferences.
+ // This method can be empty or handle music-specific preferences if any.
+ }
+
+ // Static method for handling Activity finish
+ public static boolean handleFinish(MusicSearchViewController searchViewController) {
+ if (searchViewController != null && searchViewController.isSearchActive()) {
+ searchViewController.closeSearch();
+ return true;
+ }
+ return false;
+ }
+
+ // Adapter to wrap MusicPreferenceFragment to BasePreferenceFragment interface.
+ private record PreferenceFragmentAdapter(MusicPreferenceFragment fragment) implements BasePreferenceFragment {
+
+ @Override
+ public PreferenceScreen getPreferenceScreenForSearch() {
+ return fragment.getPreferenceScreenForSearch();
+ }
+
+ @Override
+ public View getView() {
+ return fragment.getView();
+ }
+
+ @Override
+ public Activity getActivity() {
+ return fragment.getActivity();
+ }
+ }
+}
diff --git a/extensions/nothingx/build.gradle.kts b/extensions/nothingx/build.gradle.kts
new file mode 100644
index 0000000000..ed2b78c5f6
--- /dev/null
+++ b/extensions/nothingx/build.gradle.kts
@@ -0,0 +1,10 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:nothingx:stub"))
+}
+
+android {
+ defaultConfig {
+ minSdk = 23
+ }
+}
\ No newline at end of file
diff --git a/extensions/nothingx/src/main/AndroidManifest.xml b/extensions/nothingx/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..15e7c2ae67
--- /dev/null
+++ b/extensions/nothingx/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/extensions/nothingx/src/main/java/app/revanced/extension/nothingx/patches/ShowK1TokensPatch.java b/extensions/nothingx/src/main/java/app/revanced/extension/nothingx/patches/ShowK1TokensPatch.java
new file mode 100644
index 0000000000..c301ae2fb3
--- /dev/null
+++ b/extensions/nothingx/src/main/java/app/revanced/extension/nothingx/patches/ShowK1TokensPatch.java
@@ -0,0 +1,590 @@
+package app.revanced.extension.nothingx.patches;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Application;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Patches to expose the K1 token for Nothing X app to enable pairing with GadgetBridge.
+ */
+@SuppressWarnings("unused")
+public class ShowK1TokensPatch {
+
+ private static final String TAG = "ReVanced";
+ private static final String PACKAGE_NAME = "com.nothing.smartcenter";
+ private static final String EMPTY_MD5 = "d41d8cd98f00b204e9800998ecf8427e";
+ private static final String PREFS_NAME = "revanced_nothingx_prefs";
+ private static final String KEY_DONT_SHOW_DIALOG = "dont_show_k1_dialog";
+
+ // Colors
+ private static final int COLOR_BG = 0xFF1E1E1E;
+ private static final int COLOR_CARD = 0xFF2D2D2D;
+ private static final int COLOR_TEXT_PRIMARY = 0xFFFFFFFF;
+ private static final int COLOR_TEXT_SECONDARY = 0xFFB0B0B0;
+ private static final int COLOR_ACCENT = 0xFFFF9500;
+ private static final int COLOR_TOKEN_BG = 0xFF3A3A3A;
+ private static final int COLOR_BUTTON_POSITIVE = 0xFFFF9500;
+ private static final int COLOR_BUTTON_NEGATIVE = 0xFFFF6B6B;
+
+ // Match standalone K1: k1:, K1:, k1>, etc.
+ private static final Pattern K1_STANDALONE_PATTERN = Pattern.compile("(?i)(?:k1\\s*[:>]\\s*)([0-9a-f]{32})");
+ // Match combined r3+k1: format (64 chars = r3(32) + k1(32))
+ private static final Pattern K1_COMBINED_PATTERN = Pattern.compile("(?i)r3\\+k1\\s*:\\s*([0-9a-f]{64})");
+
+ private static volatile boolean k1Logged = false;
+ private static volatile boolean lifecycleCallbacksRegistered = false;
+ private static Context appContext;
+
+ /**
+ * Get K1 tokens from database and log files.
+ * Call this after the app initializes.
+ *
+ * @param context Application context
+ */
+ public static void showK1Tokens(Context context) {
+ if (k1Logged) {
+ return;
+ }
+
+ appContext = context.getApplicationContext();
+
+ Set allTokens = new LinkedHashSet<>();
+
+ // First try to get from database.
+ String dbToken = getK1TokensFromDatabase();
+ if (dbToken != null) {
+ allTokens.add(dbToken);
+ }
+
+ // Then get from log files.
+ Set logTokens = getK1TokensFromLogFiles();
+ allTokens.addAll(logTokens);
+
+ if (allTokens.isEmpty()) {
+ return;
+ }
+
+ // Log all found tokens.
+ int index = 1;
+ for (String token : allTokens) {
+ Log.i(TAG, "#" + index++ + ": " + token.toUpperCase());
+ }
+
+ // Register lifecycle callbacks to show dialog when an Activity is ready.
+ registerLifecycleCallbacks(allTokens);
+
+ k1Logged = true;
+ }
+
+ /**
+ * Register ActivityLifecycleCallbacks to show dialog when first Activity resumes.
+ *
+ * @param tokens Set of K1 tokens to display
+ */
+ private static void registerLifecycleCallbacks(Set tokens) {
+ if (lifecycleCallbacksRegistered || !(appContext instanceof Application)) {
+ return;
+ }
+
+ Application application = (Application) appContext;
+ application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
+ @Override
+ public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
+ }
+
+ @Override
+ public void onActivityStarted(Activity activity) {
+ }
+
+ @Override
+ public void onActivityResumed(Activity activity) {
+ // Check if user chose not to show dialog.
+ SharedPreferences prefs = activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ if (prefs.getBoolean(KEY_DONT_SHOW_DIALOG, false)) {
+ application.unregisterActivityLifecycleCallbacks(this);
+ lifecycleCallbacksRegistered = false;
+ return;
+ }
+
+ // Show dialog on first Activity resume.
+ if (tokens != null && !tokens.isEmpty()) {
+ activity.runOnUiThread(() -> showK1TokensDialog(activity, tokens));
+ // Unregister after showing
+ application.unregisterActivityLifecycleCallbacks(this);
+ lifecycleCallbacksRegistered = false;
+ }
+ }
+
+ @Override
+ public void onActivityPaused(Activity activity) {
+ }
+
+ @Override
+ public void onActivityStopped(Activity activity) {
+ }
+
+ @Override
+ public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
+ }
+
+ @Override
+ public void onActivityDestroyed(Activity activity) {
+ }
+ });
+
+ lifecycleCallbacksRegistered = true;
+ }
+
+ /**
+ * Show dialog with K1 tokens.
+ *
+ * @param activity Activity context
+ * @param tokens Set of K1 tokens
+ */
+ private static void showK1TokensDialog(Activity activity, Set tokens) {
+ try {
+ // Create main container.
+ LinearLayout mainLayout = new LinearLayout(activity);
+ mainLayout.setOrientation(LinearLayout.VERTICAL);
+ mainLayout.setBackgroundColor(COLOR_BG);
+ mainLayout.setPadding(dpToPx(activity, 24), dpToPx(activity, 16),
+ dpToPx(activity, 24), dpToPx(activity, 16));
+
+ // Title.
+ TextView titleView = new TextView(activity);
+ titleView.setText("K1 Token(s) Found");
+ titleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
+ titleView.setTypeface(Typeface.DEFAULT_BOLD);
+ titleView.setTextColor(COLOR_TEXT_PRIMARY);
+ titleView.setGravity(Gravity.CENTER);
+ mainLayout.addView(titleView, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ));
+
+ // Subtitle.
+ TextView subtitleView = new TextView(activity);
+ subtitleView.setText(tokens.size() == 1 ? "1 token found • Tap to copy" : tokens.size() + " tokens found • Tap to copy");
+ subtitleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
+ subtitleView.setTextColor(COLOR_TEXT_SECONDARY);
+ subtitleView.setGravity(Gravity.CENTER);
+ LinearLayout.LayoutParams subtitleParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ subtitleParams.topMargin = dpToPx(activity, 4);
+ subtitleParams.bottomMargin = dpToPx(activity, 16);
+ mainLayout.addView(subtitleView, subtitleParams);
+
+ // Scrollable content.
+ ScrollView scrollView = new ScrollView(activity);
+ scrollView.setVerticalScrollBarEnabled(false);
+ LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ 0,
+ 1.0f
+ );
+ scrollParams.topMargin = dpToPx(activity, 8);
+ scrollParams.bottomMargin = dpToPx(activity, 16);
+ mainLayout.addView(scrollView, scrollParams);
+
+ LinearLayout tokensContainer = new LinearLayout(activity);
+ tokensContainer.setOrientation(LinearLayout.VERTICAL);
+ scrollView.addView(tokensContainer);
+
+ // Add each token as a card.
+ boolean singleToken = tokens.size() == 1;
+ int index = 1;
+ for (String token : tokens) {
+ LinearLayout tokenCard = createTokenCard(activity, token, index++, singleToken);
+ LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ cardParams.bottomMargin = dpToPx(activity, 12);
+ tokensContainer.addView(tokenCard, cardParams);
+ }
+
+ // Info text.
+ TextView infoView = new TextView(activity);
+ infoView.setText(tokens.size() == 1 ? "Tap the token to copy it" : "Tap any token to copy it");
+ infoView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
+ infoView.setTextColor(COLOR_TEXT_SECONDARY);
+ infoView.setGravity(Gravity.CENTER);
+ infoView.setAlpha(0.7f);
+ LinearLayout.LayoutParams infoParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ infoParams.topMargin = dpToPx(activity, 8);
+ mainLayout.addView(infoView, infoParams);
+
+ // Button row.
+ LinearLayout buttonRow = new LinearLayout(activity);
+ buttonRow.setOrientation(LinearLayout.HORIZONTAL);
+ buttonRow.setGravity(Gravity.END);
+ LinearLayout.LayoutParams buttonRowParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ buttonRowParams.topMargin = dpToPx(activity, 16);
+ mainLayout.addView(buttonRow, buttonRowParams);
+
+ // "Don't show again" button.
+ Button dontShowButton = new Button(activity);
+ dontShowButton.setText("Don't show again");
+ dontShowButton.setTextColor(Color.WHITE);
+ dontShowButton.setBackgroundColor(Color.TRANSPARENT);
+ dontShowButton.setAllCaps(false);
+ dontShowButton.setTypeface(Typeface.DEFAULT);
+ dontShowButton.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
+ dontShowButton.setPadding(dpToPx(activity, 16), dpToPx(activity, 8),
+ dpToPx(activity, 16), dpToPx(activity, 8));
+ LinearLayout.LayoutParams dontShowParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ dontShowParams.rightMargin = dpToPx(activity, 8);
+ buttonRow.addView(dontShowButton, dontShowParams);
+
+ // "OK" button.
+ Button okButton = new Button(activity);
+ okButton.setText("OK");
+ okButton.setTextColor(Color.BLACK);
+ okButton.setBackgroundColor(COLOR_BUTTON_POSITIVE);
+ okButton.setAllCaps(false);
+ okButton.setTypeface(Typeface.DEFAULT_BOLD);
+ okButton.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
+ okButton.setPadding(dpToPx(activity, 24), dpToPx(activity, 12),
+ dpToPx(activity, 24), dpToPx(activity, 12));
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ okButton.setElevation(dpToPx(activity, 4));
+ }
+ buttonRow.addView(okButton, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ));
+
+ // Build dialog.
+ AlertDialog.Builder builder = new AlertDialog.Builder(activity);
+ builder.setView(mainLayout);
+
+ final AlertDialog dialog = builder.create();
+
+ // Style the dialog with dark background.
+ if (dialog.getWindow() != null) {
+ dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
+ }
+
+ dialog.show();
+
+ // Set button click listeners after dialog is created.
+ dontShowButton.setOnClickListener(v -> {
+ SharedPreferences prefs = activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean(KEY_DONT_SHOW_DIALOG, true).apply();
+ Toast.makeText(activity, "Dialog disabled. Clear app data to re-enable.",
+ Toast.LENGTH_SHORT).show();
+ dialog.dismiss();
+ });
+
+ okButton.setOnClickListener(v -> {
+ dialog.dismiss();
+ });
+
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to show K1 dialog", e);
+ }
+ }
+
+ /**
+ * Create a card view for a single token.
+ */
+ private static LinearLayout createTokenCard(Activity activity, String token, int index, boolean singleToken) {
+ LinearLayout card = new LinearLayout(activity);
+ card.setOrientation(LinearLayout.VERTICAL);
+ card.setBackgroundColor(COLOR_TOKEN_BG);
+ card.setPadding(dpToPx(activity, 16), dpToPx(activity, 12),
+ dpToPx(activity, 16), dpToPx(activity, 12));
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ card.setElevation(dpToPx(activity, 2));
+ }
+ card.setClickable(true);
+ card.setFocusable(true);
+
+ // Token label (only show if multiple tokens).
+ if (!singleToken) {
+ TextView labelView = new TextView(activity);
+ labelView.setText("Token #" + index);
+ labelView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
+ labelView.setTextColor(COLOR_ACCENT);
+ labelView.setTypeface(Typeface.DEFAULT_BOLD);
+ card.addView(labelView);
+ }
+
+ // Token value.
+ TextView tokenView = new TextView(activity);
+ tokenView.setText(token.toUpperCase());
+ tokenView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
+ tokenView.setTextColor(COLOR_TEXT_PRIMARY);
+ tokenView.setTypeface(Typeface.MONOSPACE);
+ tokenView.setLetterSpacing(0.05f);
+ LinearLayout.LayoutParams tokenParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ if (!singleToken) {
+ tokenParams.topMargin = dpToPx(activity, 8);
+ }
+ card.addView(tokenView, tokenParams);
+
+ // Click to copy.
+ card.setOnClickListener(v -> {
+ ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboard != null) {
+ clipboard.setText(token.toUpperCase());
+ Toast.makeText(activity, "Token copied!", Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ return card;
+ }
+
+ /**
+ * Convert dp to pixels.
+ */
+ private static int dpToPx(Context context, float dp) {
+ return (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp,
+ context.getResources().getDisplayMetrics()
+ );
+ }
+
+ /**
+ * Get K1 tokens from log files.
+ * Prioritizes pairing K1 tokens over reconnect tokens.
+ */
+ private static Set getK1TokensFromLogFiles() {
+ Set pairingTokens = new LinkedHashSet<>();
+ Set reconnectTokens = new LinkedHashSet<>();
+ try {
+ File logDir = new File("/data/data/" + PACKAGE_NAME + "/files/log");
+ if (!logDir.exists() || !logDir.isDirectory()) {
+ return pairingTokens;
+ }
+
+ File[] logFiles = logDir.listFiles((dir, name) ->
+ name.endsWith(".log") || name.endsWith(".log.") || name.matches(".*\\.log\\.\\d+"));
+
+ if (logFiles == null || logFiles.length == 0) {
+ return pairingTokens;
+ }
+
+ for (File logFile : logFiles) {
+ try (BufferedReader reader = new BufferedReader(new FileReader(logFile))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ // Determine if this is a pairing or reconnect context.
+ boolean isPairingContext = line.toLowerCase().contains("watchbind");
+ boolean isReconnectContext = line.toLowerCase().contains("watchreconnect");
+
+ String k1Token = null;
+
+ // First check for combined r3+k1 format (priority).
+ Matcher combinedMatcher = K1_COMBINED_PATTERN.matcher(line);
+ if (combinedMatcher.find()) {
+ String combined = combinedMatcher.group(1);
+ if (combined.length() == 64) {
+ // Second half is the actual K1
+ k1Token = combined.substring(32).toLowerCase();
+ }
+ }
+
+ // Then check for standalone K1 format (only if not found in combined).
+ if (k1Token == null) {
+ Matcher standaloneMatcher = K1_STANDALONE_PATTERN.matcher(line);
+ if (standaloneMatcher.find()) {
+ String token = standaloneMatcher.group(1);
+ if (token != null && token.length() == 32) {
+ k1Token = token.toLowerCase();
+ }
+ }
+ }
+
+ // Add to appropriate set.
+ if (k1Token != null) {
+ if (isPairingContext && !isReconnectContext) {
+ pairingTokens.add(k1Token);
+ } else {
+ reconnectTokens.add(k1Token);
+ }
+ }
+ }
+ } catch (Exception e) {
+ // Skip unreadable files.
+ }
+ }
+ } catch (Exception ex) {
+ // Fail silently.
+ }
+
+ // Return pairing tokens first, add reconnect tokens if no pairing tokens found.
+ if (!pairingTokens.isEmpty()) {
+ Log.i(TAG, "Found " + pairingTokens.size() + " pairing K1 token(s)");
+ return pairingTokens;
+ }
+
+ if (!reconnectTokens.isEmpty()) {
+ Log.i(TAG, "Found " + reconnectTokens.size() + " reconnect K1 token(s) (may not work for initial pairing)");
+ }
+ return reconnectTokens;
+ }
+
+ /**
+ * Try to get K1 tokens from the database.
+ */
+ private static String getK1TokensFromDatabase() {
+ try {
+ File dbDir = new File("/data/data/" + PACKAGE_NAME + "/databases");
+ if (!dbDir.exists() || !dbDir.isDirectory()) {
+ return null;
+ }
+
+ File[] dbFiles = dbDir.listFiles((dir, name) ->
+ name.endsWith(".db") && !name.startsWith("google_app_measurement") && !name.contains("firebase"));
+
+ if (dbFiles == null || dbFiles.length == 0) {
+ return null;
+ }
+
+ for (File dbFile : dbFiles) {
+ String token = getK1TokensFromDatabase(dbFile);
+ if (token != null) {
+ return token;
+ }
+ }
+
+ return null;
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+
+ /**
+ * Extract K1 tokens from a database file.
+ */
+ private static String getK1TokensFromDatabase(File dbFile) {
+ SQLiteDatabase db = null;
+ try {
+ db = SQLiteDatabase.openDatabase(dbFile.getPath(), null, SQLiteDatabase.OPEN_READONLY);
+
+ // Get all tables.
+ Cursor cursor = db.rawQuery(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
+ null
+ );
+
+ List tables = new ArrayList<>();
+ while (cursor.moveToNext()) {
+ tables.add(cursor.getString(0));
+ }
+ cursor.close();
+
+ // Scan all columns for 32-char hex strings.
+ for (String table : tables) {
+ Cursor schemaCursor = null;
+ try {
+ schemaCursor = db.rawQuery("PRAGMA table_info(" + table + ")", null);
+ List columns = new ArrayList<>();
+ while (schemaCursor.moveToNext()) {
+ columns.add(schemaCursor.getString(1));
+ }
+ schemaCursor.close();
+
+ for (String column : columns) {
+ Cursor dataCursor = null;
+ try {
+ dataCursor = db.query(table, new String[]{column}, null, null, null, null, null);
+ while (dataCursor.moveToNext()) {
+ String value = dataCursor.getString(0);
+ if (value != null && value.length() == 32 && value.matches("[0-9a-fA-F]{32}")) {
+ // Skip obviously fake tokens (MD5 of empty string).
+ if (!value.equalsIgnoreCase(EMPTY_MD5)) {
+ dataCursor.close();
+ db.close();
+ return value.toLowerCase();
+ }
+ }
+ }
+ } catch (Exception e) {
+ // Skip non-string columns.
+ } finally {
+ if (dataCursor != null) {
+ dataCursor.close();
+ }
+ }
+ }
+ } catch (Exception e) {
+ // Continue to next table.
+ } finally {
+ if (schemaCursor != null && !schemaCursor.isClosed()) {
+ schemaCursor.close();
+ }
+ }
+ }
+
+ return null;
+ } catch (Exception ex) {
+ return null;
+ } finally {
+ if (db != null && db.isOpen()) {
+ db.close();
+ }
+ }
+ }
+
+ /**
+ * Reset the logged flag (useful for testing or re-pairing).
+ */
+ public static void resetK1Logged() {
+ k1Logged = false;
+ lifecycleCallbacksRegistered = false;
+ }
+
+ /**
+ * Reset the "don't show again" preference.
+ */
+ public static void resetDontShowPreference() {
+ if (appContext != null) {
+ SharedPreferences prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean(KEY_DONT_SHOW_DIALOG, false).apply();
+ }
+ }
+}
diff --git a/extensions/nothingx/stub/build.gradle.kts b/extensions/nothingx/stub/build.gradle.kts
new file mode 100644
index 0000000000..fcadc678c4
--- /dev/null
+++ b/extensions/nothingx/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
\ No newline at end of file
diff --git a/extensions/nothingx/stub/src/main/AndroidManifest.xml b/extensions/nothingx/stub/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..15e7c2ae67
--- /dev/null
+++ b/extensions/nothingx/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/extensions/nunl/build.gradle.kts b/extensions/nunl/build.gradle.kts
new file mode 100644
index 0000000000..ab48531bba
--- /dev/null
+++ b/extensions/nunl/build.gradle.kts
@@ -0,0 +1,10 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:nunl:stub"))
+}
+
+android {
+ defaultConfig {
+ minSdk = 26
+ }
+}
diff --git a/extensions/nunl/src/main/AndroidManifest.xml b/extensions/nunl/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/nunl/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/nunl/src/main/java/app/revanced/extension/nunl/ads/HideAdsPatch.java b/extensions/nunl/src/main/java/app/revanced/extension/nunl/ads/HideAdsPatch.java
new file mode 100644
index 0000000000..2e4ab5b069
--- /dev/null
+++ b/extensions/nunl/src/main/java/app/revanced/extension/nunl/ads/HideAdsPatch.java
@@ -0,0 +1,114 @@
+package app.revanced.extension.nunl.ads;
+
+import nl.nu.performance.api.client.interfaces.Block;
+import nl.nu.performance.api.client.unions.SmallArticleLinkFlavor;
+import nl.nu.performance.api.client.objects.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import app.revanced.extension.shared.Logger;
+
+@SuppressWarnings("unused")
+public class HideAdsPatch {
+ private static final String[] blockedHeaderBlocks = {
+ "Aanbiedingen (Adverteerders)",
+ "Aangeboden door NUshop"
+ };
+
+ // "Rubrieken" menu links to ads.
+ private static final String[] blockedLinkBlocks = {
+ "Van onze adverteerders"
+ };
+
+ public static void filterAds(List blocks) {
+ try {
+ ArrayList cleanedList = new ArrayList<>();
+
+ boolean skipFullHeader = false;
+ boolean skipUntilDivider = false;
+
+ int index = 0;
+ while (index < blocks.size()) {
+ Block currentBlock = blocks.get(index);
+
+ // Because of pagination, we might not see the Divider in front of it.
+ // Just remove it as is and leave potential extra spacing visible on the screen.
+ if (currentBlock instanceof DpgBannerBlock) {
+ index++;
+ continue;
+ }
+
+ if (index + 1 < blocks.size()) {
+ // Filter Divider -> DpgMediaBanner -> Divider.
+ if (currentBlock instanceof DividerBlock
+ && blocks.get(index + 1) instanceof DpgBannerBlock) {
+ index += 2;
+ continue;
+ }
+
+ // Filter Divider -> LinkBlock (... -> LinkBlock -> LinkBlock-> LinkBlock -> Divider).
+ if (currentBlock instanceof DividerBlock
+ && blocks.get(index + 1) instanceof LinkBlock linkBlock) {
+ Link link = linkBlock.getLink();
+ if (link != null && link.getTitle() != null) {
+ for (String blockedLinkBlock : blockedLinkBlocks) {
+ if (blockedLinkBlock.equals(link.getTitle().getText())) {
+ skipUntilDivider = true;
+ break;
+ }
+ }
+ if (skipUntilDivider) {
+ index++;
+ continue;
+ }
+ }
+ }
+ }
+
+ // Skip LinkBlocks with a "flavor" claiming to be "isPartner" (sponsored inline ads).
+ if (currentBlock instanceof LinkBlock linkBlock
+ && linkBlock.getLink() != null
+ && linkBlock.getLink().getLinkFlavor() instanceof SmallArticleLinkFlavor smallArticleLinkFlavor
+ && smallArticleLinkFlavor.isPartner() != null
+ && smallArticleLinkFlavor.isPartner()) {
+ index++;
+ continue;
+ }
+
+ if (currentBlock instanceof DividerBlock) {
+ skipUntilDivider = false;
+ }
+
+ // Filter HeaderBlock with known ads until next HeaderBlock.
+ if (currentBlock instanceof HeaderBlock headerBlock) {
+ StyledText headerText = headerBlock.getTitle();
+ if (headerText != null) {
+ skipFullHeader = false;
+ for (String blockedHeaderBlock : blockedHeaderBlocks) {
+ if (blockedHeaderBlock.equals(headerText.getText())) {
+ skipFullHeader = true;
+ break;
+ }
+ }
+ if (skipFullHeader) {
+ index++;
+ continue;
+ }
+ }
+ }
+
+ if (!skipFullHeader && !skipUntilDivider) {
+ cleanedList.add(currentBlock);
+ }
+ index++;
+ }
+
+ // Replace list in-place to not deal with moving the result to the correct register in smali.
+ blocks.clear();
+ blocks.addAll(cleanedList);
+ } catch (Exception ex) {
+ Logger.printException(() -> "filterAds failure", ex);
+ }
+ }
+}
diff --git a/extensions/nunl/stub/build.gradle.kts b/extensions/nunl/stub/build.gradle.kts
new file mode 100644
index 0000000000..7905271b26
--- /dev/null
+++ b/extensions/nunl/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 26
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
diff --git a/extensions/nunl/stub/src/main/AndroidManifest.xml b/extensions/nunl/stub/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/nunl/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/interfaces/Block.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/interfaces/Block.java
new file mode 100644
index 0000000000..3514f360cb
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/interfaces/Block.java
@@ -0,0 +1,5 @@
+package nl.nu.performance.api.client.interfaces;
+
+public class Block {
+
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DividerBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DividerBlock.java
new file mode 100644
index 0000000000..0351aec049
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DividerBlock.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.interfaces.Block;
+
+public class DividerBlock extends Block {
+
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DpgBannerBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DpgBannerBlock.java
new file mode 100644
index 0000000000..ac300b0539
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/DpgBannerBlock.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.interfaces.Block;
+
+public class DpgBannerBlock extends Block {
+
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/HeaderBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/HeaderBlock.java
new file mode 100644
index 0000000000..7b1f7ad192
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/HeaderBlock.java
@@ -0,0 +1,9 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.interfaces.Block;
+
+public class HeaderBlock extends Block {
+ public final StyledText getTitle() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/Link.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/Link.java
new file mode 100644
index 0000000000..771d11dad1
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/Link.java
@@ -0,0 +1,13 @@
+package nl.nu.performance.api.client.objects;
+
+import nl.nu.performance.api.client.unions.LinkFlavor;
+
+public class Link {
+ public final StyledText getTitle() {
+ throw new UnsupportedOperationException("Stub");
+ }
+
+ public final LinkFlavor getLinkFlavor() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/LinkBlock.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/LinkBlock.java
new file mode 100644
index 0000000000..dea1950573
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/LinkBlock.java
@@ -0,0 +1,10 @@
+package nl.nu.performance.api.client.objects;
+
+import android.os.Parcelable;
+import nl.nu.performance.api.client.interfaces.Block;
+
+public abstract class LinkBlock extends Block implements Parcelable {
+ public final Link getLink() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/StyledText.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/StyledText.java
new file mode 100644
index 0000000000..719403eb4e
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/objects/StyledText.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.objects;
+
+public class StyledText {
+ public final String getText() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/LinkFlavor.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/LinkFlavor.java
new file mode 100644
index 0000000000..08413d3fd9
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/LinkFlavor.java
@@ -0,0 +1,4 @@
+package nl.nu.performance.api.client.unions;
+
+public interface LinkFlavor {
+}
diff --git a/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/SmallArticleLinkFlavor.java b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/SmallArticleLinkFlavor.java
new file mode 100644
index 0000000000..4dcbf23cb9
--- /dev/null
+++ b/extensions/nunl/stub/src/main/java/nl/nu/performance/api/client/unions/SmallArticleLinkFlavor.java
@@ -0,0 +1,7 @@
+package nl.nu.performance.api.client.unions;
+
+public class SmallArticleLinkFlavor implements LinkFlavor {
+ public final Boolean isPartner() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
diff --git a/extensions/primevideo/build.gradle.kts b/extensions/primevideo/build.gradle.kts
new file mode 100644
index 0000000000..17a3c31a21
--- /dev/null
+++ b/extensions/primevideo/build.gradle.kts
@@ -0,0 +1,10 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:primevideo:stub"))
+}
+
+android {
+ defaultConfig {
+ minSdk = 21
+ }
+}
diff --git a/extensions/primevideo/src/main/AndroidManifest.xml b/extensions/primevideo/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/primevideo/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/ads/SkipAdsPatch.java b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/ads/SkipAdsPatch.java
new file mode 100644
index 0000000000..d0a97810a2
--- /dev/null
+++ b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/ads/SkipAdsPatch.java
@@ -0,0 +1,36 @@
+package app.revanced.extension.primevideo.ads;
+
+import com.amazon.avod.fsm.SimpleTrigger;
+import com.amazon.avod.media.ads.AdBreak;
+import com.amazon.avod.media.ads.internal.state.AdBreakTrigger;
+import com.amazon.avod.media.ads.internal.state.AdEnabledPlayerTriggerType;
+import com.amazon.avod.media.playback.VideoPlayer;
+import com.amazon.avod.media.ads.internal.state.ServerInsertedAdBreakState;
+
+import app.revanced.extension.shared.Logger;
+
+@SuppressWarnings("unused")
+public final class SkipAdsPatch {
+ public static void enterServerInsertedAdBreakState(ServerInsertedAdBreakState state, AdBreakTrigger trigger, VideoPlayer player) {
+ try {
+ AdBreak adBreak = trigger.getBreak();
+
+ // There are two scenarios when entering the original method:
+ // 1. Player naturally entered an ad break while watching a video.
+ // 2. User is skipped/scrubbed to a position on the timeline. If seek position is past an ad break,
+ // user is forced to watch an ad before continuing.
+ //
+ // Scenario 2 is indicated by trigger.getSeekStartPosition() != null, so skip directly to the scrubbing
+ // target. Otherwise, just calculate when the ad break should end and skip to there.
+ if (trigger.getSeekStartPosition() != null)
+ player.seekTo(trigger.getSeekTarget().getTotalMilliseconds());
+ else
+ player.seekTo(player.getCurrentPosition() + adBreak.getDurationExcludingAux().getTotalMilliseconds());
+
+ // Send "end of ads" trigger to state machine so everything doesn't get whacky.
+ state.doTrigger(new SimpleTrigger(AdEnabledPlayerTriggerType.NO_MORE_ADS_SKIP_TRANSITION));
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed skipping ads", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch.java b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch.java
new file mode 100644
index 0000000000..b11ec0875d
--- /dev/null
+++ b/extensions/primevideo/src/main/java/app/revanced/extension/primevideo/videoplayer/PlaybackSpeedPatch.java
@@ -0,0 +1,207 @@
+package app.revanced.extension.primevideo.videoplayer;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.graphics.RectF;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.ColorFilter;
+import android.graphics.PixelFormat;
+import java.util.Arrays;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.ui.Dim;
+
+import com.amazon.video.sdk.player.Player;
+
+public class PlaybackSpeedPatch {
+ private static Player player;
+ private static final float[] SPEED_VALUES = {0.5f, 0.7f, 0.8f, 0.9f, 0.95f, 1.0f, 1.05f, 1.1f, 1.2f, 1.3f, 1.5f, 2.0f};
+ private static final String SPEED_BUTTON_TAG = "speed_overlay";
+
+ public static void setPlayer(Player playerInstance) {
+ player = playerInstance;
+ if (player != null) {
+ // Reset playback rate when switching between episodes to ensure correct display.
+ player.setPlaybackRate(1.0f);
+ }
+ }
+
+ public static void initializeSpeedOverlay(View userControlsView) {
+ try {
+ LinearLayout buttonContainer = Utils.getChildViewByResourceName(userControlsView, "ButtonContainerPlayerTop");
+
+ // If the speed overlay exists we should return early.
+ if (Utils.getChildView(buttonContainer, false, child ->
+ child instanceof ImageView && SPEED_BUTTON_TAG.equals(child.getTag())) != null) {
+ return;
+ }
+
+ ImageView speedButton = createSpeedButton(userControlsView.getContext());
+ speedButton.setOnClickListener(v -> changePlaybackSpeed(speedButton));
+ buttonContainer.addView(speedButton, 0);
+
+ } catch (IllegalArgumentException e) {
+ Logger.printException(() -> "initializeSpeedOverlay, no button container found", e);
+ } catch (Exception e) {
+ Logger.printException(() -> "initializeSpeedOverlay failure", e);
+ }
+ }
+
+ private static ImageView createSpeedButton(Context context) {
+ ImageView speedButton = new ImageView(context);
+ speedButton.setContentDescription("Playback Speed");
+ speedButton.setTag(SPEED_BUTTON_TAG);
+ speedButton.setClickable(true);
+ speedButton.setFocusable(true);
+ speedButton.setScaleType(ImageView.ScaleType.CENTER);
+
+ SpeedIconDrawable speedIcon = new SpeedIconDrawable();
+ speedButton.setImageDrawable(speedIcon);
+
+ speedButton.setMinimumWidth(Dim.dp48);
+ speedButton.setMinimumHeight(Dim.dp48);
+
+ return speedButton;
+ }
+
+ private static String[] getSpeedOptions() {
+ String[] options = new String[SPEED_VALUES.length];
+ for (int i = 0; i < SPEED_VALUES.length; i++) {
+ options[i] = SPEED_VALUES[i] + "x";
+ }
+ return options;
+ }
+
+ private static void changePlaybackSpeed(ImageView imageView) {
+ if (player == null) {
+ Logger.printException(() -> "Player not available");
+ return;
+ }
+
+ try {
+ player.pause();
+ AlertDialog dialog = createSpeedPlaybackDialog(imageView);
+ dialog.setOnDismissListener(dialogInterface -> player.play());
+ dialog.show();
+
+ } catch (Exception e) {
+ Logger.printException(() -> "changePlaybackSpeed", e);
+ }
+ }
+
+ private static AlertDialog createSpeedPlaybackDialog(ImageView imageView) {
+ Context context = imageView.getContext();
+ int currentSelection = getCurrentSpeedSelection();
+
+ return new AlertDialog.Builder(context)
+ .setTitle("Select Playback Speed")
+ .setSingleChoiceItems(getSpeedOptions(), currentSelection,
+ PlaybackSpeedPatch::handleSpeedSelection)
+ .create();
+ }
+
+ private static int getCurrentSpeedSelection() {
+ try {
+ float currentRate = player.getPlaybackRate();
+ int index = Arrays.binarySearch(SPEED_VALUES, currentRate);
+ return Math.max(index, 0); // Use slowest speed if not found.
+ } catch (Exception e) {
+ Logger.printException(() -> "getCurrentSpeedSelection error getting current playback speed", e);
+ return 0;
+ }
+ }
+
+ private static void handleSpeedSelection(android.content.DialogInterface dialog, int selectedIndex) {
+ try {
+ float selectedSpeed = SPEED_VALUES[selectedIndex];
+ player.setPlaybackRate(selectedSpeed);
+ player.play();
+ } catch (Exception e) {
+ Logger.printException(() -> "handleSpeedSelection error setting playback speed", e);
+ } finally {
+ dialog.dismiss();
+ }
+ }
+}
+
+class SpeedIconDrawable extends Drawable {
+ private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ @Override
+ public void draw(Canvas canvas) {
+ int w = getBounds().width();
+ int h = getBounds().height();
+ float centerX = w / 2f;
+ // Position gauge in lower portion.
+ float centerY = h * 0.7f;
+ float radius = Math.min(w, h) / 2f * 0.8f;
+
+ paint.setColor(Color.WHITE);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(radius * 0.1f);
+
+ // Draw semicircle.
+ RectF oval = new RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius);
+ canvas.drawArc(oval, 180, 180, false, paint);
+
+ // Draw three tick marks.
+ paint.setStrokeWidth(radius * 0.06f);
+ for (int i = 0; i < 3; i++) {
+ float angle = 180 + (i * 45); // 180°, 225°, 270°.
+ float angleRad = (float) Math.toRadians(angle);
+
+ float startX = centerX + (radius * 0.8f) * (float) Math.cos(angleRad);
+ float startY = centerY + (radius * 0.8f) * (float) Math.sin(angleRad);
+ float endX = centerX + radius * (float) Math.cos(angleRad);
+ float endY = centerY + radius * (float) Math.sin(angleRad);
+
+ canvas.drawLine(startX, startY, endX, endY, paint);
+ }
+
+ // Draw needle.
+ paint.setStrokeWidth(radius * 0.08f);
+ float needleAngle = 200; // Slightly right of center.
+ float needleAngleRad = (float) Math.toRadians(needleAngle);
+
+ float needleEndX = centerX + (radius * 0.6f) * (float) Math.cos(needleAngleRad);
+ float needleEndY = centerY + (radius * 0.6f) * (float) Math.sin(needleAngleRad);
+
+ canvas.drawLine(centerX, centerY, needleEndX, needleEndY, paint);
+
+ // Center dot.
+ paint.setStyle(Paint.Style.FILL);
+ canvas.drawCircle(centerX, centerY, radius * 0.06f, paint);
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ paint.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter colorFilter) {
+ paint.setColorFilter(colorFilter);
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ return Dim.dp32;
+ }
+
+ @Override
+ public int getIntrinsicHeight() {
+ return Dim.dp32;
+ }
+}
diff --git a/extensions/primevideo/stub/build.gradle.kts b/extensions/primevideo/stub/build.gradle.kts
new file mode 100644
index 0000000000..7744c0eaac
--- /dev/null
+++ b/extensions/primevideo/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 21
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
diff --git a/extensions/primevideo/stub/src/main/AndroidManifest.xml b/extensions/primevideo/stub/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/SimpleTrigger.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/SimpleTrigger.java
new file mode 100644
index 0000000000..b537fe0402
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/SimpleTrigger.java
@@ -0,0 +1,6 @@
+package com.amazon.avod.fsm;
+
+public final class SimpleTrigger implements Trigger {
+ public SimpleTrigger(T triggerType) {
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/StateBase.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/StateBase.java
new file mode 100644
index 0000000000..95741308c3
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/StateBase.java
@@ -0,0 +1,7 @@
+package com.amazon.avod.fsm;
+
+public abstract class StateBase {
+ // This method orginally has protected access (modified in patch code).
+ public void doTrigger(Trigger trigger) {
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/Trigger.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/Trigger.java
new file mode 100644
index 0000000000..282f0f2004
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/fsm/Trigger.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.fsm;
+
+public interface Trigger {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/TimeSpan.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/TimeSpan.java
new file mode 100644
index 0000000000..cc90e43cdc
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/TimeSpan.java
@@ -0,0 +1,7 @@
+package com.amazon.avod.media;
+
+public final class TimeSpan {
+ public long getTotalMilliseconds() {
+ throw new UnsupportedOperationException();
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/AdBreak.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/AdBreak.java
new file mode 100644
index 0000000000..9a950434dc
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/AdBreak.java
@@ -0,0 +1,7 @@
+package com.amazon.avod.media.ads;
+
+import com.amazon.avod.media.TimeSpan;
+
+public interface AdBreak {
+ TimeSpan getDurationExcludingAux();
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakState.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakState.java
new file mode 100644
index 0000000000..f417660ed7
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakState.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.media.ads.internal.state;
+
+public abstract class AdBreakState extends AdEnabledPlaybackState {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakTrigger.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakTrigger.java
new file mode 100644
index 0000000000..f8b3995650
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdBreakTrigger.java
@@ -0,0 +1,18 @@
+package com.amazon.avod.media.ads.internal.state;
+
+import com.amazon.avod.media.ads.AdBreak;
+import com.amazon.avod.media.TimeSpan;
+
+public class AdBreakTrigger {
+ public AdBreak getBreak() {
+ throw new UnsupportedOperationException();
+ }
+
+ public TimeSpan getSeekTarget() {
+ throw new UnsupportedOperationException();
+ }
+
+ public TimeSpan getSeekStartPosition() {
+ throw new UnsupportedOperationException();
+ }
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlaybackState.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlaybackState.java
new file mode 100644
index 0000000000..445aad580a
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlaybackState.java
@@ -0,0 +1,8 @@
+package com.amazon.avod.media.ads.internal.state;
+
+import com.amazon.avod.fsm.StateBase;
+import com.amazon.avod.media.playback.state.PlayerStateType;
+import com.amazon.avod.media.playback.state.trigger.PlayerTriggerType;
+
+public class AdEnabledPlaybackState extends StateBase {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlayerTriggerType.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlayerTriggerType.java
new file mode 100644
index 0000000000..e7951e9342
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/AdEnabledPlayerTriggerType.java
@@ -0,0 +1,5 @@
+package com.amazon.avod.media.ads.internal.state;
+
+public enum AdEnabledPlayerTriggerType {
+ NO_MORE_ADS_SKIP_TRANSITION
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/ServerInsertedAdBreakState.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/ServerInsertedAdBreakState.java
new file mode 100644
index 0000000000..07c198013f
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/ads/internal/state/ServerInsertedAdBreakState.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.media.ads.internal.state;
+
+public class ServerInsertedAdBreakState extends AdBreakState {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java
new file mode 100644
index 0000000000..4f82e98727
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/VideoPlayer.java
@@ -0,0 +1,13 @@
+package com.amazon.avod.media.playback;
+
+public interface VideoPlayer {
+ long getCurrentPosition();
+
+ void seekTo(long positionMs);
+
+ void pause();
+
+ void play();
+
+ boolean isPlaying();
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/PlayerStateType.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/PlayerStateType.java
new file mode 100644
index 0000000000..202723285e
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/PlayerStateType.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.media.playback.state;
+
+public interface PlayerStateType {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/trigger/PlayerTriggerType.java b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/trigger/PlayerTriggerType.java
new file mode 100644
index 0000000000..eac139f9bf
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/avod/media/playback/state/trigger/PlayerTriggerType.java
@@ -0,0 +1,4 @@
+package com.amazon.avod.media.playback.state.trigger;
+
+public interface PlayerTriggerType {
+}
\ No newline at end of file
diff --git a/extensions/primevideo/stub/src/main/java/com/amazon/video/sdk/player/Player.java b/extensions/primevideo/stub/src/main/java/com/amazon/video/sdk/player/Player.java
new file mode 100644
index 0000000000..bd609e1964
--- /dev/null
+++ b/extensions/primevideo/stub/src/main/java/com/amazon/video/sdk/player/Player.java
@@ -0,0 +1,11 @@
+package com.amazon.video.sdk.player;
+
+public interface Player {
+ float getPlaybackRate();
+
+ void setPlaybackRate(float rate);
+
+ void play();
+
+ void pause();
+}
\ No newline at end of file
diff --git a/extensions/reddit/build.gradle.kts b/extensions/reddit/build.gradle.kts
index 8693f97f53..75c8d7a179 100644
--- a/extensions/reddit/build.gradle.kts
+++ b/extensions/reddit/build.gradle.kts
@@ -1,3 +1,9 @@
dependencies {
compileOnly(project(":extensions:reddit:stub"))
}
+
+android {
+ defaultConfig {
+ minSdk = 28
+ }
+}
diff --git a/extensions/reddit/src/main/java/app/revanced/extension/patches/FilterPromotedLinksPatch.java b/extensions/reddit/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java
similarity index 83%
rename from extensions/reddit/src/main/java/app/revanced/extension/patches/FilterPromotedLinksPatch.java
rename to extensions/reddit/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java
index 5b3e61b2ae..12cdc88345 100644
--- a/extensions/reddit/src/main/java/app/revanced/extension/patches/FilterPromotedLinksPatch.java
+++ b/extensions/reddit/src/main/java/app/revanced/extension/reddit/patches/FilterPromotedLinksPatch.java
@@ -1,12 +1,16 @@
-package app.revanced.extension.patches;
+package app.revanced.extension.reddit.patches;
import com.reddit.domain.model.ILink;
import java.util.ArrayList;
import java.util.List;
+@SuppressWarnings("unused")
public final class FilterPromotedLinksPatch {
+
/**
+ * Injection point.
+ *
* Filters list from promoted links.
**/
public static List> filterChildren(final Iterable> links) {
diff --git a/extensions/reddit/stub/build.gradle.kts b/extensions/reddit/stub/build.gradle.kts
index c1cc5794c0..b4bee8809f 100644
--- a/extensions/reddit/stub/build.gradle.kts
+++ b/extensions/reddit/stub/build.gradle.kts
@@ -1,10 +1,10 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
namespace = "app.revanced.extension"
- compileSdk = 33
+ compileSdk = 34
defaultConfig {
minSdk = 24
diff --git a/extensions/samsung/radio/build.gradle.kts b/extensions/samsung/radio/build.gradle.kts
new file mode 100644
index 0000000000..15d386efb3
--- /dev/null
+++ b/extensions/samsung/radio/build.gradle.kts
@@ -0,0 +1,10 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:samsung:radio:stub"))
+}
+
+android {
+ defaultConfig {
+ minSdk = 26
+ }
+}
diff --git a/extensions/samsung/radio/src/main/AndroidManifest.xml b/extensions/samsung/radio/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9b65eb06cf
--- /dev/null
+++ b/extensions/samsung/radio/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/misc/fix/crash/FixCrashPatch.java b/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/misc/fix/crash/FixCrashPatch.java
new file mode 100644
index 0000000000..72c5addc4c
--- /dev/null
+++ b/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/misc/fix/crash/FixCrashPatch.java
@@ -0,0 +1,24 @@
+package app.revanced.extension.samsung.radio.misc.fix.crash;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+@SuppressWarnings("unused")
+public final class FixCrashPatch {
+ /**
+ * Injection point.
+ *
+ * Add the required permissions to the request list to avoid crashes on API 34+.
+ **/
+ public static final String[] fixPermissionRequestList(String[] perms) {
+ List permsList = new ArrayList<>(Arrays.asList(perms));
+ if (permsList.contains("android.permission.POST_NOTIFICATIONS")) {
+ permsList.addAll(Arrays.asList("android.permission.RECORD_AUDIO", "android.permission.READ_PHONE_STATE", "android.permission.FOREGROUND_SERVICE_MICROPHONE"));
+ }
+ if (permsList.contains("android.permission.RECORD_AUDIO")) {
+ permsList.add("android.permission.FOREGROUND_SERVICE_MICROPHONE");
+ }
+ return permsList.toArray(new String[0]);
+ }
+}
diff --git a/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/restrictions/device/BypassDeviceChecksPatch.java b/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/restrictions/device/BypassDeviceChecksPatch.java
new file mode 100644
index 0000000000..19b6c3e822
--- /dev/null
+++ b/extensions/samsung/radio/src/main/java/app/revanced/extension/samsung/radio/restrictions/device/BypassDeviceChecksPatch.java
@@ -0,0 +1,19 @@
+package app.revanced.extension.samsung.radio.restrictions.device;
+
+import android.os.SemSystemProperties;
+
+import java.util.Arrays;
+
+@SuppressWarnings("unused")
+public final class BypassDeviceChecksPatch {
+
+ /**
+ * Injection point.
+ *
+ * Check if the device has the required hardware
+ **/
+ public static final boolean checkIfDeviceIsIncompatible(String[] deviceList) {
+ String currentDevice = SemSystemProperties.getSalesCode();
+ return Arrays.asList(deviceList).contains(currentDevice);
+ }
+}
diff --git a/extensions/samsung/radio/stub/build.gradle.kts b/extensions/samsung/radio/stub/build.gradle.kts
new file mode 100644
index 0000000000..b4bee8809f
--- /dev/null
+++ b/extensions/samsung/radio/stub/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
diff --git a/extensions/samsung/radio/stub/src/main/AndroidManifest.xml b/extensions/samsung/radio/stub/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..15e7c2ae67
--- /dev/null
+++ b/extensions/samsung/radio/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/extensions/samsung/radio/stub/src/main/java/android/os/SemSystemProperties.java b/extensions/samsung/radio/stub/src/main/java/android/os/SemSystemProperties.java
new file mode 100644
index 0000000000..33a4b4400c
--- /dev/null
+++ b/extensions/samsung/radio/stub/src/main/java/android/os/SemSystemProperties.java
@@ -0,0 +1,7 @@
+package android.os;
+
+public class SemSystemProperties {
+ public static String getSalesCode() {
+ throw new UnsupportedOperationException("Stub");
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/build.gradle.kts b/extensions/shared/build.gradle.kts
index 2da2e1e89c..3eb6ff48c7 100644
--- a/extensions/shared/build.gradle.kts
+++ b/extensions/shared/build.gradle.kts
@@ -1,3 +1,10 @@
dependencies {
implementation(project(":extensions:shared:library"))
+ compileOnly(libs.okhttp)
+}
+
+android {
+ defaultConfig {
+ minSdk = 23
+ }
}
diff --git a/extensions/shared/library/build.gradle.kts b/extensions/shared/library/build.gradle.kts
index 3cbb560695..8215e513ad 100644
--- a/extensions/shared/library/build.gradle.kts
+++ b/extensions/shared/library/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id("com.android.library")
+ alias(libs.plugins.android.library)
}
android {
@@ -18,4 +18,7 @@ android {
dependencies {
compileOnly(libs.annotation)
+ compileOnly(libs.okhttp)
+ compileOnly(libs.protobuf.javalite)
+ implementation(project(":extensions:shared:protobuf", configuration = "shadowRuntimeElements"))
}
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/ByteTrieSearch.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ByteTrieSearch.java
similarity index 89%
rename from extensions/youtube/src/main/java/app/revanced/extension/youtube/ByteTrieSearch.java
rename to extensions/shared/library/src/main/java/app/revanced/extension/shared/ByteTrieSearch.java
index 162e0b0405..c91de4a7aa 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/ByteTrieSearch.java
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/ByteTrieSearch.java
@@ -1,6 +1,4 @@
-package app.revanced.extension.youtube;
-
-import androidx.annotation.NonNull;
+package app.revanced.extension.shared;
import java.nio.charset.StandardCharsets;
@@ -39,7 +37,7 @@ public final class ByteTrieSearch extends TrieSearch {
return replacement;
}
- public ByteTrieSearch(@NonNull byte[]... patterns) {
+ public ByteTrieSearch(byte[]... patterns) {
super(new ByteTrieNode(), patterns);
}
}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java
index 1e2586b2bc..fb7e68963a 100644
--- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/GmsCoreSupport.java
@@ -1,10 +1,8 @@
package app.revanced.extension.shared;
-import static app.revanced.extension.shared.StringRef.str;
-
import android.annotation.SuppressLint;
import android.app.Activity;
-import android.app.AlertDialog;
+import android.app.Dialog;
import android.app.SearchManager;
import android.content.Context;
import android.content.DialogInterface;
@@ -14,145 +12,401 @@ import android.net.Uri;
import android.os.Build;
import android.os.PowerManager;
import android.provider.Settings;
+import android.util.Pair;
+import android.widget.LinearLayout;
-import androidx.annotation.RequiresApi;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.requests.Route;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.ui.CustomDialog;
+
+import org.json.JSONObject;
+
+import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
+import java.util.Locale;
-/**
- * @noinspection unused
- */
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.requests.Route.Method.GET;
+
+@SuppressWarnings("unused")
public class GmsCoreSupport {
- public static final String ORIGINAL_UNPATCHED_PACKAGE_NAME = "com.google.android.youtube";
- private static final String GMS_CORE_PACKAGE_NAME
- = getGmsCoreVendorGroupId() + ".android.gms";
- private static final Uri GMS_CORE_PROVIDER
- = Uri.parse("content://" + getGmsCoreVendorGroupId() + ".android.gsf.gservices/prefix");
- private static final String DONT_KILL_MY_APP_LINK
- = "https://dontkillmyapp.com";
+ private static GmsCore gmsCore = GmsCore.UNKNOWN;
- private static void open(String queryOrLink) {
- Intent intent;
- try {
- // Check if queryOrLink is a valid URL.
- new URL(queryOrLink);
-
- intent = new Intent(Intent.ACTION_VIEW, Uri.parse(queryOrLink));
- } catch (MalformedURLException e) {
- intent = new Intent(Intent.ACTION_WEB_SEARCH);
- intent.putExtra(SearchManager.QUERY, queryOrLink);
+ static {
+ for (GmsCore core : GmsCore.values()) {
+ if (core.getGroupId().equals(getGmsCoreVendorGroupId())) {
+ GmsCoreSupport.gmsCore = core;
+ break;
+ }
}
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- Utils.getContext().startActivity(intent);
-
- // Gracefully exit, otherwise the broken app will continue to run.
- System.exit(0);
- }
-
- private static void showBatteryOptimizationDialog(Activity context,
- String dialogMessageRef,
- String positiveButtonStringRef,
- DialogInterface.OnClickListener onPositiveClickListener) {
- // 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);
}
/**
* Injection point.
*/
- @RequiresApi(api = Build.VERSION_CODES.N)
public static void checkGmsCore(Activity context) {
- try {
- // Verify the user has not included GmsCore for a root installation.
- // GmsCore Support changes the package name, but with a mounted installation
- // all manifest changes are ignored and the original package name is used.
- if (context.getPackageName().equals(ORIGINAL_UNPATCHED_PACKAGE_NAME)) {
- Logger.printInfo(() -> "App is mounted with root, but GmsCore patch was included");
- // Cannot use localize text here, since the app will load
- // resources from the unpatched app and all patch strings are missing.
- Utils.showToastLong("The 'GmsCore support' patch breaks mount installations");
+ gmsCore.check(context);
+ }
- // Do not exit. If the app exits before launch completes (and without
- // opening another activity), then on some devices such as Pixel phone Android 10
- // no toast will be shown and the app will continually be relaunched
- // with the appearance of a hung app.
- }
+ private static String getOriginalPackageName() {
+ return null; // Modified during patching.
+ }
- // Verify GmsCore is installed.
+ private static String getGmsCoreVendorGroupId() {
+ return "app.revanced"; // Modified during patching.
+ }
+
+
+ /**
+ * @return If the current package name is the same as the original unpatched app.
+ * If `GmsCore support` was not included during patching, this returns true;
+ */
+ public static boolean isPackageNameOriginal() {
+ String originalPackageName = getOriginalPackageName();
+ return originalPackageName == null
+ || originalPackageName.equals(Utils.getContext().getPackageName());
+ }
+
+ private enum GmsCore {
+ REVANCED("app.revanced", "https://github.com/revanced/gmscore/releases/latest", () -> {
try {
- PackageManager manager = context.getPackageManager();
- manager.getPackageInfo(GMS_CORE_PACKAGE_NAME, PackageManager.GET_ACTIVITIES);
- } catch (PackageManager.NameNotFoundException exception) {
- Logger.printInfo(() -> "GmsCore was not found");
- // Cannot show a dialog and must show a toast,
- // because on some installations the app crashes before a dialog can be displayed.
- Utils.showToastLong(str("gms_core_toast_not_installed_message"));
- open(getGmsCoreDownload());
+ HttpURLConnection connection = Requester.getConnectionFromRoute(
+ "https://api.github.com",
+ new Route(GET, "/repos/revanced/gmscore/releases/latest")
+ );
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(5000);
+
+ int responseCode = connection.getResponseCode();
+ if (responseCode != 200) {
+ Logger.printDebug(() -> "GitHub API returned status code: " + responseCode);
+ return null;
+ }
+
+ // Parse the response
+ JSONObject releaseData = Requester.parseJSONObject(connection);
+ String tagName = releaseData.optString("tag_name", "");
+ connection.disconnect();
+
+ if (tagName.isEmpty()) {
+ Logger.printDebug(() -> "No tag_name found in GitHub release data");
+ return null;
+ }
+
+ if (tagName.startsWith("v")) tagName = tagName.substring(1);
+
+ return tagName;
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "Failed to fetch latest GmsCore version from GitHub", ex);
+ return null;
+ }
+ }),
+ UNKNOWN(getGmsCoreVendorGroupId(), getGmsCoreVendorGroupId() + "android.gms", () -> null);
+
+ private static final String DONT_KILL_MY_APP_URL
+ = "https://dontkillmyapp.com/";
+ private static final Route DONT_KILL_MY_APP_MANUFACTURER_API
+ = new Route(GET, "/api/v2/{manufacturer}.json");
+ private static final String DONT_KILL_MY_APP_NAME_PARAMETER
+ = "?app=MicroG";
+ private static final String BUILD_MANUFACTURER
+ = Build.MANUFACTURER.toLowerCase(Locale.ROOT).replace(" ", "-");
+
+ /**
+ * If a manufacturer specific page exists on DontKillMyApp.
+ */
+ @Nullable
+ private volatile Boolean dontKillMyAppManufacturerSupported;
+
+ private final String groupId;
+ private final String packageName;
+ private final String downloadQuery;
+ private final GetLatestVersion getLatestVersion;
+ private final Uri gmsCoreProvider;
+
+ GmsCore(String groupId, String downloadQuery, GetLatestVersion getLatestVersion) {
+ this.groupId = groupId;
+ this.packageName = groupId + ".android.gms";
+ this.gmsCoreProvider = Uri.parse("content://" + groupId + ".android.gsf.gservices/prefix");
+
+ this.downloadQuery = downloadQuery;
+ this.getLatestVersion = getLatestVersion;
+ }
+
+ String getGroupId() {
+ return groupId;
+ }
+
+ void check(Activity context) {
+ checkInstallation(context);
+ checkUpdates(context);
+ }
+
+ private void checkInstallation(Activity context) {
+ try {
+ // Verify the user has not included GmsCore for a root installation.
+ // GmsCore Support changes the package name, but with a mounted installation
+ // all manifest changes are ignored and the original package name is used.
+ if (isPackageNameOriginal()) {
+ Logger.printInfo(() -> "App is mounted with root, but GmsCore patch was included");
+ // Cannot use localize text here, since the app will load resources
+ // from the unpatched app and all patch strings are missing.
+ Utils.showToastLong("The 'GmsCore support' patch breaks mount installations");
+
+ // Do not exit. If the app exits before launch completes (and without
+ // opening another activity), then on some devices such as Pixel phone Android 10
+ // no toast will be shown and the app will continually relaunch
+ // with the appearance of a hung app.
+ }
+
+ // Verify GmsCore is installed.
+ try {
+ PackageManager manager = context.getPackageManager();
+ manager.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES);
+ } catch (PackageManager.NameNotFoundException exception) {
+ Logger.printInfo(() -> "GmsCore was not found");
+ // Cannot show a dialog and must show a toast,
+ // because on some installations the app crashes before a dialog can be displayed.
+ Utils.showToastLong(str("revanced_gms_core_toast_not_installed_message"));
+
+ open(downloadQuery);
+ return;
+ }
+
+ // Check if GmsCore is whitelisted from battery optimizations.
+ if (isAndroidAutomotive(context)) {
+ // Ignore Android Automotive devices (Google built-in),
+ // as there is no way to disable battery optimizations.
+ Logger.printDebug(() -> "Device is Android Automotive");
+ } else if (batteryOptimizationsEnabled(context)) {
+ Logger.printInfo(() -> "GmsCore is not whitelisted from battery optimizations");
+
+ showBatteryOptimizationDialog(context,
+ "revanced_gms_core_dialog_not_whitelisted_using_battery_optimizations_message",
+ "revanced_gms_core_dialog_continue_text",
+ (dialog, id) -> openGmsCoreDisableBatteryOptimizationsIntent(context));
+ return;
+ }
+
+ // Check if GmsCore is currently running in the background.
+ var client = context.getContentResolver().acquireContentProviderClient(gmsCoreProvider);
+ //noinspection TryFinallyCanBeTryWithResources
+ try {
+ if (client == null) {
+ Logger.printInfo(() -> "GmsCore is not running in the background");
+ checkIfDontKillMyAppSupportsManufacturer();
+
+ showBatteryOptimizationDialog(context,
+ "revanced_gms_core_dialog_not_whitelisted_not_allowed_in_background_message",
+ "gmsrevanced_gms_core_log_open_website_text",
+ (dialog, id) -> openDontKillMyApp());
+ }
+ } finally {
+ if (client != null) client.close();
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "checkGmsCore failure", ex);
+ }
+ }
+
+ private void checkUpdates(Activity context) {
+ if (!BaseSettings.GMS_CORE_CHECK_UPDATES.get()) {
+ Logger.printDebug(() -> "GmsCore update check is disabled in settings");
return;
}
- // Check if GmsCore is running in the background.
- try (var client = context.getContentResolver().acquireContentProviderClient(GMS_CORE_PROVIDER)) {
- if (client == null) {
- Logger.printInfo(() -> "GmsCore is not running in the background");
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ PackageManager manager = context.getPackageManager();
+ var installedVersion = manager.getPackageInfo(packageName, 0).versionName;
- showBatteryOptimizationDialog(context,
- "gms_core_dialog_not_whitelisted_not_allowed_in_background_message",
- "gms_core_dialog_open_website_text",
- (dialog, id) -> open(DONT_KILL_MY_APP_LINK));
- return;
+ // GmsCore adds suffixes for flavor builds. Remove the suffix for version comparison.
+ int suffixIndex = installedVersion.indexOf('-');
+ if (suffixIndex != -1)
+ installedVersion = installedVersion.substring(0, suffixIndex);
+ String finalInstalledVersion = installedVersion;
+
+ Logger.printDebug(() -> "Installed GmsCore version: " + finalInstalledVersion);
+
+ var latestVersion = getLatestVersion.get();
+
+ if (latestVersion == null || latestVersion.isEmpty()) {
+ Logger.printDebug(() -> "Could not get latest GmsCore version");
+ Utils.showToastLong(str("revanced_gms_core_toast_update_check_failed_message"));
+ return;
+ }
+
+ Logger.printDebug(() -> "Latest GmsCore version on GitHub: " + latestVersion);
+
+ // Compare versions
+ if (!installedVersion.equals(latestVersion)) {
+ Logger.printInfo(() -> "GmsCore update available. Installed: " + finalInstalledVersion
+ + ", Latest: " + latestVersion);
+
+ showUpdateDialog(context, installedVersion, latestVersion);
+ } else {
+ Logger.printDebug(() -> "GmsCore is up to date");
+ }
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "Could not check GmsCore updates", ex);
+ Utils.showToastLong(str("revanced_gms_core_toast_update_check_failed_message"));
}
+ });
+ }
+
+ private void open(String queryOrLink) {
+ Logger.printInfo(() -> "Opening link: " + queryOrLink);
+
+ Intent intent;
+ try {
+ // Check if queryOrLink is a valid URL.
+ new URL(queryOrLink);
+
+ intent = new Intent(Intent.ACTION_VIEW, Uri.parse(queryOrLink));
+ } catch (MalformedURLException e) {
+ intent = new Intent(Intent.ACTION_WEB_SEARCH);
+ intent.putExtra(SearchManager.QUERY, queryOrLink);
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ Utils.getContext().startActivity(intent);
+
+ // Gracefully exit, otherwise the broken app will continue to run.
+ System.exit(0);
+ }
+
+ private void showUpdateDialog(Activity context, String installedVersion, String latestVersion) {
+ // 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(() -> {
+ try {
+ Pair