ACCESS_NETWORK_STATE
app manifest permission.
+ */
+ @SuppressWarnings({"deprecation", "MissingPermission"})
public static NetworkType getNetworkType() {
Context networkContext = getContext();
if (networkContext == null) {
@@ -852,6 +857,20 @@ public class Utils {
return getResourceColor(colorString);
}
+ /**
+ * Converts dip value to actual device pixels.
+ *
+ * @param dip The density-independent pixels value
+ * @return The device pixel value
+ */
+ public static int dipToPixels(float dip) {
+ return (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dip,
+ Resources.getSystem().getDisplayMetrics()
+ );
+ }
+
public static int clamp(int value, int lower, int upper) {
return Math.max(lower, Math.min(value, upper));
}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerPreference.java
new file mode 100644
index 000000000..e9f24ea61
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerPreference.java
@@ -0,0 +1,442 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.getResourceIdentifier;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.os.Build;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.TextWatcher;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.ColorInt;
+
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.StringSetting;
+
+/**
+ * A custom preference for selecting a color via a hexadecimal code or a color picker dialog.
+ * Extends {@link EditTextPreference} to display a colored dot in the widget area,
+ * reflecting the currently selected color. The dot is dimmed when the preference is disabled.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class ColorPickerPreference extends EditTextPreference {
+
+ /**
+ * Character to show the color appearance.
+ */
+ public static final String COLOR_DOT_STRING = "⬤";
+
+ /**
+ * Length of a valid color string of format #RRGGBB.
+ */
+ public static final int COLOR_STRING_LENGTH = 7;
+
+ /**
+ * Matches everything that is not a hex number/letter.
+ */
+ private static final Pattern PATTERN_NOT_HEX = Pattern.compile("[^0-9A-Fa-f]");
+
+ /**
+ * Alpha for dimming when the preference is disabled.
+ */
+ private static final float DISABLED_ALPHA = 0.5f; // 50%
+
+ /**
+ * View displaying a colored dot in the widget area.
+ */
+ private View widgetColorDot;
+
+ /**
+ * Current color in RGB format (without alpha).
+ */
+ @ColorInt
+ private int currentColor;
+
+ /**
+ * Associated setting for storing the color value.
+ */
+ private StringSetting colorSetting;
+
+ /**
+ * Dialog TextWatcher for the EditText to monitor color input changes.
+ */
+ private TextWatcher colorTextWatcher;
+
+ /**
+ * Dialog TextView displaying a colored dot for the selected color preview in the dialog.
+ */
+ private TextView dialogColorPreview;
+
+ /**
+ * Dialog color picker view.
+ */
+ private ColorPickerView dialogColorPickerView;
+
+ /**
+ * Removes non valid hex characters, converts to all uppercase,
+ * and adds # character to the start if not present.
+ */
+ public static String cleanupColorCodeString(String colorString) {
+ // Remove non-hex chars, convert to uppercase, and ensure correct length
+ String result = "#" + PATTERN_NOT_HEX.matcher(colorString)
+ .replaceAll("").toUpperCase(Locale.ROOT);
+
+ if (result.length() < COLOR_STRING_LENGTH) {
+ return result;
+ }
+
+ return result.substring(0, COLOR_STRING_LENGTH);
+ }
+
+ /**
+ * @param color RGB color, without an alpha channel.
+ * @return #RRGGBB hex color string
+ */
+ public static String getColorString(@ColorInt int color) {
+ String colorString = String.format("#%06X", color);
+ if ((color & 0xFF000000) != 0) {
+ // Likely a bug somewhere.
+ Logger.printException(() -> "getColorString: color has alpha channel: " + colorString);
+ }
+ return colorString;
+ }
+
+ /**
+ * Creates a Spanned object for a colored dot using SpannableString.
+ *
+ * @param color The RGB color (without alpha).
+ * @return A Spanned object with the colored dot.
+ */
+ public static Spanned getColorDot(@ColorInt int color) {
+ SpannableString spannable = new SpannableString(COLOR_DOT_STRING);
+ spannable.setSpan(new ForegroundColorSpan(color | 0xFF000000), 0, COLOR_DOT_STRING.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ spannable.setSpan(new RelativeSizeSpan(1.5f), 0, 1,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ return spannable;
+ }
+
+ public ColorPickerPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ public ColorPickerPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ /**
+ * Initializes the preference by setting up the EditText, loading the color, and set the widget layout.
+ */
+ private void init() {
+ colorSetting = (StringSetting) Setting.getSettingFromPath(getKey());
+ if (colorSetting == null) {
+ Logger.printException(() -> "Could not find color setting for: " + getKey());
+ }
+
+ EditText editText = getEditText();
+ editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
+ | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ editText.setAutofillHints((String) null);
+ }
+
+ // Set the widget layout to a custom layout containing the colored dot.
+ setWidgetLayoutResource(getResourceIdentifier("revanced_color_dot_widget", "layout"));
+ }
+
+ /**
+ * Sets the selected color and updates the UI and settings.
+ *
+ * @param colorString The color in hexadecimal format (e.g., "#RRGGBB").
+ * @throws IllegalArgumentException If the color string is invalid.
+ */
+ @Override
+ public final void setText(String colorString) {
+ try {
+ Logger.printDebug(() -> "setText: " + colorString);
+ super.setText(colorString);
+
+ currentColor = Color.parseColor(colorString) & 0x00FFFFFF;
+ if (colorSetting != null) {
+ colorSetting.save(getColorString(currentColor));
+ }
+ updateColorPreview();
+ updateWidgetColorDot();
+ } catch (IllegalArgumentException ex) {
+ // This code is reached if the user pastes settings json with an invalid color
+ // since this preference is updated with the new setting text.
+ Logger.printDebug(() -> "Parse color error: " + colorString, ex);
+ Utils.showToastShort(str("revanced_settings_color_invalid"));
+ setText(colorSetting.resetToDefault());
+ } catch (Exception ex) {
+ Logger.printException(() -> "setText failure: " + colorString, ex);
+ }
+ }
+
+ @Override
+ protected void onBindView(View view) {
+ super.onBindView(view);
+
+ widgetColorDot = view.findViewById(getResourceIdentifier(
+ "revanced_color_dot_widget", "id"));
+ widgetColorDot.setBackgroundResource(getResourceIdentifier(
+ "revanced_settings_circle_background", "drawable"));
+ widgetColorDot.getBackground().setTint(currentColor | 0xFF000000);
+ widgetColorDot.setAlpha(isEnabled() ? 1.0f : DISABLED_ALPHA);
+ }
+
+ /**
+ * Creates a layout with a color preview and EditText for hex color input.
+ *
+ * @param context The context for creating the layout.
+ * @return A LinearLayout containing the color preview and EditText.
+ */
+ private LinearLayout createDialogLayout(Context context) {
+ LinearLayout layout = new LinearLayout(context);
+ layout.setOrientation(LinearLayout.VERTICAL);
+ layout.setPadding(70, 0, 70, 0);
+
+ // Inflate color picker.
+ View colorPicker = LayoutInflater.from(context).inflate(
+ getResourceIdentifier("revanced_color_picker", "layout"), null);
+ dialogColorPickerView = colorPicker.findViewById(
+ getResourceIdentifier("color_picker_view", "id"));
+ dialogColorPickerView.setColor(currentColor);
+ layout.addView(colorPicker);
+
+ // Horizontal layout for preview and EditText.
+ LinearLayout inputLayout = new LinearLayout(context);
+ inputLayout.setOrientation(LinearLayout.HORIZONTAL);
+ inputLayout.setPadding(0, 20, 0, 0);
+
+ dialogColorPreview = new TextView(context);
+ inputLayout.addView(dialogColorPreview);
+ updateColorPreview();
+
+ EditText editText = getEditText();
+ ViewParent parent = editText.getParent();
+ if (parent instanceof ViewGroup parentViewGroup) {
+ parentViewGroup.removeView(editText);
+ }
+ editText.setLayoutParams(new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ));
+ String currentColorString = getColorString(currentColor);
+ editText.setText(currentColorString);
+ editText.setSelection(currentColorString.length());
+ editText.setTypeface(Typeface.MONOSPACE);
+ colorTextWatcher = createColorTextWatcher(dialogColorPickerView);
+ editText.addTextChangedListener(colorTextWatcher);
+ inputLayout.addView(editText);
+
+ // Add a dummy view to take up remaining horizontal space,
+ // otherwise it will show an oversize underlined text view.
+ View paddingView = new View(context);
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ 0,
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ 1f
+ );
+ paddingView.setLayoutParams(params);
+ inputLayout.addView(paddingView);
+
+ layout.addView(inputLayout);
+
+ // Set up color picker listener with debouncing.
+ // Add listener last to prevent callbacks from set calls above.
+ dialogColorPickerView.setOnColorChangedListener(color -> {
+ // Check if it actually changed, since this callback
+ // can be caused by updates in afterTextChanged().
+ if (currentColor == color) {
+ return;
+ }
+
+ String updatedColorString = getColorString(color);
+ Logger.printDebug(() -> "onColorChanged: " + updatedColorString);
+ currentColor = color;
+ editText.setText(updatedColorString);
+ editText.setSelection(updatedColorString.length());
+
+ updateColorPreview();
+ updateWidgetColorDot();
+ });
+
+ return layout;
+ }
+
+ /**
+ * Updates the color preview TextView with a colored dot.
+ */
+ private void updateColorPreview() {
+ if (dialogColorPreview != null) {
+ dialogColorPreview.setText(getColorDot(currentColor));
+ }
+ }
+
+ private void updateWidgetColorDot() {
+ if (widgetColorDot != null) {
+ widgetColorDot.getBackground().setTint(currentColor | 0xFF000000);
+ widgetColorDot.setAlpha(isEnabled() ? 1.0f : DISABLED_ALPHA);
+ }
+ }
+
+ /**
+ * Creates a TextWatcher to monitor changes in the EditText for color input.
+ *
+ * @return A TextWatcher that updates the color preview on valid input.
+ */
+ private TextWatcher createColorTextWatcher(ColorPickerView colorPickerView) {
+ return new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable edit) {
+ try {
+ String colorString = edit.toString();
+
+ String sanitizedColorString = cleanupColorCodeString(colorString);
+ if (!sanitizedColorString.equals(colorString)) {
+ edit.replace(0, colorString.length(), sanitizedColorString);
+ return;
+ }
+
+ if (sanitizedColorString.length() != COLOR_STRING_LENGTH) {
+ // User is still typing out the color.
+ return;
+ }
+
+ final int newColor = Color.parseColor(colorString);
+ if (currentColor != newColor) {
+ Logger.printDebug(() -> "afterTextChanged: " + sanitizedColorString);
+ currentColor = newColor;
+ updateColorPreview();
+ updateWidgetColorDot();
+ colorPickerView.setColor(newColor);
+ }
+ } catch (Exception ex) {
+ // Should never be reached since input is validated before using.
+ Logger.printException(() -> "afterTextChanged failure", ex);
+ }
+ }
+ };
+ }
+
+ /**
+ * Prepares the dialog builder with a custom view and reset button.
+ *
+ * @param builder The AlertDialog.Builder to configure.
+ */
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ Utils.setEditTextDialogTheme(builder);
+ LinearLayout dialogLayout = createDialogLayout(builder.getContext());
+ builder.setView(dialogLayout);
+ final int originalColor = currentColor;
+
+ builder.setNeutralButton(str("revanced_settings_reset_color"), null);
+
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ try {
+ String colorString = getEditText().getText().toString();
+
+ if (colorString.length() != COLOR_STRING_LENGTH) {
+ Utils.showToastShort(str("revanced_settings_color_invalid"));
+ setText(getColorString(originalColor));
+ return;
+ }
+
+ setText(colorString);
+ } catch (Exception ex) {
+ // Should never happen due to a bad color string,
+ // since the text is validated and fixed while the user types.
+ Logger.printException(() -> "setPositiveButton failure", ex);
+ }
+ });
+
+ builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
+ try {
+ // Restore the original color.
+ setText(getColorString(originalColor));
+ } catch (Exception ex) {
+ Logger.printException(() -> "setNegativeButton failure", ex);
+ }
+ });
+ }
+
+ @Override
+ protected void showDialog(Bundle state) {
+ super.showDialog(state);
+
+ AlertDialog dialog = (AlertDialog) getDialog();
+ dialog.setCanceledOnTouchOutside(false);
+
+ // Do not close dialog when reset is pressed.
+ Button button = dialog.getButton(AlertDialog.BUTTON_NEUTRAL);
+ button.setOnClickListener(view -> {
+ try {
+ final int defaultColor = Color.parseColor(colorSetting.defaultValue) & 0x00FFFFFF;
+ // Setting view color causes listener callback into this class.
+ dialogColorPickerView.setColor(defaultColor);
+ } catch (Exception ex) {
+ Logger.printException(() -> "setOnClickListener failure", ex);
+ }
+ });
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ super.onDialogClosed(positiveResult);
+
+ if (colorTextWatcher != null) {
+ getEditText().removeTextChangedListener(colorTextWatcher);
+ colorTextWatcher = null;
+ }
+
+ dialogColorPreview = null;
+ dialogColorPickerView = null;
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+ updateWidgetColorDot();
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerView.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerView.java
new file mode 100644
index 000000000..cbec3349c
--- /dev/null
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ColorPickerView.java
@@ -0,0 +1,500 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.Utils.dipToPixels;
+import static app.revanced.extension.shared.settings.preference.ColorPickerPreference.getColorString;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ComposeShader;
+import android.graphics.LinearGradient;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.RectF;
+import android.graphics.Shader;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+
+import androidx.annotation.ColorInt;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+
+/**
+ * A custom color picker view that allows the user to select a color using a hue slider and a saturation-value selector.
+ * This implementation is density-independent and responsive across different screen sizes and DPIs.
+ *
+ * + * This view displays two main components for color selection: + *
+ * The view uses {@link LinearGradient} and {@link ComposeShader} to create the color gradients for the hue bar and the + * saturation-value selector. It also uses {@link Paint} to draw the selectors (draggable handles). + * + *
+ * The selected color can be retrieved using {@link #getColor()} and can be set using {@link #setColor(int)}. + * An {@link OnColorChangedListener} can be registered to receive notifications when the selected color changes. + */ +public class ColorPickerView extends View { + + /** + * Interface definition for a callback to be invoked when the selected color changes. + */ + public interface OnColorChangedListener { + /** + * Called when the selected color has changed. + * + * Important: Callback color uses RGB format with zero alpha channel. + * + * @param color The new selected color. + */ + void onColorChanged(@ColorInt int color); + } + + /** Expanded touch area for the hue bar to increase the touch-sensitive area. */ + public static final float TOUCH_EXPANSION = dipToPixels(20f); + + private static final float MARGIN_BETWEEN_AREAS = dipToPixels(24); + private static final float VIEW_PADDING = dipToPixels(16); + private static final float HUE_BAR_WIDTH = dipToPixels(12); + private static final float HUE_CORNER_RADIUS = dipToPixels(6); + private static final float SELECTOR_RADIUS = dipToPixels(12); + private static final float SELECTOR_STROKE_WIDTH = 8; + /** + * Hue fill radius. Use slightly smaller radius for the selector handle fill, + * otherwise the anti-aliasing causes the fill color to bleed past the selector outline. + */ + private static final float SELECTOR_FILL_RADIUS = SELECTOR_RADIUS - SELECTOR_STROKE_WIDTH / 2; + /** Thin dark outline stroke width for the selector rings. */ + private static final float SELECTOR_EDGE_STROKE_WIDTH = 1; + public static final float SELECTOR_EDGE_RADIUS = + SELECTOR_RADIUS + SELECTOR_STROKE_WIDTH / 2 + SELECTOR_EDGE_STROKE_WIDTH / 2; + + /** Selector outline inner color. */ + @ColorInt + private static final int SELECTOR_OUTLINE_COLOR = Color.WHITE; + + /** Dark edge color for the selector rings. */ + @ColorInt + private static final int SELECTOR_EDGE_COLOR = Color.parseColor("#CFCFCF"); + + private static final int[] HUE_COLORS = new int[361]; + static { + for (int i = 0; i < 361; i++) { + HUE_COLORS[i] = Color.HSVToColor(new float[]{i, 1, 1}); + } + } + + /** Hue bar. */ + private final Paint huePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + /** Saturation-value selector. */ + private final Paint saturationValuePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + /** Draggable selector. */ + private final Paint selectorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + { + selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH); + } + + /** Bounds of the hue bar. */ + private final RectF hueRect = new RectF(); + /** Bounds of the saturation-value selector. */ + private final RectF saturationValueRect = new RectF(); + + /** HSV color calculations to avoid allocations during drawing. */ + private final float[] hsvArray = {1, 1, 1}; + + /** Current hue value (0-360). */ + private float hue = 0f; + /** Current saturation value (0-1). */ + private float saturation = 1f; + /** Current value (brightness) value (0-1). */ + private float value = 1f; + + /** The currently selected color in RGB format with no alpha channel. */ + @ColorInt + private int selectedColor; + + private OnColorChangedListener colorChangedListener; + + /** Track if we're currently dragging the hue or saturation handle. */ + private boolean isDraggingHue; + private boolean isDraggingSaturation; + + public ColorPickerView(Context context) { + super(context); + } + + public ColorPickerView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ColorPickerView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final float DESIRED_ASPECT_RATIO = 0.8f; // height = width * 0.8 + + final int minWidth = Utils.dipToPixels(250); + final int minHeight = (int) (minWidth * DESIRED_ASPECT_RATIO); + + int width = resolveSize(minWidth, widthMeasureSpec); + int height = resolveSize(minHeight, heightMeasureSpec); + + // Ensure minimum dimensions for usability + width = Math.max(width, minWidth); + height = Math.max(height, minHeight); + + // Adjust height to maintain desired aspect ratio if possible + final int desiredHeight = (int) (width * DESIRED_ASPECT_RATIO); + if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) { + height = desiredHeight; + } + + setMeasuredDimension(width, height); + } + + /** + * Called when the size of the view changes. + * This method calculates and sets the bounds of the hue bar and saturation-value selector. + * It also creates the necessary shaders for the gradients. + */ + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + + // Calculate bounds with hue bar on the right + final float effectiveWidth = width - (2 * VIEW_PADDING); + final float selectorWidth = effectiveWidth - HUE_BAR_WIDTH - MARGIN_BETWEEN_AREAS; + + // Adjust rectangles to account for padding and density-independent dimensions + saturationValueRect.set( + VIEW_PADDING, + VIEW_PADDING, + VIEW_PADDING + selectorWidth, + height - VIEW_PADDING + ); + + hueRect.set( + width - VIEW_PADDING - HUE_BAR_WIDTH, + VIEW_PADDING, + width - VIEW_PADDING, + height - VIEW_PADDING + ); + + // Update the shaders. + updateHueShader(); + updateSaturationValueShader(); + } + + /** + * Updates the hue full spectrum (0-360 degrees). + */ + private void updateHueShader() { + LinearGradient hueShader = new LinearGradient( + hueRect.left, hueRect.top, + hueRect.left, hueRect.bottom, + HUE_COLORS, + null, + Shader.TileMode.CLAMP + ); + + huePaint.setShader(hueShader); + } + + /** + * Updates the shader for the saturation-value selector based on the currently selected hue. + * This method creates a combined shader that blends a saturation gradient with a value gradient. + */ + private void updateSaturationValueShader() { + // Create a saturation-value gradient based on the current hue. + // Calculate the start color (white with the selected hue) for the saturation gradient. + final int startColor = Color.HSVToColor(new float[]{hue, 0f, 1f}); + + // Calculate the middle color (fully saturated color with the selected hue) for the saturation gradient. + final int midColor = Color.HSVToColor(new float[]{hue, 1f, 1f}); + + // Create a linear gradient for the saturation from startColor to midColor (horizontal). + LinearGradient satShader = new LinearGradient( + saturationValueRect.left, saturationValueRect.top, + saturationValueRect.right, saturationValueRect.top, + startColor, + midColor, + Shader.TileMode.CLAMP + ); + + // Create a linear gradient for the value (brightness) from white to black (vertical). + //noinspection ExtractMethodRecommender + LinearGradient valShader = new LinearGradient( + saturationValueRect.left, saturationValueRect.top, + saturationValueRect.left, saturationValueRect.bottom, + Color.WHITE, + Color.BLACK, + Shader.TileMode.CLAMP + ); + + // Combine the saturation and value shaders using PorterDuff.Mode.MULTIPLY to create the final color. + ComposeShader combinedShader = new ComposeShader(satShader, valShader, PorterDuff.Mode.MULTIPLY); + + // Set the combined shader for the saturation-value paint. + saturationValuePaint.setShader(combinedShader); + } + + /** + * Draws the color picker view on the canvas. + * This method draws the saturation-value selector, the hue bar with rounded corners, + * and the draggable handles. + * + * @param canvas The canvas on which to draw. + */ + @Override + protected void onDraw(Canvas canvas) { + // Draw the saturation-value selector rectangle. + canvas.drawRect(saturationValueRect, saturationValuePaint); + + // Draw the hue bar. + canvas.drawRoundRect(hueRect, HUE_CORNER_RADIUS, HUE_CORNER_RADIUS, huePaint); + + final float hueSelectorX = hueRect.centerX(); + final float hueSelectorY = hueRect.top + (hue / 360f) * hueRect.height(); + + final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width(); + final float satSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height(); + + // Draw the saturation and hue selector handle filled with the selected color. + hsvArray[0] = hue; + final int hueHandleColor = Color.HSVToColor(0xFF, hsvArray); + selectorPaint.setStyle(Paint.Style.FILL_AND_STROKE); + + selectorPaint.setColor(hueHandleColor); + canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_FILL_RADIUS, selectorPaint); + + selectorPaint.setColor(selectedColor | 0xFF000000); + canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_FILL_RADIUS, selectorPaint); + + // Draw white outlines for the handles. + selectorPaint.setColor(SELECTOR_OUTLINE_COLOR); + selectorPaint.setStyle(Paint.Style.STROKE); + selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH); + canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_RADIUS, selectorPaint); + canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_RADIUS, selectorPaint); + + // Draw thin dark outlines for the handles at the outer edge of the white outline. + selectorPaint.setColor(SELECTOR_EDGE_COLOR); + selectorPaint.setStrokeWidth(SELECTOR_EDGE_STROKE_WIDTH); + canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint); + canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint); + } + + /** + * Handles touch events on the view. + * This method determines whether the touch event occurred within the hue bar or the saturation-value selector, + * updates the corresponding values (hue, saturation, value), and invalidates the view to trigger a redraw. + *
+ * In addition to testing if the touch is within the strict rectangles, an expanded hit area (by selectorRadius)
+ * is used so that the draggable handles remain active even when half of the handle is outside the drawn bounds.
+ *
+ * @param event The motion event.
+ * @return True if the event was handled, false otherwise.
+ */
+ @SuppressLint("ClickableViewAccessibility") // performClick is not overridden, but not needed in this case.
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ try {
+ final float x = event.getX();
+ final float y = event.getY();
+ final int action = event.getAction();
+ Logger.printDebug(() -> "onTouchEvent action: " + action + " x: " + x + " y: " + y);
+
+ // Define touch expansion for the hue bar.
+ RectF expandedHueRect = new RectF(
+ hueRect.left - TOUCH_EXPANSION,
+ hueRect.top,
+ hueRect.right + TOUCH_EXPANSION,
+ hueRect.bottom
+ );
+
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ // Calculate current handle positions.
+ final float hueSelectorX = hueRect.centerX();
+ final float hueSelectorY = hueRect.top + (hue / 360f) * hueRect.height();
+
+ final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width();
+ final float valSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height();
+
+ // Create hit areas for both handles.
+ RectF hueHitRect = new RectF(
+ hueSelectorX - SELECTOR_RADIUS,
+ hueSelectorY - SELECTOR_RADIUS,
+ hueSelectorX + SELECTOR_RADIUS,
+ hueSelectorY + SELECTOR_RADIUS
+ );
+ RectF satValHitRect = new RectF(
+ satSelectorX - SELECTOR_RADIUS,
+ valSelectorY - SELECTOR_RADIUS,
+ satSelectorX + SELECTOR_RADIUS,
+ valSelectorY + SELECTOR_RADIUS
+ );
+
+ // Check if the touch started on a handle or within the expanded hue bar area.
+ if (hueHitRect.contains(x, y)) {
+ isDraggingHue = true;
+ updateHueFromTouch(y);
+ } else if (satValHitRect.contains(x, y)) {
+ isDraggingSaturation = true;
+ updateSaturationValueFromTouch(x, y);
+ } else if (expandedHueRect.contains(x, y)) {
+ // Handle touch within the expanded hue bar area.
+ isDraggingHue = true;
+ updateHueFromTouch(y);
+ } else if (saturationValueRect.contains(x, y)) {
+ isDraggingSaturation = true;
+ updateSaturationValueFromTouch(x, y);
+ }
+ break;
+
+ case MotionEvent.ACTION_MOVE:
+ // Continue updating values even if touch moves outside the view.
+ if (isDraggingHue) {
+ updateHueFromTouch(y);
+ } else if (isDraggingSaturation) {
+ updateSaturationValueFromTouch(x, y);
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ isDraggingHue = false;
+ isDraggingSaturation = false;
+ break;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onTouchEvent failure", ex);
+ }
+
+ return true;
+ }
+
+ /**
+ * Updates the hue value based on touch position, clamping to valid range.
+ *
+ * @param y The y-coordinate of the touch position.
+ */
+ private void updateHueFromTouch(float y) {
+ // Clamp y to the hue rectangle bounds.
+ final float clampedY = Utils.clamp(y, hueRect.top, hueRect.bottom);
+ final float updatedHue = ((clampedY - hueRect.top) / hueRect.height()) * 360f;
+ if (hue == updatedHue) {
+ return;
+ }
+
+ hue = updatedHue;
+ updateSaturationValueShader();
+ updateSelectedColor();
+ }
+
+ /**
+ * Updates saturation and value based on touch position, clamping to valid range.
+ *
+ * @param x The x-coordinate of the touch position.
+ * @param y The y-coordinate of the touch position.
+ */
+ private void updateSaturationValueFromTouch(float x, float y) {
+ // Clamp x and y to the saturation-value rectangle bounds.
+ final float clampedX = Utils.clamp(x, saturationValueRect.left, saturationValueRect.right);
+ final float clampedY = Utils.clamp(y, saturationValueRect.top, saturationValueRect.bottom);
+
+ final float updatedSaturation = (clampedX - saturationValueRect.left) / saturationValueRect.width();
+ final float updatedValue = 1 - ((clampedY - saturationValueRect.top) / saturationValueRect.height());
+
+ if (saturation == updatedSaturation && value == updatedValue) {
+ return;
+ }
+ saturation = updatedSaturation;
+ value = updatedValue;
+ updateSelectedColor();
+ }
+
+ /**
+ * Updates the selected color and notifies listeners.
+ */
+ private void updateSelectedColor() {
+ final int updatedColor = Color.HSVToColor(0, new float[]{hue, saturation, value});
+
+ if (selectedColor != updatedColor) {
+ selectedColor = updatedColor;
+
+ if (colorChangedListener != null) {
+ colorChangedListener.onColorChanged(updatedColor);
+ }
+ }
+
+ // Must always redraw, otherwise if saturation is pure grey or black
+ // then the hue slider cannot be changed.
+ invalidate();
+ }
+
+ /**
+ * Sets the currently selected color.
+ *
+ * @param color The color to set in either ARGB or RGB format.
+ */
+ public void setColor(@ColorInt int color) {
+ color &= 0x00FFFFFF;
+ if (selectedColor == color) {
+ return;
+ }
+
+ // Update the selected color.
+ selectedColor = color;
+ Logger.printDebug(() -> "setColor: " + getColorString(selectedColor));
+
+ // Convert the ARGB color to HSV values.
+ float[] hsv = new float[3];
+ Color.colorToHSV(color, hsv);
+
+ // Update the hue, saturation, and value.
+ hue = hsv[0];
+ saturation = hsv[1];
+ value = hsv[2];
+
+ // Update the saturation-value shader based on the new hue.
+ updateSaturationValueShader();
+
+ // Notify the listener if it's set.
+ if (colorChangedListener != null) {
+ colorChangedListener.onColorChanged(selectedColor);
+ }
+
+ // Invalidate the view to trigger a redraw.
+ invalidate();
+ }
+
+ /**
+ * Gets the currently selected color.
+ *
+ * @return The selected color in RGB format with no alpha channel.
+ */
+ @ColorInt
+ public int getColor() {
+ return selectedColor;
+ }
+
+ /**
+ * Sets the listener to be notified when the selected color changes.
+ *
+ * @param listener The listener to set.
+ */
+ public void setOnColorChangedListener(OnColorChangedListener listener) {
+ colorChangedListener = listener;
+ }
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java
index ba2facccb..161711737 100644
--- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ReVancedAboutPreference.java
@@ -8,7 +8,6 @@ import android.app.Dialog;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
-import android.content.res.Configuration;
import android.graphics.Color;
import android.net.Uri;
import android.os.Bundle;
@@ -54,7 +53,7 @@ public class ReVancedAboutPreference extends Preference {
}
protected boolean isDarkModeEnabled() {
- return Utils.isDarkModeEnabled(getContext());
+ return Utils.isDarkModeEnabled();
}
/**
diff --git a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/Utils.java b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/Utils.java
index 63f4b0127..d1d0f089f 100644
--- a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/Utils.java
+++ b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/Utils.java
@@ -2,7 +2,6 @@ package app.revanced.extension.tiktok;
import static app.revanced.extension.shared.Utils.isDarkModeEnabled;
-import android.content.Context;
import android.graphics.Color;
import android.view.View;
import android.widget.TextView;
@@ -43,8 +42,8 @@ public class Utils {
private static final @ColorInt int TEXT_LIGHT_MODE_SUMMARY
= Color.argb(255, 80, 80, 80);
- public static void setTitleAndSummaryColor(Context context, View view) {
- final boolean darkModeEnabled = isDarkModeEnabled(context);
+ public static void setTitleAndSummaryColor(View view) {
+ final boolean darkModeEnabled = isDarkModeEnabled();
TextView title = view.findViewById(android.R.id.title);
title.setTextColor(darkModeEnabled
diff --git a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java
index fb4e27121..f387c68f5 100644
--- a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java
+++ b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/DownloadPathPreference.java
@@ -101,7 +101,7 @@ public class DownloadPathPreference extends DialogPreference {
protected void onBindView(View view) {
super.onBindView(view);
- Utils.setTitleAndSummaryColor(getContext(), view);
+ Utils.setTitleAndSummaryColor(view);
}
@Override
diff --git a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java
index d2df31462..a4785c955 100644
--- a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java
+++ b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/InputTextPreference.java
@@ -22,6 +22,6 @@ public class InputTextPreference extends EditTextPreference {
protected void onBindView(View view) {
super.onBindView(view);
- Utils.setTitleAndSummaryColor(getContext(), view);
+ Utils.setTitleAndSummaryColor(view);
}
}
diff --git a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java
index 012d334f0..a13438a90 100644
--- a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java
+++ b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/RangeValuePreference.java
@@ -127,7 +127,7 @@ public class RangeValuePreference extends DialogPreference {
protected void onBindView(View view) {
super.onBindView(view);
- Utils.setTitleAndSummaryColor(getContext(), view);
+ Utils.setTitleAndSummaryColor(view);
}
@Override
diff --git a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedTikTokAboutPreference.java b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedTikTokAboutPreference.java
index 408bab6c0..7348ccc70 100644
--- a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedTikTokAboutPreference.java
+++ b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/ReVancedTikTokAboutPreference.java
@@ -48,6 +48,6 @@ public class ReVancedTikTokAboutPreference extends ReVancedAboutPreference {
protected void onBindView(View view) {
super.onBindView(view);
- Utils.setTitleAndSummaryColor(getContext(), view);
+ Utils.setTitleAndSummaryColor(view);
}
}
diff --git a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java
index f0e8085f0..7a78d504d 100644
--- a/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java
+++ b/extensions/tiktok/src/main/java/app/revanced/extension/tiktok/settings/preference/TogglePreference.java
@@ -22,6 +22,6 @@ public class TogglePreference extends SwitchPreference {
protected void onBindView(View view) {
super.onBindView(view);
- Utils.setTitleAndSummaryColor(getContext(), view);
+ Utils.setTitleAndSummaryColor(view);
}
}
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java
index d2e1d5f8b..7021cc6f9 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/NavigationButtonsPatch.java
@@ -95,7 +95,7 @@ public final class NavigationButtonsPatch {
return false;
}
- return Utils.isDarkModeEnabled(Utils.getContext())
+ return Utils.isDarkModeEnabled()
? !DISABLE_TRANSLUCENT_NAVIGATION_BAR_DARK
: !DISABLE_TRANSLUCENT_NAVIGATION_BAR_LIGHT;
}
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java
index 90f18a6c2..2b07a2f73 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/WideSearchbarPatch.java
@@ -1,7 +1,5 @@
package app.revanced.extension.youtube.patches;
-import android.content.res.Resources;
-import android.util.TypedValue;
import android.view.View;
import app.revanced.extension.shared.Logger;
@@ -33,8 +31,7 @@ public final class WideSearchbarPatch {
final int paddingRight = searchBarView.getPaddingRight();
final int paddingTop = searchBarView.getPaddingTop();
final int paddingBottom = searchBarView.getPaddingBottom();
- final int paddingStart = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
- 8, Resources.getSystem().getDisplayMetrics());
+ final int paddingStart = Utils.dipToPixels(8);
if (Utils.isRightToLeftLocale()) {
searchBarView.setPadding(paddingLeft, paddingTop, paddingStart, paddingBottom);
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/theme/ProgressBarDrawable.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/theme/ProgressBarDrawable.java
index bf0284f79..93d53c9a5 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/theme/ProgressBarDrawable.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/theme/ProgressBarDrawable.java
@@ -20,13 +20,16 @@ import app.revanced.extension.youtube.settings.Settings;
public class ProgressBarDrawable extends Drawable {
private final Paint paint = new Paint();
+ {
+ paint.setColor(SeekbarColorPatch.getSeekbarColor());
+ }
@Override
public void draw(@NonNull Canvas canvas) {
if (Settings.HIDE_SEEKBAR_THUMBNAIL.get()) {
return;
}
- paint.setColor(SeekbarColorPatch.getSeekbarColor());
+
canvas.drawRect(getBounds(), paint);
}
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/theme/SeekbarColorPatch.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/theme/SeekbarColorPatch.java
index 4d036509e..1489ffd51 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/theme/SeekbarColorPatch.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/theme/SeekbarColorPatch.java
@@ -179,7 +179,7 @@ public final class SeekbarColorPatch {
//noinspection ConstantConditions
if (false) { // Set true to force slow animation for development.
final int longAnimation = Utils.getResourceIdentifier(
- Utils.isDarkModeEnabled(Utils.getContext())
+ Utils.isDarkModeEnabled()
? "startup_animation_5s_30fps_dark"
: "startup_animation_5s_30fps_light",
"raw");
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java
index a0730c055..2a3c5a07c 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java
@@ -21,8 +21,6 @@ import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import android.text.style.ImageSpan;
import android.text.style.ReplacementSpan;
-import android.util.DisplayMetrics;
-import android.util.TypedValue;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
@@ -120,16 +118,13 @@ public class ReturnYouTubeDislike {
private static final ShapeDrawable leftSeparatorShape;
static {
- DisplayMetrics dp = Objects.requireNonNull(Utils.getContext()).getResources().getDisplayMetrics();
-
leftSeparatorBounds = new Rect(0, 0,
- (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp),
- (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14, dp));
- final int middleSeparatorSize =
- (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp);
+ Utils.dipToPixels(1.2f),
+ Utils.dipToPixels(14f));
+ final int middleSeparatorSize = Utils.dipToPixels(3.7f);
middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize);
- leftSeparatorShapePaddingPixels = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8.4f, dp);
+ leftSeparatorShapePaddingPixels = Utils.dipToPixels(8.4f);
leftSeparatorShape = new ShapeDrawable(new RectShape());
leftSeparatorShape.setBounds(leftSeparatorBounds);
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/LicenseActivityHook.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/LicenseActivityHook.java
index 5a14ca39c..a6335a5e3 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/LicenseActivityHook.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/LicenseActivityHook.java
@@ -6,7 +6,6 @@ import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.preference.PreferenceFragment;
-import android.util.TypedValue;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toolbar;
@@ -119,8 +118,7 @@ public class LicenseActivityHook {
toolbar.setNavigationIcon(ReVancedPreferenceFragment.getBackButtonDrawable());
toolbar.setTitle(getResourceIdentifier("revanced_settings_title", "string"));
- final int margin = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16,
- Utils.getContext().getResources().getDisplayMetrics());
+ final int margin = Utils.dipToPixels(16);
toolbar.setTitleMarginStart(margin);
toolbar.setTitleMarginEnd(margin);
TextView toolbarTextView = Utils.getChildView(toolbar, false,
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java
index 916378bf5..1257bb190 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java
@@ -17,7 +17,6 @@ import android.preference.SwitchPreference;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
-import android.util.TypedValue;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.TextView;
@@ -245,9 +244,7 @@ public class ReVancedPreferenceFragment extends AbstractPreferenceFragment {
toolbar.setNavigationIcon(getBackButtonDrawable());
toolbar.setNavigationOnClickListener(view -> preferenceScreenDialog.dismiss());
- final int margin = (int) TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP, 16, getResources().getDisplayMetrics()
- );
+ final int margin = Utils.dipToPixels(16);
toolbar.setTitleMargin(margin, 0, margin, 0);
TextView toolbarTextView = Utils.getChildView(toolbar,
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java
index 3f48930e3..22259d571 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java
@@ -5,7 +5,6 @@ import static app.revanced.extension.shared.StringRef.str;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.text.TextUtils;
-import android.util.TypedValue;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -727,15 +726,11 @@ public class SegmentPlaybackController {
}
}
- private static int highlightSegmentTimeBarScreenWidth = -1; // actual pixel width to use
- private static int getHighlightSegmentTimeBarScreenWidth() {
- if (highlightSegmentTimeBarScreenWidth == -1) {
- highlightSegmentTimeBarScreenWidth = (int) TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP, HIGHLIGHT_SEGMENT_DRAW_BAR_WIDTH,
- Objects.requireNonNull(Utils.getContext()).getResources().getDisplayMetrics());
- }
- return highlightSegmentTimeBarScreenWidth;
- }
+ /**
+ * Actual screen pixel width to use for the highlight segment time bar.
+ */
+ private static final int highlightSegmentTimeBarScreenWidth
+ = Utils.dipToPixels(HIGHLIGHT_SEGMENT_DRAW_BAR_WIDTH);
/**
* Injection point.
@@ -757,7 +752,7 @@ public class SegmentPlaybackController {
final float left = leftPadding + segment.start * videoMillisecondsToPixels;
final float right;
if (segment.category == SegmentCategory.HIGHLIGHT) {
- right = left + getHighlightSegmentTimeBarScreenWidth();
+ right = left + highlightSegmentTimeBarScreenWidth;
} else {
right = leftPadding + segment.end * videoMillisecondsToPixels;
}
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java
index ec1b1fca5..ae6900330 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java
@@ -1,6 +1,7 @@
package app.revanced.extension.youtube.sponsorblock.objects;
import static app.revanced.extension.shared.StringRef.sf;
+import static app.revanced.extension.shared.settings.preference.ColorPickerPreference.COLOR_DOT_STRING;
import static app.revanced.extension.youtube.settings.Settings.*;
import android.graphics.Color;
@@ -9,7 +10,9 @@ import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
+import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -134,7 +137,8 @@ public enum SegmentCategory {
updateEnabledCategories();
}
- public static int applyOpacityToColor(int color, float opacity) {
+ @ColorInt
+ public static int applyOpacityToColor(@ColorInt int color, float opacity) {
if (opacity < 0 || opacity > 1.0f) {
throw new IllegalArgumentException("Invalid opacity: " + opacity);
}
@@ -165,29 +169,28 @@ public enum SegmentCategory {
/**
* Skipped segment toast, if the skip occurred in the first quarter of the video
*/
- @NonNull
public final StringRef skippedToastBeginning;
/**
* Skipped segment toast, if the skip occurred in the middle half of the video
*/
- @NonNull
public final StringRef skippedToastMiddle;
/**
* Skipped segment toast, if the skip occurred in the last quarter of the video
*/
- @NonNull
public final StringRef skippedToastEnd;
- @NonNull
public final Paint paint;
+ /**
+ * Category color with opacity applied.
+ */
+ @ColorInt
private int color;
/**
* Value must be changed using {@link #setBehaviour(CategoryBehaviour)}.
* Caller must also {@link #updateEnabledCategories()}.
*/
- @NonNull
public CategoryBehaviour behaviour = CategoryBehaviour.IGNORE;
SegmentCategory(String keyValue, StringRef title, StringRef description,
@@ -247,7 +250,7 @@ public enum SegmentCategory {
}
}
- public void setBehaviour(@NonNull CategoryBehaviour behaviour) {
+ public void setBehaviour(CategoryBehaviour behaviour) {
this.behaviour = Objects.requireNonNull(behaviour);
this.behaviorSetting.save(behaviour.reVancedKeyValue);
}
@@ -273,6 +276,10 @@ public enum SegmentCategory {
return opacitySetting.get();
}
+ public float getOpacityDefault() {
+ return opacitySetting.defaultValue;
+ }
+
public void resetColorAndOpacity() {
setColor(colorSetting.defaultValue);
setOpacity(opacitySetting.defaultValue);
@@ -291,10 +298,19 @@ public enum SegmentCategory {
/**
* @return Integer color of #RRGGBB format.
*/
+ @ColorInt
public int getColorNoOpacity() {
return color & 0x00FFFFFF;
}
+ /**
+ * @return Integer color of #RRGGBB format.
+ */
+ @ColorInt
+ public int getColorNoOpacityDefault() {
+ return Color.parseColor(colorSetting.defaultValue) & 0x00FFFFFF;
+ }
+
/**
* @return Hex color string of #RRGGBB format with no opacity level.
*/
@@ -302,22 +318,27 @@ public enum SegmentCategory {
return String.format(Locale.US, "#%06X", getColorNoOpacity());
}
- private static SpannableString getCategoryColorDotSpan(String text, int color) {
- SpannableString dotSpan = new SpannableString('⬤' + text);
+ private static SpannableString getCategoryColorDotSpan(String text, @ColorInt int color) {
+ SpannableString dotSpan = new SpannableString(COLOR_DOT_STRING + text);
dotSpan.setSpan(new ForegroundColorSpan(color), 0, 1,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return dotSpan;
}
- public static SpannableString getCategoryColorDot(int color) {
- return getCategoryColorDotSpan("", color);
+ public static SpannableString getCategoryColorDot(@ColorInt int color) {
+ SpannableString dotSpan = new SpannableString(COLOR_DOT_STRING);
+ dotSpan.setSpan(new ForegroundColorSpan(color), 0, 1,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ dotSpan.setSpan(new RelativeSizeSpan(1.5f), 0, 1,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ return dotSpan;
}
public SpannableString getCategoryColorDot() {
return getCategoryColorDot(color);
}
- public SpannableString getTitleWithColorDot(int categoryColor) {
+ public SpannableString getTitleWithColorDot(@ColorInt int categoryColor) {
return getCategoryColorDotSpan(" " + title, categoryColor);
}
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategoryListPreference.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategoryListPreference.java
index 36204319c..8c2a98757 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategoryListPreference.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategoryListPreference.java
@@ -1,35 +1,46 @@
package app.revanced.extension.youtube.sponsorblock.objects;
import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.getResourceIdentifier;
+import static app.revanced.extension.shared.settings.preference.ColorPickerPreference.getColorString;
import static app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory.applyOpacityToColor;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.graphics.Color;
+import android.graphics.Typeface;
+import android.os.Bundle;
import android.preference.ListPreference;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
import android.widget.EditText;
import android.widget.GridLayout;
+import android.widget.LinearLayout;
import android.widget.TextView;
+import androidx.annotation.ColorInt;
+
import java.util.Locale;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.preference.ColorPickerPreference;
+import app.revanced.extension.shared.settings.preference.ColorPickerView;
@SuppressWarnings("deprecation")
public class SegmentCategoryListPreference extends ListPreference {
private final SegmentCategory category;
- private TextView colorDotView;
- private EditText colorEditText;
- private EditText opacityEditText;
+
/**
- * #RRGGBB
+ * RGB format (no alpha).
*/
+ @ColorInt
private int categoryColor;
/**
* [0, 1]
@@ -37,6 +48,11 @@ public class SegmentCategoryListPreference extends ListPreference {
private float categoryOpacity;
private int selectedDialogEntryIndex;
+ private TextView dialogColorDotView;
+ private EditText dialogColorEditText;
+ private EditText dialogOpacityEditText;
+ private ColorPickerView dialogColorPickerView;
+
public SegmentCategoryListPreference(Context context, SegmentCategory category) {
super(context);
this.category = Objects.requireNonNull(category);
@@ -67,8 +83,20 @@ public class SegmentCategoryListPreference extends ListPreference {
categoryOpacity = category.getOpacity();
Context context = builder.getContext();
+ LinearLayout mainLayout = new LinearLayout(context);
+ mainLayout.setOrientation(LinearLayout.VERTICAL);
+ mainLayout.setPadding(70, 0, 70, 0);
+
+ // Inflate the color picker view.
+ View colorPickerContainer = LayoutInflater.from(context)
+ .inflate(getResourceIdentifier("revanced_color_picker", "layout"), null);
+ dialogColorPickerView = colorPickerContainer.findViewById(
+ getResourceIdentifier("color_picker_view", "id"));
+ dialogColorPickerView.setColor(categoryColor);
+ mainLayout.addView(colorPickerContainer);
+
+ // Grid layout for color and opacity inputs.
GridLayout gridLayout = new GridLayout(context);
- gridLayout.setPadding(70, 0, 150, 0); // Padding for the entire layout.
gridLayout.setColumnCount(3);
gridLayout.setRowCount(2);
@@ -84,19 +112,22 @@ public class SegmentCategoryListPreference extends ListPreference {
gridParams.rowSpec = GridLayout.spec(0); // First row.
gridParams.columnSpec = GridLayout.spec(1); // Second column.
gridParams.setMargins(0, 0, 10, 0);
- colorDotView = new TextView(context);
- colorDotView.setLayoutParams(gridParams);
- gridLayout.addView(colorDotView);
+ dialogColorDotView = new TextView(context);
+ dialogColorDotView.setLayoutParams(gridParams);
+ gridLayout.addView(dialogColorDotView);
updateCategoryColorDot();
gridParams = new GridLayout.LayoutParams();
gridParams.rowSpec = GridLayout.spec(0); // First row.
gridParams.columnSpec = GridLayout.spec(2); // Third column.
- colorEditText = new EditText(context);
- colorEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS);
- colorEditText.setTextLocale(Locale.US);
- colorEditText.setText(category.getColorString());
- colorEditText.addTextChangedListener(new TextWatcher() {
+ dialogColorEditText = new EditText(context);
+ dialogColorEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
+ | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ dialogColorEditText.setAutofillHints((String) null);
+ dialogColorEditText.setTypeface(Typeface.MONOSPACE);
+ dialogColorEditText.setTextLocale(Locale.US);
+ dialogColorEditText.setText(getColorString(categoryColor));
+ dialogColorEditText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@@ -109,28 +140,30 @@ public class SegmentCategoryListPreference extends ListPreference {
public void afterTextChanged(Editable edit) {
try {
String colorString = edit.toString();
- final int colorStringLength = colorString.length();
+ String normalizedColorString = ColorPickerPreference.cleanupColorCodeString(colorString);
- if (!colorString.startsWith("#")) {
- edit.insert(0, "#"); // Recursively calls back into this method.
+ if (!normalizedColorString.equals(colorString)) {
+ edit.replace(0, colorString.length(), normalizedColorString);
return;
}
- final int maxColorStringLength = 7; // #RRGGBB
- if (colorStringLength > maxColorStringLength) {
- edit.delete(maxColorStringLength, colorStringLength);
+ if (normalizedColorString.length() != ColorPickerPreference.COLOR_STRING_LENGTH) {
+ // User is still typing out the color.
return;
}
- categoryColor = Color.parseColor(colorString);
- updateCategoryColorDot();
- } catch (IllegalArgumentException ex) {
- // Ignore.
+ // Remove the alpha channel.
+ final int newColor = Color.parseColor(colorString) & 0x00FFFFFF;
+ // Changing view color causes callback into this class.
+ dialogColorPickerView.setColor(newColor);
+ } catch (Exception ex) {
+ // Should never be reached since input is validated before using.
+ Logger.printException(() -> "colorEditText afterTextChanged failure", ex);
}
}
});
- colorEditText.setLayoutParams(gridParams);
- gridLayout.addView(colorEditText);
+ dialogColorEditText.setLayoutParams(gridParams);
+ gridLayout.addView(dialogColorEditText);
gridParams = new GridLayout.LayoutParams();
gridParams.rowSpec = GridLayout.spec(1); // Second row.
@@ -143,9 +176,13 @@ public class SegmentCategoryListPreference extends ListPreference {
gridParams = new GridLayout.LayoutParams();
gridParams.rowSpec = GridLayout.spec(1); // Second row.
gridParams.columnSpec = GridLayout.spec(2); // Third column.
- opacityEditText = new EditText(context);
- opacityEditText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL);
- opacityEditText.addTextChangedListener(new TextWatcher() {
+ dialogOpacityEditText = new EditText(context);
+ dialogOpacityEditText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL
+ | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ dialogOpacityEditText.setAutofillHints((String) null);
+ dialogOpacityEditText.setTypeface(Typeface.MONOSPACE);
+ dialogOpacityEditText.setTextLocale(Locale.US);
+ dialogOpacityEditText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@@ -183,31 +220,40 @@ public class SegmentCategoryListPreference extends ListPreference {
}
updateCategoryColorDot();
- } catch (NumberFormatException ex) {
+ } catch (Exception ex) {
// Should never happen.
- Logger.printException(() -> "Could not parse opacity string", ex);
+ Logger.printException(() -> "opacityEditText afterTextChanged failure", ex);
}
}
});
- opacityEditText.setLayoutParams(gridParams);
- gridLayout.addView(opacityEditText);
+ dialogOpacityEditText.setLayoutParams(gridParams);
+ gridLayout.addView(dialogOpacityEditText);
updateOpacityText();
- builder.setView(gridLayout);
+ mainLayout.addView(gridLayout);
+
+ // Set up color picker listener.
+ // Do last to prevent listener callbacks while setting up view.
+ dialogColorPickerView.setOnColorChangedListener(color -> {
+ if (categoryColor == color) {
+ return;
+ }
+ categoryColor = color;
+ String hexColor = getColorString(color);
+ Logger.printDebug(() -> "onColorChanged: " + hexColor);
+
+ updateCategoryColorDot();
+ dialogColorEditText.setText(hexColor);
+ dialogColorEditText.setSelection(hexColor.length());
+ });
+
+ builder.setView(mainLayout);
builder.setTitle(category.title.toString());
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
onClick(dialog, DialogInterface.BUTTON_POSITIVE);
});
- builder.setNeutralButton(str("revanced_sb_reset_color"), (dialog, which) -> {
- try {
- category.resetColorAndOpacity();
- updateUI();
- Utils.showToastShort(str("revanced_sb_color_reset"));
- } catch (Exception ex) {
- Logger.printException(() -> "setNeutralButton failure", ex);
- }
- });
+ builder.setNeutralButton(str("revanced_settings_reset_color"), null);
builder.setNegativeButton(android.R.string.cancel, null);
selectedDialogEntryIndex = findIndexOfValue(getValue());
@@ -218,6 +264,25 @@ public class SegmentCategoryListPreference extends ListPreference {
}
}
+ @Override
+ protected void showDialog(Bundle state) {
+ super.showDialog(state);
+
+ // Do not close dialog when reset is pressed.
+ Button button = ((AlertDialog) getDialog()).getButton(AlertDialog.BUTTON_NEUTRAL);
+ button.setOnClickListener(view -> {
+ try {
+ // Setting view color causes callback to update the UI.
+ dialogColorPickerView.setColor(category.getColorNoOpacityDefault());
+
+ categoryOpacity = category.getOpacityDefault();
+ updateOpacityText();
+ } catch (Exception ex) {
+ Logger.printException(() -> "setOnClickListener failure", ex);
+ }
+ });
+ }
+
@Override
protected void onDialogClosed(boolean positiveResult) {
try {
@@ -230,43 +295,42 @@ public class SegmentCategoryListPreference extends ListPreference {
}
try {
- String colorString = colorEditText.getText().toString();
- if (!colorString.equals(category.getColorString()) || categoryOpacity != category.getOpacity()) {
- category.setColor(colorString);
- category.setOpacity(categoryOpacity);
- Utils.showToastShort(str("revanced_sb_color_changed"));
- }
+ category.setColor(dialogColorEditText.getText().toString());
+ category.setOpacity(categoryOpacity);
} catch (IllegalArgumentException ex) {
- Utils.showToastShort(str("revanced_sb_color_invalid"));
+ Utils.showToastShort(str("revanced_settings_color_invalid"));
}
updateUI();
}
} catch (Exception ex) {
Logger.printException(() -> "onDialogClosed failure", ex);
+ } finally {
+ dialogColorDotView = null;
+ dialogColorEditText = null;
+ dialogOpacityEditText = null;
+ dialogColorPickerView = null;
}
}
- private void applyOpacityToCategoryColor() {
- categoryColor = applyOpacityToColor(categoryColor, categoryOpacity);
+ @ColorInt
+ private int applyOpacityToCategoryColor() {
+ return applyOpacityToColor(categoryColor, categoryOpacity);
}
public void updateUI() {
categoryColor = category.getColorNoOpacity();
categoryOpacity = category.getOpacity();
- applyOpacityToCategoryColor();
- setTitle(category.getTitleWithColorDot(categoryColor));
+ setTitle(category.getTitleWithColorDot(applyOpacityToCategoryColor()));
}
private void updateCategoryColorDot() {
- applyOpacityToCategoryColor();
-
- colorDotView.setText(SegmentCategory.getCategoryColorDot(categoryColor));
+ dialogColorDotView.setText(SegmentCategory.getCategoryColorDot(applyOpacityToCategoryColor()));
}
private void updateOpacityText() {
- opacityEditText.setText(String.format(Locale.US, "%.2f", categoryOpacity));
+ dialogOpacityEditText.setText(String.format(Locale.US, "%.2f", categoryOpacity));
}
@Override
@@ -277,4 +341,4 @@ public class SegmentCategoryListPreference extends ListPreference {
// This is required otherwise the ReVanced preference fragment
// sets all ListPreference summaries to show the current selection.
}
-}
\ No newline at end of file
+}
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockPreferenceGroup.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockPreferenceGroup.java
index 4ed3bf238..1cf2e0444 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockPreferenceGroup.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/SponsorBlockPreferenceGroup.java
@@ -421,7 +421,7 @@ public class SponsorBlockPreferenceGroup extends PreferenceGroup {
.setTitle(apiUrl.getTitle())
.setView(editText)
.setNegativeButton(android.R.string.cancel, null)
- .setNeutralButton(str("revanced_sb_reset"), urlChangeListener)
+ .setNeutralButton(str("revanced_settings_reset"), urlChangeListener)
.setPositiveButton(android.R.string.ok, urlChangeListener)
.show();
return true;
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsPatch.kt
index 2c55658fb..6c14a93b6 100644
--- a/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsPatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/interaction/swipecontrols/SwipeControlsPatch.kt
@@ -48,7 +48,9 @@ private val swipeControlsResourcePatch = resourcePatch {
summaryKey = null,
),
TextPreference("revanced_swipe_overlay_background_opacity", inputType = InputType.NUMBER),
- TextPreference("revanced_swipe_overlay_progress_color", inputType = InputType.TEXT_CAP_CHARACTERS),
+ TextPreference("revanced_swipe_overlay_progress_color",
+ tag = "app.revanced.extension.shared.settings.preference.ColorPickerPreference",
+ inputType = InputType.TEXT_CAP_CHARACTERS),
TextPreference("revanced_swipe_text_overlay_size", inputType = InputType.NUMBER),
TextPreference("revanced_swipe_overlay_timeout", inputType = InputType.NUMBER),
TextPreference("revanced_swipe_threshold", inputType = InputType.NUMBER),
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/ThemePatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/ThemePatch.kt
index 73e84d659..514eae710 100644
--- a/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/ThemePatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/layout/theme/ThemePatch.kt
@@ -89,14 +89,15 @@ val themePatch = bytecodePatch(
execute {
val preferences = mutableSetOf
- * com.company.SomethingView
- * com.company.SomethingView$StaticClass
- * com.company.SomethingView$1
- *
- */
- private String findOuterClassSimpleName() {
- var selfClass = this.getClass();
+ private enum LogLevel {
+ DEBUG,
+ INFO,
+ ERROR
+ }
- String fullClassName = selfClass.getName();
- final int dollarSignIndex = fullClassName.indexOf('$');
- if (dollarSignIndex < 0) {
- return selfClass.getSimpleName(); // Already an outer class.
+ private static final String REVANCED_LOG_TAG = "revanced";
+
+ private static final String LOGGER_CLASS_NAME = Logger.class.getName();
+
+ /**
+ * @return For outer classes, this returns {@link Class#getSimpleName()}.
+ * For static, inner, or anonymous classes, this returns the simple name of the enclosing class.
+ *
+ * com.company.SomethingView
+ * com.company.SomethingView$StaticClass
+ * com.company.SomethingView$1
+ *
+ */
+ private static String getOuterClassSimpleName(Object obj) {
+ Class> logClass = obj.getClass();
+ String fullClassName = logClass.getName();
+ final int dollarSignIndex = fullClassName.indexOf('$');
+ if (dollarSignIndex < 0) {
+ return logClass.getSimpleName(); // Already an outer class.
+ }
+
+ // Class is inner, static, or anonymous.
+ // Parse the simple name full name.
+ // A class with no package returns index of -1, but incrementing gives index zero which is correct.
+ final int simpleClassNameStartIndex = fullClassName.lastIndexOf('.') + 1;
+ return fullClassName.substring(simpleClassNameStartIndex, dollarSignIndex);
+ }
+
+ /**
+ * Internal method to handle logging to Android Log and {@link LogBufferManager}.
+ * Appends the log message, stack trace (if enabled), and exception (if present) to logBuffer
+ * with class name but without 'revanced:' prefix.
+ *
+ * @param logLevel The log level.
+ * @param message Log message object.
+ * @param ex Optional exception.
+ * @param includeStackTrace If the current stack should be included.
+ * @param showToast If a toast is to be shown.
+ */
+ private static void logInternal(LogLevel logLevel, LogMessage message, @Nullable Throwable ex,
+ boolean includeStackTrace, boolean showToast) {
+ // It's very important that no Settings are used in this method,
+ // as this code is used when a context is not set and thus referencing
+ // a setting will crash the app.
+ String messageString = message.buildMessageString();
+ String className = getOuterClassSimpleName(message);
+
+ StringBuilder logBuilder = new StringBuilder(className.length() + 2
+ + messageString.length());
+ logBuilder.append(className).append(": ").append(messageString);
+
+ String toastMessage = showToast ? logBuilder.toString() : null;
+
+ // Append exception message if present.
+ if (ex != null) {
+ var exceptionMessage = ex.getMessage();
+ if (exceptionMessage != null) {
+ logBuilder.append("\nException: ").append(exceptionMessage);
}
+ }
- // Class is inner, static, or anonymous.
- // Parse the simple name full name.
- // A class with no package returns index of -1, but incrementing gives index zero which is correct.
- final int simpleClassNameStartIndex = fullClassName.lastIndexOf('.') + 1;
- return fullClassName.substring(simpleClassNameStartIndex, dollarSignIndex);
+ if (includeStackTrace) {
+ var sw = new StringWriter();
+ new Throwable().printStackTrace(new PrintWriter(sw));
+ String stackTrace = sw.toString();
+ // Remove the stacktrace elements of this class.
+ final int loggerIndex = stackTrace.lastIndexOf(LOGGER_CLASS_NAME);
+ final int loggerBegins = stackTrace.indexOf('\n', loggerIndex);
+ logBuilder.append(stackTrace, loggerBegins, stackTrace.length());
+ }
+
+ String logText = logBuilder.toString();
+ LogBufferManager.appendToLogBuffer(logText);
+
+ switch (logLevel) {
+ case DEBUG:
+ if (ex == null) Log.d(REVANCED_LOG_TAG, logText);
+ else Log.d(REVANCED_LOG_TAG, logText, ex);
+ break;
+ case INFO:
+ if (ex == null) Log.i(REVANCED_LOG_TAG, logText);
+ else Log.i(REVANCED_LOG_TAG, logText, ex);
+ break;
+ case ERROR:
+ if (ex == null) Log.e(REVANCED_LOG_TAG, logText);
+ else Log.e(REVANCED_LOG_TAG, logText, ex);
+ break;
+ }
+
+ if (toastMessage != null) {
+ Utils.showToastLong(toastMessage);
}
}
- private static final String REVANCED_LOG_PREFIX = "revanced: ";
-
/**
* Logs debug messages under the outer class name of the code calling this method.
- * Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()}
- * so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
+ * + * Whenever possible, the log string should be constructed entirely inside + * {@link LogMessage#buildMessageString()} so the performance cost of + * building strings is paid only if {@link BaseSettings#DEBUG} is enabled. */ - public static void printDebug(@NonNull LogMessage message) { + public static void printDebug(LogMessage message) { printDebug(message, null); } /** * Logs debug messages under the outer class name of the code calling this method. - * Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()} - * so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled. + *
+ * Whenever possible, the log string should be constructed entirely inside
+ * {@link LogMessage#buildMessageString()} so the performance cost of
+ * building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
*/
- public static void printDebug(@NonNull LogMessage message, @Nullable Exception ex) {
+ public static void printDebug(LogMessage message, @Nullable Exception ex) {
if (DEBUG.get()) {
- String logMessage = message.buildMessageString();
- String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
-
- if (DEBUG_STACKTRACE.get()) {
- var builder = new StringBuilder(logMessage);
- var sw = new StringWriter();
- new Throwable().printStackTrace(new PrintWriter(sw));
-
- builder.append('\n').append(sw);
- logMessage = builder.toString();
- }
-
- if (ex == null) {
- Log.d(logTag, logMessage);
- } else {
- Log.d(logTag, logMessage, ex);
- }
+ logInternal(LogLevel.DEBUG, message, ex, DEBUG_STACKTRACE.get(), false);
}
}
/**
* Logs information messages using the outer class name of the code calling this method.
*/
- public static void printInfo(@NonNull LogMessage message) {
+ public static void printInfo(LogMessage message) {
printInfo(message, null);
}
/**
* Logs information messages using the outer class name of the code calling this method.
*/
- public static void printInfo(@NonNull LogMessage message, @Nullable Exception ex) {
- String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
- String logMessage = message.buildMessageString();
- if (ex == null) {
- Log.i(logTag, logMessage);
- } else {
- Log.i(logTag, logMessage, ex);
- }
+ public static void printInfo(LogMessage message, @Nullable Exception ex) {
+ logInternal(LogLevel.INFO, message, ex, DEBUG_STACKTRACE.get(), false);
}
/**
* Logs exceptions under the outer class name of the code calling this method.
+ * Appends the log message, exception (if present), and toast message (if enabled) to logBuffer.
*/
- public static void printException(@NonNull LogMessage message) {
+ public static void printException(LogMessage message) {
printException(message, null);
}
@@ -122,35 +192,23 @@ public class Logger {
* @param message log message
* @param ex exception (optional)
*/
- public static void printException(@NonNull LogMessage message, @Nullable Throwable ex) {
- String messageString = message.buildMessageString();
- String outerClassSimpleName = message.findOuterClassSimpleName();
- String logMessage = REVANCED_LOG_PREFIX + outerClassSimpleName;
- if (ex == null) {
- Log.e(logMessage, messageString);
- } else {
- Log.e(logMessage, messageString, ex);
- }
- if (DEBUG_TOAST_ON_ERROR.get()) {
- Utils.showToastLong(outerClassSimpleName + ": " + messageString);
- }
+ public static void printException(LogMessage message, @Nullable Throwable ex) {
+ logInternal(LogLevel.ERROR, message, ex, DEBUG_STACKTRACE.get(), DEBUG_TOAST_ON_ERROR.get());
}
/**
* Logging to use if {@link BaseSettings#DEBUG} or {@link Utils#getContext()} may not be initialized.
* Normally this method should not be used.
*/
- public static void initializationInfo(@NonNull Class> callingClass, @NonNull String message) {
- Log.i(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message);
+ public static void initializationInfo(LogMessage message) {
+ logInternal(LogLevel.INFO, message, null, false, false);
}
/**
* Logging to use if {@link BaseSettings#DEBUG} or {@link Utils#getContext()} may not be initialized.
* Normally this method should not be used.
*/
- public static void initializationException(@NonNull Class> callingClass, @NonNull String message,
- @Nullable Exception ex) {
- Log.e(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message, ex);
+ public static void initializationException(LogMessage message, @Nullable Exception ex) {
+ logInternal(LogLevel.ERROR, message, ex, false, false);
}
-
-}
\ No newline at end of file
+}
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
index dead738b4..111a0dd77 100644
--- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
@@ -363,15 +363,17 @@ public class Utils {
public static Context getContext() {
if (context == null) {
- Logger.initializationException(Utils.class, "Context is not set by extension hook, returning null", null);
+ Logger.initializationException(() -> "Context is not set by extension hook, returning null", null);
}
return context;
}
public static void setContext(Context appContext) {
+ // Intentionally use logger before context is set,
+ // to expose any bugs in the 'no context available' logger method.
+ Logger.initializationInfo(() -> "Set context: " + appContext);
// Must initially set context to check the app language.
context = appContext;
- Logger.initializationInfo(Utils.class, "Set context: " + appContext);
AppLanguage language = BaseSettings.REVANCED_LANGUAGE.get();
if (language != AppLanguage.DEFAULT) {
@@ -383,8 +385,9 @@ public class Utils {
}
}
- public static void setClipboard(@NonNull String text) {
- android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ public static void setClipboard(CharSequence text) {
+ android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context
+ .getSystemService(Context.CLIPBOARD_SERVICE);
android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text);
clipboard.setPrimaryClip(clip);
}
@@ -548,14 +551,15 @@ public class Utils {
private static void showToast(@NonNull String messageToToast, int toastDuration) {
Objects.requireNonNull(messageToToast);
runOnMainThreadNowOrLater(() -> {
- if (context == null) {
- Logger.initializationException(Utils.class, "Cannot show toast (context is null): " + messageToToast, null);
- } else {
- Logger.printDebug(() -> "Showing toast: " + messageToToast);
- Toast.makeText(context, messageToToast, toastDuration).show();
- }
- }
- );
+ Context currentContext = context;
+
+ if (currentContext == null) {
+ Logger.initializationException(() -> "Cannot show toast (context is null): " + messageToToast, null);
+ } else {
+ Logger.printDebug(() -> "Showing toast: " + messageToToast);
+ Toast.makeText(currentContext, messageToToast, toastDuration).show();
+ }
+ });
}
public static boolean isDarkModeEnabled() {
@@ -579,7 +583,7 @@ public class Utils {
}
/**
- * Automatically logs any exceptions the runnable throws
+ * Automatically logs any exceptions the runnable throws.
*/
public static void runOnMainThreadDelayed(@NonNull Runnable runnable, long delayMillis) {
Runnable loggingRunnable = () -> {
@@ -605,14 +609,14 @@ public class Utils {
}
/**
- * @return if the calling thread is on the main thread
+ * @return if the calling thread is on the main thread.
*/
public static boolean isCurrentlyOnMainThread() {
return Looper.getMainLooper().isCurrentThread();
}
/**
- * @throws IllegalStateException if the calling thread is _off_ the main thread
+ * @throws IllegalStateException if the calling thread is _off_ the main thread.
*/
public static void verifyOnMainThread() throws IllegalStateException {
if (!isCurrentlyOnMainThread()) {
@@ -621,7 +625,7 @@ public class Utils {
}
/**
- * @throws IllegalStateException if the calling thread is _on_ the main thread
+ * @throws IllegalStateException if the calling thread is _on_ the main thread.
*/
public static void verifyOffMainThread() throws IllegalStateException {
if (isCurrentlyOnMainThread()) {
@@ -635,6 +639,11 @@ public class Utils {
OTHER,
}
+ /**
+ * Calling extension code must ensure the un-patched app has the permission
+ * android.permission.ACCESS_NETWORK_STATE
, otherwise the app will crash
+ * if this method is used.
+ */
public static boolean isNetworkConnected() {
NetworkType networkType = getNetworkType();
return networkType == NetworkType.MOBILE
@@ -642,10 +651,11 @@ public class Utils {
}
/**
- * Calling extension code must ensure the target app has the
- * ACCESS_NETWORK_STATE
app manifest permission.
+ * Calling extension code must ensure the un-patched app has the permission
+ * android.permission.ACCESS_NETWORK_STATE
, otherwise the app will crash
+ * if this method is used.
*/
- @SuppressWarnings({"deprecation", "MissingPermission"})
+ @SuppressLint({"MissingPermission", "deprecation"})
public static NetworkType getNetworkType() {
Context networkContext = getContext();
if (networkContext == null) {
@@ -782,8 +792,9 @@ public class Utils {
preferences.add(new Pair<>(sortValue, preference));
}
+ //noinspection ComparatorCombinators
Collections.sort(preferences, (pair1, pair2)
- -> pair1.first.compareToIgnoreCase(pair2.first));
+ -> pair1.first.compareTo(pair2.first));
int index = 0;
for (Pair