1
mirror of https://github.com/revanced/revanced-integrations synced 2024-11-16 22:50:31 +01:00

feat(twitch): integrations code for patches (#216)

Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
This commit is contained in:
Tim Schneeberger 2022-11-21 22:53:12 +01:00 committed by GitHub
parent 24367cea3f
commit d4c3b74a9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 582 additions and 0 deletions

View File

@ -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")
}

View File

@ -0,0 +1,5 @@
package app.revanced.twitch.settings;
public enum ReturnType {
BOOLEAN, INTEGER, STRING, LONG, FLOAT
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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<SettingsMenuGroup> handleSettingMenuCreation(List<SettingsMenuGroup> settingGroups, Object revancedEntry) {
List<SettingsMenuGroup> groups = new ArrayList<>(settingGroups);
if(groups.size() < 1) {
// Create new menu group if none exist yet
List<Object> items = new ArrayList<>();
items.add(revancedEntry);
groups.add(new SettingsMenuGroup(items));
}
else {
// Add to last menu group
int groupIdx = groups.size() - 1;
List<Object> 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;
}
}

View File

@ -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"));
}
}
}

View File

@ -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 "<unknown>";
}
}
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());
}
}
}

View File

@ -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> T ifContextAttached(SafeContextAccessReturnLambda<T> 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> {
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)), "");
}
}

View File

@ -0,0 +1,5 @@
package tv.twitch.android.app.core;
import android.app.Activity;
public class LandingActivity extends Activity {}

View File

@ -0,0 +1,14 @@
package tv.twitch.android.feature.settings.menu;
import java.util.List;
// Dummy
public final class SettingsMenuGroup {
public SettingsMenuGroup(List<Object> settingsMenuItems) {
throw new UnsupportedOperationException("Stub");
}
public List<Object> getSettingsMenuItems() {
throw new UnsupportedOperationException("Stub");
}
}

View File

@ -0,0 +1,5 @@
package tv.twitch.android.settings;
import android.app.Activity;
public class SettingsActivity extends Activity {}

View File

@ -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) {}
}