diff --git a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java index 22ce1dcf..8cd60615 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java @@ -2,6 +2,8 @@ package app.revanced.integrations.returnyoutubedislike; import android.content.Context; import android.icu.text.CompactDecimalFormat; +import android.icu.text.DecimalFormat; +import android.icu.text.DecimalFormatSymbols; import android.os.Build; import android.text.SpannableString; @@ -17,6 +19,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; +import app.revanced.integrations.returnyoutubedislike.requests.RYDVoteData; import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.utils.LogHelper; @@ -41,7 +44,7 @@ public class ReturnYouTubeDislike { private static volatile boolean isEnabled = SettingsEnum.RYD_ENABLED.getBoolean(); /** - * Used to guard {@link #currentVideoId} and {@link #dislikeFetchFuture}, + * Used to guard {@link #currentVideoId} and {@link #voteFetchFuture}, * as multiple threads access this class. */ private static final Object videoIdLockObject = new Object(); @@ -50,10 +53,10 @@ public class ReturnYouTubeDislike { private static String currentVideoId; /** - * Stores the results of the dislike fetch, and used as a barrier to wait until fetch completes + * Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes */ @GuardedBy("videoIdLockObject") - private static Future dislikeFetchFuture; + private static Future voteFetchFuture; public enum Vote { LIKE(1), @@ -73,8 +76,14 @@ public class ReturnYouTubeDislike { /** * Used to format like/dislike count. */ - @GuardedBy("ReturnYouTubeDislike.class") // number formatter is not thread safe - private static CompactDecimalFormat compactNumberFormatter; + @GuardedBy("ReturnYouTubeDislike.class") // not thread safe + private static CompactDecimalFormat dislikeCountFormatter; + + /** + * Used to format like/dislike count. + */ + @GuardedBy("ReturnYouTubeDislike.class") // not thread safe + private static DecimalFormat dislikePercentageFormatter; public static void onEnabledChange(boolean enabled) { isEnabled = enabled; @@ -86,9 +95,9 @@ public class ReturnYouTubeDislike { } } - private static Future getDislikeFetchFuture() { + private static Future getVoteFetchFuture() { synchronized (videoIdLockObject) { - return dislikeFetchFuture; + return voteFetchFuture; } } @@ -104,7 +113,7 @@ public class ReturnYouTubeDislike { currentVideoId = videoId; // no need to wrap the fetchDislike call in a try/catch, // as any exceptions are propagated out in the later Future#Get call - dislikeFetchFuture = ReVancedUtils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchDislikes(videoId)); + voteFetchFuture = ReVancedUtils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId)); } } catch (Exception ex) { LogHelper.printException(() -> "Failed to load new video: " + videoId, ex); @@ -128,19 +137,19 @@ public class ReturnYouTubeDislike { // Have to block the current thread until fetching is done // There's no known way to edit the text after creation yet - Integer dislikeCount; + RYDVoteData votingData; try { - dislikeCount = getDislikeFetchFuture().get(MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE, TimeUnit.MILLISECONDS); + votingData = getVoteFetchFuture().get(MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { LogHelper.printDebug(() -> "UI timed out waiting for dislike fetch to complete"); return; } - if (dislikeCount == null) { - LogHelper.printDebug(() -> "Cannot add dislike count to UI (dislike count not available)"); + if (votingData == null) { + LogHelper.printDebug(() -> "Cannot add dislike count to UI (RYD data not available)"); return; } - updateDislike(textRef, isSegmentedButton, dislikeCount); + updateDislike(textRef, isSegmentedButton, votingData); LogHelper.printDebug(() -> "Updated text on component: " + conversionContextString); } catch (Exception ex) { LogHelper.printException(() -> "Error while trying to update dislikes text", ex); @@ -197,9 +206,11 @@ public class ReturnYouTubeDislike { return userId; } - private static void updateDislike(AtomicReference textRef, boolean isSegmentedButton, int dislikeCount) { + private static void updateDislike(AtomicReference textRef, boolean isSegmentedButton, RYDVoteData voteData) { SpannableString oldSpannableString = (SpannableString) textRef.get(); - String newDislikeString = formatDislikeCount(dislikeCount); + String newDislikeString = SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getBoolean() + ? formatDislikePercentage(voteData.dislikePercentage) + : formatDislikeCount(voteData.dislikeCount); if (isSegmentedButton) { // both likes and dislikes are on a custom segmented button // parse out the like count as a string @@ -239,16 +250,16 @@ public class ReturnYouTubeDislike { textRef.set(newSpannableString); } - private static String formatDislikeCount(int dislikeCount) { + private static String formatDislikeCount(long dislikeCount) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { String formatted; synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize - if (compactNumberFormatter == null) { + if (dislikeCountFormatter == null) { Locale locale = ReVancedUtils.getContext().getResources().getConfiguration().locale; LogHelper.printDebug(() -> "Locale: " + locale); - compactNumberFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); + dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); } - formatted = compactNumberFormatter.format(dislikeCount); + formatted = dislikeCountFormatter.format(dislikeCount); } LogHelper.printDebug(() -> "Dislike count: " + dislikeCount + " formatted as: " + formatted); return formatted; @@ -257,4 +268,29 @@ public class ReturnYouTubeDislike { // never will be reached, as the oldest supported YouTube app requires Android N or greater return String.valueOf(dislikeCount); } + + private static String formatDislikePercentage(float dislikePercentage) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + String formatted; + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikePercentageFormatter == null) { + Locale locale = ReVancedUtils.getContext().getResources().getConfiguration().locale; + LogHelper.printDebug(() -> "Locale: " + locale); + dislikePercentageFormatter = new DecimalFormat("", new DecimalFormatSymbols(locale)); + } + if (dislikePercentage == 0 || dislikePercentage >= 0.01) { // zero, or at least 1% + dislikePercentageFormatter.applyLocalizedPattern("0"); // show only whole percentage points + } else { // between (0, 1)% + dislikePercentageFormatter.applyLocalizedPattern("0.#"); // show 1 digit precision + } + final char percentChar = dislikePercentageFormatter.getDecimalFormatSymbols().getPercent(); + formatted = dislikePercentageFormatter.format(100 * dislikePercentage) + percentChar; + } + LogHelper.printDebug(() -> "Dislike percentage: " + dislikePercentage + " formatted as: " + formatted); + return formatted; + } + + // never will be reached, as the oldest supported YouTube app requires Android N or greater + return (int) (100 * dislikePercentage) + "%"; + } } diff --git a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/RYDVoteData.java b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/RYDVoteData.java new file mode 100644 index 00000000..faaf85a4 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/RYDVoteData.java @@ -0,0 +1,76 @@ +package app.revanced.integrations.returnyoutubedislike.requests; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Objects; + +/** + * ReturnYouTubeDislike API estimated like/dislike/view counts. + * + * ReturnYouTubeDislike does not guarantee when the counts are updated. + * So these values may lag behind what YouTube shows. + */ +public final class RYDVoteData { + + public final String videoId; + + /** + * Estimated number of views + */ + public final long viewCount; + + /** + * Estimated like count + */ + public final long likeCount; + + /** + * Estimated dislike count + */ + public final long dislikeCount; + + /** + * Estimated percentage of likes for all votes. Value has range of [0, 1] + * + * A video with 400 positive votes, and 100 negative votes, has a likePercentage of 0.8 + */ + public final float likePercentage; + + /** + * Estimated percentage of dislikes for all votes. Value has range of [0, 1] + * + * A video with 400 positive votes, and 100 negative votes, has a dislikePercentage of 0.2 + */ + public final float dislikePercentage; + + /** + * @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values) + */ + public RYDVoteData(JSONObject json) throws JSONException { + Objects.requireNonNull(json); + videoId = json.getString("id"); + viewCount = json.getLong("viewCount"); + likeCount = json.getLong("likes"); + dislikeCount = json.getLong("dislikes"); + if (likeCount < 0 || dislikeCount < 0 || viewCount < 0) { + throw new JSONException("Unexpected JSON values: " + json); + } + likePercentage = (likeCount == 0 ? 0 : (float)likeCount / (likeCount + dislikeCount)); + dislikePercentage = (dislikeCount == 0 ? 0 : (float)dislikeCount / (likeCount + dislikeCount)); + } + + @Override + public String toString() { + return "RYDVoteData{" + + "videoId=" + videoId + + ", viewCount=" + viewCount + + ", likeCount=" + likeCount + + ", dislikeCount=" + dislikeCount + + ", likePercentage=" + likePercentage + + ", dislikePercentage=" + dislikePercentage + + '}'; + } + + // equals and hashcode is not implemented (currently not needed) +} diff --git a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java index 6984cd2a..e85f3c47 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java @@ -4,6 +4,7 @@ import android.util.Base64; import androidx.annotation.Nullable; +import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; @@ -26,7 +27,7 @@ public class ReturnYouTubeDislikeApi { private static final String RYD_API_URL = "https://returnyoutubedislikeapi.com/"; /** - * Default connection and response timeout for {@link #fetchDislikes(String)} + * Default connection and response timeout for {@link #fetchVotes(String)} */ private static final int API_GET_DISLIKE_DEFAULT_TIMEOUT_MILLISECONDS = 5000; @@ -128,11 +129,10 @@ public class ReturnYouTubeDislikeApi { } /** - * @return The number of dislikes. - * Returns NULL if fetch failed, or a rate limit is in effect. + * @return NULL if fetch failed, or if a rate limit is in effect. */ @Nullable - public static Integer fetchDislikes(String videoId) { + public static RYDVoteData fetchVotes(String videoId) { ReVancedUtils.verifyOffMainThread(); Objects.requireNonNull(videoId); try { @@ -161,10 +161,14 @@ public class ReturnYouTubeDislikeApi { } if (responseCode == SUCCESS_HTTP_STATUS_CODE) { JSONObject json = Requester.getJSONObject(connection); // also disconnects - Integer fetchedDislikeCount = json.getInt("dislikes"); - LogHelper.printDebug(() -> "Fetched video: " + videoId - + " dislikes: " + fetchedDislikeCount); - return fetchedDislikeCount; + try { + RYDVoteData votingData = new RYDVoteData(json); + LogHelper.printDebug(() -> "Voting data fetched: " + votingData); + return votingData; + } catch (JSONException ex) { + LogHelper.printException(() -> "Failed to parse video: " + videoId + " json: " + json, ex); + return null; + } } LogHelper.printDebug(() -> "Failed to fetch dislikes for video: " + videoId + " response code was: " + responseCode); diff --git a/app/src/main/java/app/revanced/integrations/settings/SettingsEnum.java b/app/src/main/java/app/revanced/integrations/settings/SettingsEnum.java index 2c574892..476bc90e 100644 --- a/app/src/main/java/app/revanced/integrations/settings/SettingsEnum.java +++ b/app/src/main/java/app/revanced/integrations/settings/SettingsEnum.java @@ -115,6 +115,7 @@ public enum SettingsEnum { // RYD settings RYD_USER_ID("ryd_userId", null, SharedPrefHelper.SharedPrefNames.RYD, ReturnType.STRING), RYD_ENABLED("ryd_enabled", true, SharedPrefHelper.SharedPrefNames.RYD, ReturnType.BOOLEAN), + RYD_SHOW_DISLIKE_PERCENTAGE("ryd_show_dislike_percentage", false, SharedPrefHelper.SharedPrefNames.RYD, ReturnType.BOOLEAN), // SponsorBlock settings SB_ENABLED("sb-enabled", true, SharedPrefHelper.SharedPrefNames.SPONSOR_BLOCK, ReturnType.BOOLEAN), diff --git a/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java b/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java index 79782c7d..435b555d 100644 --- a/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java +++ b/app/src/main/java/app/revanced/integrations/settingsmenu/ReturnYouTubeDislikeSettingsFragment.java @@ -3,7 +3,6 @@ package app.revanced.integrations.settingsmenu; import static app.revanced.integrations.sponsorblock.StringRef.str; import android.app.Activity; -import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Bundle; @@ -18,65 +17,89 @@ import app.revanced.integrations.settings.SettingsEnum; import app.revanced.integrations.utils.SharedPrefHelper; public class ReturnYouTubeDislikeSettingsFragment extends PreferenceFragment { + + /** + * If ReturnYouTubeDislike is enabled + */ + private SwitchPreference enabledPreference; + + /** + * If dislikes are shown as percentage + */ + private SwitchPreference percentagePreference; + + private void updateUIState() { + final boolean rydIsEnabled = SettingsEnum.RYD_ENABLED.getBoolean(); + final boolean dislikePercentageEnabled = SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getBoolean(); + + enabledPreference.setSummary(rydIsEnabled + ? str("revanced_ryd_enable_summary_on") + : str("revanced_ryd_enable_summary_off")); + + percentagePreference.setSummary(dislikePercentageEnabled + ? str("revanced_ryd_dislike_percentage_summary_on") + : str("revanced_ryd_dislike_percentage_summary_off")); + percentagePreference.setEnabled(rydIsEnabled); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); getPreferenceManager().setSharedPreferencesName(SharedPrefHelper.SharedPrefNames.RYD.getName()); - final Activity context = this.getActivity(); - + Activity context = this.getActivity(); PreferenceScreen preferenceScreen = getPreferenceManager().createPreferenceScreen(context); setPreferenceScreen(preferenceScreen); - // RYD enable toggle - { - SwitchPreference preference = new SwitchPreference(context); - preferenceScreen.addPreference(preference); - preference.setKey(SettingsEnum.RYD_ENABLED.getPath()); - preference.setDefaultValue(SettingsEnum.RYD_ENABLED.getDefaultValue()); - preference.setChecked(SettingsEnum.RYD_ENABLED.getBoolean()); - preference.setTitle(str("revanced_ryd_title")); - preference.setSummary(str("revanced_ryd_summary")); - preference.setOnPreferenceChangeListener((pref, newValue) -> { - final boolean value = (Boolean) newValue; - ReturnYouTubeDislike.onEnabledChange(value); - SettingsEnum.RYD_ENABLED.saveValue(value); - return true; - }); - } + enabledPreference = new SwitchPreference(context); + enabledPreference.setKey(SettingsEnum.RYD_ENABLED.getPath()); + enabledPreference.setDefaultValue(SettingsEnum.RYD_ENABLED.getDefaultValue()); + enabledPreference.setChecked(SettingsEnum.RYD_ENABLED.getBoolean()); + enabledPreference.setTitle(str("revanced_ryd_enable_title")); + enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> { + final boolean rydIsEnabled = (Boolean) newValue; + SettingsEnum.RYD_ENABLED.saveValue(rydIsEnabled); + ReturnYouTubeDislike.onEnabledChange(rydIsEnabled); + + updateUIState(); + return true; + }); + preferenceScreen.addPreference(enabledPreference); + + percentagePreference = new SwitchPreference(context); + percentagePreference.setKey(SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getPath()); + percentagePreference.setDefaultValue(SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getDefaultValue()); + percentagePreference.setChecked(SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.getBoolean()); + percentagePreference.setTitle(str("revanced_ryd_dislike_percentage_title")); + percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> { + SettingsEnum.RYD_SHOW_DISLIKE_PERCENTAGE.saveValue((Boolean)newValue); + + updateUIState(); + return true; + }); + preferenceScreen.addPreference(percentagePreference); + + updateUIState(); + // About category - addAboutCategory(context, preferenceScreen); + + PreferenceCategory aboutCategory = new PreferenceCategory(context); + aboutCategory.setTitle(str("about")); + preferenceScreen.addPreference(aboutCategory); + + // ReturnYouTubeDislike Website + + Preference aboutWebsitePreference = new Preference(context); + aboutWebsitePreference.setTitle(str("revanced_ryd_attribution_title")); + aboutWebsitePreference.setSummary(str("revanced_ryd_attribution_summary")); + aboutWebsitePreference.setOnPreferenceClickListener(pref -> { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse("https://returnyoutubedislike.com")); + pref.getContext().startActivity(i); + return false; + }); + preferenceScreen.addPreference(aboutWebsitePreference); } - private void addAboutCategory(Context context, PreferenceScreen screen) { - PreferenceCategory category = new PreferenceCategory(context); - screen.addPreference(category); - category.setTitle(str("about")); - - { - Preference preference = new Preference(context); - screen.addPreference(preference); - preference.setTitle(str("revanced_ryd_attribution_title")); - preference.setSummary(str("revanced_ryd_attribution_summary")); - preference.setOnPreferenceClickListener(pref -> { - Intent i = new Intent(Intent.ACTION_VIEW); - i.setData(Uri.parse("https://returnyoutubedislike.com")); - pref.getContext().startActivity(i); - return false; - }); - } - - { - Preference preference = new Preference(context); - screen.addPreference(preference); - preference.setTitle("GitHub"); - preference.setOnPreferenceClickListener(pref -> { - Intent i = new Intent(Intent.ACTION_VIEW); - i.setData(Uri.parse("https://github.com/Anarios/return-youtube-dislike")); - pref.getContext().startActivity(i); - return false; - }); - } - } }