diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 11783ce9..a8cc3527 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,4 +45,5 @@ android { dependencies { compileOnly(project(mapOf("path" to ":dummy"))) compileOnly("androidx.annotation:annotation:1.5.0") + compileOnly("androidx.appcompat:appcompat:1.5.1") } diff --git a/app/src/main/java/app/revanced/twitch/settings/ReturnType.java b/app/src/main/java/app/revanced/twitch/settings/ReturnType.java new file mode 100644 index 00000000..142c4a1f --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/settings/ReturnType.java @@ -0,0 +1,5 @@ +package app.revanced.twitch.settings; + +public enum ReturnType { + BOOLEAN, INTEGER, STRING, LONG, FLOAT +} diff --git a/app/src/main/java/app/revanced/twitch/settings/SettingsEnum.java b/app/src/main/java/app/revanced/twitch/settings/SettingsEnum.java new file mode 100644 index 00000000..548366d2 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/settings/SettingsEnum.java @@ -0,0 +1,149 @@ +package app.revanced.twitch.settings; + +import android.content.Context; +import android.content.SharedPreferences; + +import app.revanced.twitch.utils.LogHelper; +import app.revanced.twitch.utils.ReVancedUtils; + +public enum SettingsEnum { + /* Ads */ + BLOCK_VIDEO_ADS("revanced_block_video_ads", true, ReturnType.BOOLEAN), + BLOCK_AUDIO_ADS("revanced_block_audio_ads", true, ReturnType.BOOLEAN), + + /* Chat */ + SHOW_DELETED_MESSAGES("revanced_show_deleted_messages", "cross-out", ReturnType.STRING), + + /* Misc */ + DEBUG_MODE("revanced_debug_mode", false, ReturnType.BOOLEAN, true); + + public static final String REVANCED_PREFS = "revanced_prefs"; + + private final String path; + private final Object defaultValue; + private final ReturnType returnType; + private final boolean rebootApp; + + private Object value = null; + + SettingsEnum(String path, Object defaultValue, ReturnType returnType) { + this.path = path; + this.defaultValue = defaultValue; + this.returnType = returnType; + this.rebootApp = false; + } + + SettingsEnum(String path, Object defaultValue, ReturnType returnType, Boolean rebootApp) { + this.path = path; + this.defaultValue = defaultValue; + this.returnType = returnType; + this.rebootApp = rebootApp; + } + + static { + load(); + } + + private static void load() { + ReVancedUtils.ifContextAttached((context -> { + try { + SharedPreferences prefs = context.getSharedPreferences(REVANCED_PREFS, Context.MODE_PRIVATE); + for (SettingsEnum setting : values()) { + Object value = setting.getDefaultValue(); + + try { + switch (setting.getReturnType()) { + case BOOLEAN: + value = prefs.getBoolean(setting.getPath(), (boolean)setting.getDefaultValue()); + break; + // Numbers are implicitly converted from strings + case FLOAT: + case LONG: + case INTEGER: + case STRING: + value = prefs.getString(setting.getPath(), setting.getDefaultValue() + ""); + break; + default: + LogHelper.error("Setting '%s' does not have a valid type", setting.name()); + break; + } + } + catch (ClassCastException ex) { + LogHelper.printException("Failed to read value", ex); + } + + setting.setValue(value); + LogHelper.debug("Loaded setting '%s' with value %s", setting.name(), value); + } + } catch (Throwable th) { + LogHelper.printException("Failed to load settings", th); + } + })); + } + + public void setValue(Object newValue) { + // Implicitly convert strings to numbers depending on the ResultType + switch (returnType) { + case FLOAT: + value = Float.valueOf(newValue + ""); + break; + case LONG: + value = Long.valueOf(newValue + ""); + break; + case INTEGER: + value = Integer.valueOf(newValue + ""); + break; + default: + value = newValue; + break; + } + } + + public void saveValue(Object newValue) { + ReVancedUtils.ifContextAttached((context) -> { + SharedPreferences prefs = context.getSharedPreferences(REVANCED_PREFS, Context.MODE_PRIVATE); + if (returnType == ReturnType.BOOLEAN) { + prefs.edit().putBoolean(path, (Boolean)newValue).apply(); + } else { + prefs.edit().putString(path, newValue + "").apply(); + } + value = newValue; + }); + } + + public int getInt() { + return (int) value; + } + + public String getString() { + return (String) value; + } + + public boolean getBoolean() { + return (Boolean) value; + } + + public Long getLong() { + return (Long) value; + } + + public Float getFloat() { + return (Float) value; + } + + public Object getDefaultValue() { + return defaultValue; + } + + public String getPath() { + return path; + } + + public ReturnType getReturnType() { + return returnType; + } + + public boolean shouldRebootOnChange() { + return rebootApp; + } +} diff --git a/app/src/main/java/app/revanced/twitch/settingsmenu/ReVancedSettingsFragment.java b/app/src/main/java/app/revanced/twitch/settingsmenu/ReVancedSettingsFragment.java new file mode 100644 index 00000000..e18246d3 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/settingsmenu/ReVancedSettingsFragment.java @@ -0,0 +1,125 @@ +package app.revanced.twitch.settingsmenu; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlarmManager; +import android.app.AlertDialog; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.Process; +import android.preference.EditTextPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceManager; +import android.preference.SwitchPreference; + +import androidx.annotation.Nullable; + +import app.revanced.twitch.settings.SettingsEnum; +import app.revanced.twitch.utils.LogHelper; +import app.revanced.twitch.utils.ReVancedUtils; + +import tv.twitch.android.app.core.LandingActivity; + +public class ReVancedSettingsFragment extends PreferenceFragment { + + private boolean registered = false; + private boolean settingsInitialized = false; + + SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, key) -> { + LogHelper.debug("Setting '%s' changed", key); + syncPreference(key); + }; + + /** + * Sync preference + * @param key Preference to load. If key is null, all preferences are updated + */ + private void syncPreference(@Nullable String key) { + for (SettingsEnum setting : SettingsEnum.values()) { + if (!setting.getPath().equals(key) && key != null) + continue; + + Preference pref = this.findPreference(setting.getPath()); + LogHelper.debug("Syncing setting '%s' with UI", setting.getPath()); + + if (pref instanceof SwitchPreference) { + setting.setValue(((SwitchPreference) pref).isChecked()); + } + else if (pref instanceof EditTextPreference) { + setting.setValue(((EditTextPreference) pref).getText()); + } + else if (pref instanceof ListPreference) { + ListPreference listPref = (ListPreference) pref; + listPref.setSummary(listPref.getEntry()); + setting.setValue(listPref.getValue()); + } + else { + LogHelper.error("Setting '%s' cannot be handled!", pref); + } + + if (ReVancedUtils.getContext() != null && key != null && settingsInitialized && setting.shouldRebootOnChange()) { + rebootDialog(getActivity()); + } + + // First onChange event is caused by initial state loading + this.settingsInitialized = true; + } + } + + @SuppressLint("ResourceType") + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + + try { + PreferenceManager mgr = getPreferenceManager(); + mgr.setSharedPreferencesName(SettingsEnum.REVANCED_PREFS); + mgr.getSharedPreferences().registerOnSharedPreferenceChangeListener(this.listener); + + addPreferencesFromResource( + getResources().getIdentifier( + SettingsEnum.REVANCED_PREFS, + "xml", + this.getContext().getPackageName() + ) + ); + + // Sync all preferences with UI + syncPreference(null); + + this.registered = true; + } catch (Throwable th) { + LogHelper.printException("Error during onCreate()", th); + } + } + + @Override + public void onDestroy() { + if (this.registered) { + getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this.listener); + this.registered = false; + } + super.onDestroy(); + } + + @SuppressLint("MissingPermission") + private void reboot(Activity activity) { + int flags; + flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; + ((AlarmManager) activity.getSystemService(Context.ALARM_SERVICE)).setExact(AlarmManager.ELAPSED_REALTIME, 1500L, PendingIntent.getActivity(activity, 0, new Intent(activity, LandingActivity.class), flags)); + Process.killProcess(Process.myPid()); + } + + private void rebootDialog(final Activity activity) { + new AlertDialog.Builder(activity). + setMessage(ReVancedUtils.getString("revanced_reboot_message")). + setPositiveButton(ReVancedUtils.getString("revanced_reboot"), (dialog, i) -> reboot(activity)) + .setNegativeButton(ReVancedUtils.getString("revanced_cancel"), null) + .show(); + } +} diff --git a/app/src/main/java/app/revanced/twitch/settingsmenu/SettingsHooks.java b/app/src/main/java/app/revanced/twitch/settingsmenu/SettingsHooks.java new file mode 100644 index 00000000..749a188a --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/settingsmenu/SettingsHooks.java @@ -0,0 +1,110 @@ +package app.revanced.twitch.settingsmenu; + +import static app.revanced.twitch.utils.ReVancedUtils.getIdentifier; +import static app.revanced.twitch.utils.ReVancedUtils.getStringId; + +import android.content.Intent; +import android.os.Bundle; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; + +import java.util.ArrayList; +import java.util.List; + +import app.revanced.twitch.utils.ReVancedUtils; +import app.revanced.twitch.utils.LogHelper; +import tv.twitch.android.feature.settings.menu.SettingsMenuGroup; +import tv.twitch.android.settings.SettingsActivity; + +public class SettingsHooks { + private static final int REVANCED_SETTINGS_MENU_ITEM_ID = 0x7; + private static final String EXTRA_REVANCED_SETTINGS = "app.revanced.twitch.settings"; + + /** + * Launches SettingsActivity and show ReVanced settings + */ + public static void startSettingsActivity() { + LogHelper.debug("Launching ReVanced settings"); + + ReVancedUtils.ifContextAttached((c) -> { + Intent intent = new Intent(c, SettingsActivity.class); + Bundle bundle = new Bundle(); + bundle.putBoolean(EXTRA_REVANCED_SETTINGS, true); + intent.putExtras(bundle); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + c.startActivity(intent); + }); + } + + /** + * Helper for easy access in smali + * @return Returns string resource id + */ + public static int getReVancedSettingsString() { + return getStringId("revanced_settings"); + } + + /** + * Intercepts settings menu group list creation in SettingsMenuPresenter$Event.MenuGroupsUpdated + * @return Returns a modified list of menu groups + */ + public static List handleSettingMenuCreation(List settingGroups, Object revancedEntry) { + List groups = new ArrayList<>(settingGroups); + + if(groups.size() < 1) { + // Create new menu group if none exist yet + List items = new ArrayList<>(); + items.add(revancedEntry); + groups.add(new SettingsMenuGroup(items)); + } + else { + // Add to last menu group + int groupIdx = groups.size() - 1; + List items = new ArrayList<>(groups.remove(groupIdx).getSettingsMenuItems()); + items.add(revancedEntry); + groups.add(new SettingsMenuGroup(items)); + } + + LogHelper.debug("%d menu groups in list", settingGroups.size()); + return groups; + } + + /** + * Intercepts settings menu group onclick events + * @return Returns true if handled, otherwise false + */ + @SuppressWarnings("rawtypes") + public static boolean handleSettingMenuOnClick(Enum item) { + LogHelper.debug("item %d clicked", item.ordinal()); + if(item.ordinal() != REVANCED_SETTINGS_MENU_ITEM_ID) { + return false; + } + + startSettingsActivity(); + return true; + } + + /** + * Intercepts fragment loading in SettingsActivity.onCreate + * @return Returns true if the revanced settings have been requested by the user, otherwise false + */ + public static boolean handleSettingsCreation(AppCompatActivity base) { + if(!base.getIntent().getBooleanExtra(EXTRA_REVANCED_SETTINGS, false)) { + LogHelper.debug("Revanced settings not requested"); + return false; // User wants to enter another settings fragment + } + LogHelper.debug("ReVanced settings requested"); + + ReVancedSettingsFragment fragment = new ReVancedSettingsFragment(); + ActionBar supportActionBar = base.getSupportActionBar(); + if(supportActionBar != null) + supportActionBar.setTitle(getStringId("revanced_settings")); + + base.getFragmentManager() + .beginTransaction() + .replace(getIdentifier("fragment_container", "id"), fragment) + .commit(); + return true; + } +} diff --git a/app/src/main/java/app/revanced/twitch/settingsmenu/preference/CustomPreferenceCategory.java b/app/src/main/java/app/revanced/twitch/settingsmenu/preference/CustomPreferenceCategory.java new file mode 100644 index 00000000..ec92a505 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/settingsmenu/preference/CustomPreferenceCategory.java @@ -0,0 +1,23 @@ +package app.revanced.twitch.settingsmenu.preference; + +import android.content.Context; +import android.graphics.Color; +import android.preference.PreferenceCategory; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +public class CustomPreferenceCategory extends PreferenceCategory { + public CustomPreferenceCategory(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onBindView(View rootView) { + super.onBindView(rootView); + + if(rootView instanceof TextView) { + ((TextView) rootView).setTextColor(Color.parseColor("#8161b3")); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/twitch/utils/LogHelper.java b/app/src/main/java/app/revanced/twitch/utils/LogHelper.java new file mode 100644 index 00000000..f8d8add6 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/utils/LogHelper.java @@ -0,0 +1,49 @@ +package app.revanced.twitch.utils; + +import android.util.Log; +import android.widget.Toast; + +import app.revanced.twitch.settings.SettingsEnum; + +public class LogHelper { + + public static String getCallOrigin() + { + try { + final StackTraceElement elem = new Throwable().getStackTrace()[/* depth */ 2]; + final String fullName = elem.getClassName(); + return fullName.substring(fullName.lastIndexOf('.') + 1) + "/" + elem.getMethodName(); + } + catch (Exception ex) { + return ""; + } + } + + public static final String TAG = "revanced"; + + public static void debug(String message, Object... args) { + Log.d(TAG, getCallOrigin() + ": " + String.format(message, args)); + } + + public static void info(String message, Object... args) { + Log.i(TAG, getCallOrigin() + ": " + String.format(message, args)); + } + + public static void error(String message, Object... args) { + String msg = getCallOrigin() + ": " + String.format(message, args); + showDebugToast(msg); + Log.e(TAG, msg); + } + + public static void printException(String message, Throwable ex) { + String msg = getCallOrigin() + ": " + message; + showDebugToast(msg + " (" + ex.getClass().getSimpleName() + ")"); + Log.e(TAG, msg, ex); + } + + private static void showDebugToast(String msg) { + if(SettingsEnum.DEBUG_MODE.getBoolean()) { + ReVancedUtils.ifContextAttached((c) -> Toast.makeText(c, msg, Toast.LENGTH_SHORT).show()); + } + } +} diff --git a/app/src/main/java/app/revanced/twitch/utils/ReVancedUtils.java b/app/src/main/java/app/revanced/twitch/utils/ReVancedUtils.java new file mode 100644 index 00000000..d915bff4 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/utils/ReVancedUtils.java @@ -0,0 +1,87 @@ +package app.revanced.twitch.utils; + +import android.annotation.SuppressLint; +import android.content.Context; + +public class ReVancedUtils { + @SuppressLint("StaticFieldLeak") + public static Context context; + + /** + * Regular context getter + * @return Returns context or null if not initialized + */ + public static Context getContext() { + if (context != null) { + return context; + } + + LogHelper.error("Context is null (at %s)", LogHelper.getCallOrigin()); + return null; + } + + /** + * Execute lambda only if context attached. + */ + public static void ifContextAttached(SafeContextAccessLambda lambda) { + if (context != null) { + lambda.run(context); + return; + } + + LogHelper.error("Context is null, lambda not executed (at %s)", LogHelper.getCallOrigin()); + } + + /** + * Execute lambda only if context attached. + * @return Returns result on success or valueOnError on failure + */ + public static T ifContextAttached(SafeContextAccessReturnLambda lambda, T valueOnError) { + if (context != null) { + return lambda.run(context); + } + + LogHelper.error("Context is null, lambda not executed (at %s)", LogHelper.getCallOrigin()); + return valueOnError; + } + + public interface SafeContextAccessReturnLambda { + T run(Context ctx); + } + + public interface SafeContextAccessLambda { + void run(Context ctx); + } + + /** + * Get resource id safely + * @return May return 0 if resource not found or context not attached + */ + @SuppressLint("DiscouragedApi") + public static int getIdentifier(String name, String defType) { + return ifContextAttached( + (context) -> { + int resId = context.getResources().getIdentifier(name, defType, context.getPackageName()); + if(resId == 0) { + LogHelper.error("Resource '%s' not found (at %s)", name, LogHelper.getCallOrigin()); + } + return resId; + }, + 0 + ); + } + + /* Called from SettingsPatch smali */ + public static int getStringId(String name) { + return getIdentifier(name, "string"); + } + + /* Called from SettingsPatch smali */ + public static int getDrawableId(String name) { + return getIdentifier(name, "drawable"); + } + + public static String getString(String name) { + return ifContextAttached((c) -> c.getString(getStringId(name)), ""); + } +} diff --git a/dummy/src/main/java/tv/twitch/android/app/core/LandingActivity.java b/dummy/src/main/java/tv/twitch/android/app/core/LandingActivity.java new file mode 100644 index 00000000..8411575f --- /dev/null +++ b/dummy/src/main/java/tv/twitch/android/app/core/LandingActivity.java @@ -0,0 +1,5 @@ +package tv.twitch.android.app.core; + +import android.app.Activity; + +public class LandingActivity extends Activity {} diff --git a/dummy/src/main/java/tv/twitch/android/feature/settings/menu/SettingsMenuGroup.java b/dummy/src/main/java/tv/twitch/android/feature/settings/menu/SettingsMenuGroup.java new file mode 100644 index 00000000..72495e4b --- /dev/null +++ b/dummy/src/main/java/tv/twitch/android/feature/settings/menu/SettingsMenuGroup.java @@ -0,0 +1,14 @@ +package tv.twitch.android.feature.settings.menu; + +import java.util.List; + +// Dummy +public final class SettingsMenuGroup { + public SettingsMenuGroup(List settingsMenuItems) { + throw new UnsupportedOperationException("Stub"); + } + + public List getSettingsMenuItems() { + throw new UnsupportedOperationException("Stub"); + } +} \ No newline at end of file diff --git a/dummy/src/main/java/tv/twitch/android/settings/SettingsActivity.java b/dummy/src/main/java/tv/twitch/android/settings/SettingsActivity.java new file mode 100644 index 00000000..dcf42ab3 --- /dev/null +++ b/dummy/src/main/java/tv/twitch/android/settings/SettingsActivity.java @@ -0,0 +1,5 @@ +package tv.twitch.android.settings; + +import android.app.Activity; + +public class SettingsActivity extends Activity {} diff --git a/dummy/src/main/java/tv/twitch/android/shared/chat/util/ClickableUsernameSpan.java b/dummy/src/main/java/tv/twitch/android/shared/chat/util/ClickableUsernameSpan.java new file mode 100644 index 00000000..0322bdba --- /dev/null +++ b/dummy/src/main/java/tv/twitch/android/shared/chat/util/ClickableUsernameSpan.java @@ -0,0 +1,9 @@ +package tv.twitch.android.shared.chat.util; + +import android.text.style.ClickableSpan; +import android.view.View; + +public final class ClickableUsernameSpan extends ClickableSpan { + @Override + public void onClick(View widget) {} +} \ No newline at end of file