diff --git a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/Registration.java b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/Registration.java deleted file mode 100644 index 7ad23176a9..0000000000 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/Registration.java +++ /dev/null @@ -1,103 +0,0 @@ -package app.revanced.integrations.returnyoutubedislike; - - -import android.util.Base64; - -import java.security.MessageDigest; -import java.security.SecureRandom; - -import app.revanced.integrations.settings.SettingsEnum; -import app.revanced.integrations.utils.LogHelper; -import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; - -public class Registration { - - // https://stackoverflow.com/a/157202 - private final String AB = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - private SecureRandom rnd = new SecureRandom(); - private String userId; - - public String getUserId() { - return userId != null ? userId : fetchUserId(); - } - - public void saveUserId(String userId) { - SettingsEnum.RYD_USER_ID.saveValue(userId); - } - - public static String solvePuzzle(String challenge, int difficulty) { - byte[] decodedChallenge = Base64.decode(challenge, Base64.NO_WRAP); - - byte[] buffer = new byte[20]; - for (int i = 4; i < 20; i++) { - buffer[i] = decodedChallenge[i - 4]; - } - - try { - int maxCount = (int) (Math.pow(2, difficulty + 1) * 5); - MessageDigest md = MessageDigest.getInstance("SHA-512"); - for (int i = 0; i < maxCount; i++) { - buffer[0] = (byte) i; - buffer[1] = (byte) (i >> 8); - buffer[2] = (byte) (i >> 16); - buffer[3] = (byte) (i >> 24); - byte[] messageDigest = md.digest(buffer); - - if (countLeadingZeroes(messageDigest) >= difficulty) { - String encode = Base64.encodeToString(new byte[]{buffer[0], buffer[1], buffer[2], buffer[3]}, Base64.NO_WRAP); - return encode; - } - } - } catch (Exception ex) { - LogHelper.printException(Registration.class, "Failed to solve puzzle", ex); - } - - return null; - } - - private String register() { - String userId = randomString(36); - LogHelper.debug(Registration.class, "Trying to register the following userId: " + userId); - return ReturnYouTubeDislikeApi.register(userId, this); - } - - private String randomString(int len) { - StringBuilder sb = new StringBuilder(len); - for (int i = 0; i < len; i++) - sb.append(AB.charAt(rnd.nextInt(AB.length()))); - return sb.toString(); - } - - private String fetchUserId() { - this.userId = SettingsEnum.RYD_USER_ID.getString(); - if (this.userId == null) { - this.userId = register(); - } - - return this.userId; - } - - private static int countLeadingZeroes(byte[] uInt8View) { - int zeroes = 0; - int value = 0; - for (int i = 0; i < uInt8View.length; i++) { - value = uInt8View[i] & 0xFF; - if (value == 0) { - zeroes += 8; - } else { - int count = 1; - if (value >>> 4 == 0) { - count += 4; - value <<= 4; - } - if (value >>> 6 == 0) { - count += 2; - value <<= 2; - } - zeroes += count - (value >>> 7); - break; - } - } - return zeroes; - } -} 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 142aab9cf0..c79eccc406 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java @@ -5,6 +5,9 @@ import android.os.Build; import android.text.SpannableString; +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; + import java.util.Locale; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; @@ -16,8 +19,16 @@ import app.revanced.integrations.utils.SharedPrefHelper; public class ReturnYouTubeDislike { + /** + * maximum amount of time to block the UI from updates, while waiting for dislike network call to complete + */ + private static final long MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE = 5000; + + // Different threads read and write these fields. access to them must be synchronized + @GuardedBy("this") private static String currentVideoId; - public static Integer dislikeCount; + @GuardedBy("this") + private static Integer dislikeCount; private static boolean isEnabled; private static boolean segmentedButton; @@ -27,7 +38,7 @@ public enum Vote { DISLIKE(-1), LIKE_REMOVE(0); - public int value; + public final int value; Vote(int value) { this.value = value; @@ -36,21 +47,14 @@ public enum Vote { private static Thread _dislikeFetchThread = null; private static Thread _votingThread = null; - private static Registration registration; - private static Voting voting; private static CompactDecimalFormat compactNumberFormatter; static { - Context context = ReVancedUtils.getContext(); isEnabled = SettingsEnum.RYD_ENABLED.getBoolean(); - if (isEnabled) { - registration = new Registration(); - voting = new Voting(registration); - } - - Locale locale = context.getResources().getConfiguration().locale; - LogHelper.debug(ReturnYouTubeDislike.class, "locale - " + locale); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Context context = ReVancedUtils.getContext(); + Locale locale = context.getResources().getConfiguration().locale; + LogHelper.debug(ReturnYouTubeDislike.class, "Locale: " + locale); compactNumberFormatter = CompactDecimalFormat.getInstance( locale, CompactDecimalFormat.CompactStyle.SHORT @@ -58,34 +62,90 @@ public enum Vote { } } + private ReturnYouTubeDislike() { + } // only static methods + public static void onEnabledChange(boolean enabled) { isEnabled = enabled; - if (registration == null) { - registration = new Registration(); - } - if (voting == null) { - voting = new Voting(registration); - } } - public static void newVideoLoaded(String videoId) { - LogHelper.debug(ReturnYouTubeDislike.class, "newVideoLoaded - " + videoId); + public static synchronized String getCurrentVideoId() { + return currentVideoId; + } + public static synchronized void setCurrentVideoId(String videoId) { + Objects.requireNonNull(videoId); + currentVideoId = videoId; dislikeCount = null; - if (!isEnabled) return; + } - currentVideoId = videoId; + /** + * @return the dislikeCount for {@link #getCurrentVideoId()}. + * Returns NULL if the dislike is not yet loaded, or if the dislike network fetch failed. + */ + public static synchronized Integer getDislikeCount() { + return dislikeCount; + } + + /** + * @return true if the videoId parameter matches the current dislike request, and the set value was successful. + * If videoID parameter does not match currentVideoId, then this call does nothing + */ + private static synchronized boolean setCurrentDislikeCount(String videoId, Integer videoIdDislikeCount) { + if (!videoId.equals(currentVideoId)) { + return false; + } + dislikeCount = videoIdDislikeCount; + return true; + } + private static void interruptDislikeFetchThreadIfRunning() { + if (_dislikeFetchThread == null) return; try { - if (_dislikeFetchThread != null && _dislikeFetchThread.getState() != Thread.State.TERMINATED) { - LogHelper.debug(ReturnYouTubeDislike.class, "Interrupting the thread. Current state " + _dislikeFetchThread.getState()); + Thread.State dislikeFetchThreadState = _dislikeFetchThread.getState(); + if (dislikeFetchThreadState != Thread.State.TERMINATED) { + LogHelper.debug(ReturnYouTubeDislike.class, "Interrupting the fetch dislike thread of state: " + dislikeFetchThreadState); _dislikeFetchThread.interrupt(); } } catch (Exception ex) { - LogHelper.printException(ReturnYouTubeDislike.class, "Error in the dislike fetch thread", ex); + LogHelper.printException(ReturnYouTubeDislike.class, "Error in the fetch dislike thread", ex); } + } + + private static void interruptVoteThreadIfRunning() { + if (_votingThread == null) return; + try { + Thread.State voteThreadState = _votingThread.getState(); + if (voteThreadState != Thread.State.TERMINATED) { + LogHelper.debug(ReturnYouTubeDislike.class, "Interrupting the voting thread of state: " + voteThreadState); + _votingThread.interrupt(); + } + } catch (Exception ex) { + LogHelper.printException(ReturnYouTubeDislike.class, "Error in the voting thread", ex); + } + } - _dislikeFetchThread = new Thread(() -> ReturnYouTubeDislikeApi.fetchDislikes(videoId)); + public static void newVideoLoaded(String videoId) { + if (!isEnabled) return; + LogHelper.debug(ReturnYouTubeDislike.class, "New video loaded: " + videoId); + + setCurrentVideoId(videoId); + interruptDislikeFetchThreadIfRunning(); + + // TODO use a private fixed size thread pool + _dislikeFetchThread = new Thread(() -> { + try { + Integer fetchedDislikeCount = ReturnYouTubeDislikeApi.fetchDislikes(videoId); + if (fetchedDislikeCount == null) { + return; // fetch failed or thread was interrupted + } + if (!ReturnYouTubeDislike.setCurrentDislikeCount(videoId, fetchedDislikeCount)) { + LogHelper.debug(ReturnYouTubeDislike.class, "Ignoring stale dislike fetched call for video " + videoId); + } + } catch (Exception ex) { + LogHelper.printException(ReturnYouTubeDislike.class, "Failed to fetch dislikes for videoId: " + videoId, ex); + } + }); _dislikeFetchThread.start(); } @@ -96,17 +156,26 @@ public static void onComponentCreated(Object conversionContext, AtomicReference< var conversionContextString = conversionContext.toString(); // Check for new component - if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) + if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) { segmentedButton = true; - else if (!conversionContextString.contains("|dislike_button.eml|")) + } else if (!conversionContextString.contains("|dislike_button.eml|")) { + LogHelper.debug(ReturnYouTubeDislike.class, "could not find a dislike button in " + conversionContextString); return; - + } // Have to block the current thread until fetching is done // There's no known way to edit the text after creation yet - if (_dislikeFetchThread != null) _dislikeFetchThread.join(); + if (_dislikeFetchThread != null) { + _dislikeFetchThread.join(MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE); + } - if (dislikeCount == null) return; + Integer fetchedDislikeCount = getDislikeCount(); + if (fetchedDislikeCount == null) { + LogHelper.debug(ReturnYouTubeDislike.class, "Cannot add dislike count to UI (dislike count not available)"); + // There's no point letting the request continue, as there is not another chance to use the result + interruptDislikeFetchThreadIfRunning(); + return; + } updateDislike(textRef, dislikeCount); LogHelper.debug(ReturnYouTubeDislike.class, "Updated text on component" + conversionContextString); @@ -118,24 +187,18 @@ else if (!conversionContextString.contains("|dislike_button.eml|")) public static void sendVote(Vote vote) { if (!isEnabled) return; - Context context = ReVancedUtils.getContext(); - if (SharedPrefHelper.getBoolean(Objects.requireNonNull(context), SharedPrefHelper.SharedPrefNames.YOUTUBE, "user_signed_out", true)) + Context context = Objects.requireNonNull(ReVancedUtils.getContext()); + if (SharedPrefHelper.getBoolean(context, SharedPrefHelper.SharedPrefNames.YOUTUBE, "user_signed_out", true)) return; - LogHelper.debug(ReturnYouTubeDislike.class, "sending vote - " + vote + " for video " + currentVideoId); - try { - if (_votingThread != null && _votingThread.getState() != Thread.State.TERMINATED) { - LogHelper.debug(ReturnYouTubeDislike.class, "Interrupting the thread. Current state " + _votingThread.getState()); - _votingThread.interrupt(); - } - } catch (Exception ex) { - LogHelper.printException(ReturnYouTubeDislike.class, "Error in the voting thread", ex); - } + // Must make a local copy of videoId, since it may change between now and when the vote thread runs + String videoIdToVoteFor = getCurrentVideoId(); + interruptVoteThreadIfRunning(); + // TODO: use a private fixed sized thread pool _votingThread = new Thread(() -> { try { - boolean result = voting.sendVote(currentVideoId, vote); - LogHelper.debug(ReturnYouTubeDislike.class, "sendVote status " + result); + ReturnYouTubeDislikeApi.sendVote(videoIdToVoteFor, getUserId(), vote); } catch (Exception ex) { LogHelper.printException(ReturnYouTubeDislike.class, "Failed to send vote", ex); } @@ -143,11 +206,40 @@ public static void sendVote(Vote vote) { _votingThread.start(); } + /** + * Lock used exclusively by {@link #getUserId()} + */ + private static final Object rydUserIdLock = new Object(); + + /** + * Must call off main thread, as this will make a network call if user has not yet been registered yet + * + * @return ReturnYouTubeDislike user ID. If user registration has never happened + * and the network call fails, this will return NULL + */ + @Nullable + private static String getUserId() { + ReVancedUtils.verifyOffMainThread(); + + synchronized (rydUserIdLock) { + String userId = SettingsEnum.RYD_USER_ID.getString(); + if (userId != null) { + return userId; + } + + userId = ReturnYouTubeDislikeApi.registerAsNewUser(); // blocks until network call is completed + if (userId != null) { + SettingsEnum.RYD_USER_ID.saveValue(userId); + } + return userId; + } + } + private static void updateDislike(AtomicReference textRef, Integer dislikeCount) { SpannableString oldSpannableString = (SpannableString) textRef.get(); - // parse the buttons string - // if the button is segmented, only get the like count as a string + // Parse the buttons string. + // If the button is segmented, only get the like count as a string var oldButtonString = oldSpannableString.toString(); if (segmentedButton) oldButtonString = oldButtonString.split(" \\| ")[0]; diff --git a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/Voting.java b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/Voting.java deleted file mode 100644 index 77d07ffbe7..0000000000 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/Voting.java +++ /dev/null @@ -1,18 +0,0 @@ -package app.revanced.integrations.returnyoutubedislike; - -import app.revanced.integrations.utils.LogHelper; -import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; - -public class Voting { - private Registration registration; - - public Voting(Registration registration) { - this.registration = registration; - } - - public boolean sendVote(String videoId, ReturnYouTubeDislike.Vote vote) { - String userId = registration.getUserId(); - LogHelper.debug(Voting.class, "Trying to vote the following video: " + videoId + " with vote " + vote + " and userId: " + userId); - return ReturnYouTubeDislikeApi.sendVote(videoId, userId, vote.value); - } -} 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 dded0cc70c..512c08b5bc 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 @@ -2,71 +2,178 @@ import static app.revanced.integrations.requests.Requester.parseJson; +import android.util.Base64; + +import androidx.annotation.Nullable; import org.json.JSONObject; import java.io.IOException; import java.io.OutputStream; import java.net.HttpURLConnection; +import java.net.ProtocolException; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Objects; -import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike; -import app.revanced.integrations.utils.LogHelper; -import app.revanced.integrations.returnyoutubedislike.Registration; import app.revanced.integrations.requests.Requester; import app.revanced.integrations.requests.Route; +import app.revanced.integrations.returnyoutubedislike.ReturnYouTubeDislike; +import app.revanced.integrations.utils.LogHelper; +import app.revanced.integrations.utils.ReVancedUtils; public class ReturnYouTubeDislikeApi { private static final String RYD_API_URL = "https://returnyoutubedislikeapi.com/"; + private static final int HTTP_CONNECTION_DEFAULT_TIMEOUT = 5000; + + /** + * Indicates a client rate limit has been reached + */ + private static final int RATE_LIMIT_HTTP_STATUS_CODE = 429; + + /** + * How long wait until API calls are resumed, if a rate limit is hit + * No clear guideline of how long to backoff. Using 60 seconds for now. + */ + private static final int RATE_LIMIT_BACKOFF_SECONDS = 60; + + /** + * Last time a {@link #RATE_LIMIT_HTTP_STATUS_CODE} was reached. + * zero if has not been reached. + */ + private static volatile long lastTimeLimitWasHit; // must be volatile, since different threads access this + private ReturnYouTubeDislikeApi() { + } // utility class + + /** + * @return True, if api rate limit is in effect. + */ + private static boolean checkIfRateLimitInEffect(String apiEndPointName) { + if (lastTimeLimitWasHit == 0) { + return false; + } + final long numberOfSecondsSinceLastRateLimit = (System.currentTimeMillis() - lastTimeLimitWasHit) / 1000; + if (numberOfSecondsSinceLastRateLimit < RATE_LIMIT_BACKOFF_SECONDS) { + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Ignoring api call " + apiEndPointName + " as only " + + numberOfSecondsSinceLastRateLimit + " seconds has passed since last rate limit."); + return true; + } + return false; } - public static void fetchDislikes(String videoId) { + /** + * @return True, if the rate limit was reached. + */ + private static boolean checkIfRateLimitWasHit(int httpResponseCode) { + // set to true, to verify rate limit works + final boolean DEBUG_RATE_LIMIT = false; + if (DEBUG_RATE_LIMIT) { + final double RANDOM_RATE_LIMIT_PERCENTAGE = 0.1; // 10% chance of a triggering a rate limit + if (Math.random() < RANDOM_RATE_LIMIT_PERCENTAGE) { + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Artificially triggering rate limit for debug purposes"); + httpResponseCode = RATE_LIMIT_HTTP_STATUS_CODE; + } + } + + if (httpResponseCode == RATE_LIMIT_HTTP_STATUS_CODE) { + lastTimeLimitWasHit = System.currentTimeMillis(); + LogHelper.debug(ReturnYouTubeDislikeApi.class, "API rate limit was hit. Stopping API calls for the next " + + RATE_LIMIT_BACKOFF_SECONDS + " seconds"); + return true; + } + return false; + } + + /** + * @return The number of dislikes. + * Returns NULL if fetch failed, calling thread is interrupted, or rate limit is in effect. + */ + @Nullable + public static Integer fetchDislikes(String videoId) { + ReVancedUtils.verifyOffMainThread(); + Objects.requireNonNull(videoId); try { + if (checkIfRateLimitInEffect("fetchDislikes")) { + return null; + } LogHelper.debug(ReturnYouTubeDislikeApi.class, "Fetching dislikes for " + videoId); HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_DISLIKES, videoId); - connection.setConnectTimeout(1000); - if (connection.getResponseCode() == 200) { + connection.setConnectTimeout(HTTP_CONNECTION_DEFAULT_TIMEOUT); + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); + return null; + } else if (responseCode == 200) { JSONObject json = getJSONObject(connection); - ReturnYouTubeDislike.dislikeCount = json.getInt("dislikes"); - LogHelper.debug(ReturnYouTubeDislikeApi.class, "dislikes fetched - " + ReturnYouTubeDislike.dislikeCount); + Integer fetchedDislikeCount = json.getInt("dislikes"); + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Dislikes fetched: " + fetchedDislikeCount); + connection.disconnect(); + return fetchedDislikeCount; } else { - LogHelper.debug(ReturnYouTubeDislikeApi.class, "dislikes fetch response was " + connection.getResponseCode()); + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Dislikes fetch response was " + responseCode); + connection.disconnect(); } - connection.disconnect(); } catch (Exception ex) { LogHelper.printException(ReturnYouTubeDislikeApi.class, "Failed to fetch dislikes", ex); } + return null; } - public static String register(String userId, Registration registration) { + /** + * @return The newly created and registered user id. Returns NULL if registration failed. + */ + @Nullable + public static String registerAsNewUser() { + ReVancedUtils.verifyOffMainThread(); try { + if (checkIfRateLimitInEffect("registerAsNewUser")) { + return null; + } + String userId = randomString(36); + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Trying to register the following userId: " + userId); + HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_REGISTRATION, userId); - connection.setConnectTimeout(5 * 1000); - if (connection.getResponseCode() == 200) { + connection.setConnectTimeout(HTTP_CONNECTION_DEFAULT_TIMEOUT); + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); + return null; + } else if (responseCode == 200) { JSONObject json = getJSONObject(connection); String challenge = json.getString("challenge"); int difficulty = json.getInt("difficulty"); LogHelper.debug(ReturnYouTubeDislikeApi.class, "Registration challenge - " + challenge + " with difficulty of " + difficulty); + connection.disconnect(); // Solve the puzzle - String solution = Registration.solvePuzzle(challenge, difficulty); + String solution = solvePuzzle(challenge, difficulty); LogHelper.debug(ReturnYouTubeDislikeApi.class, "Registration confirmation solution is " + solution); - - return confirmRegistration(userId, solution, registration); + if (solution == null) { + return null; // failed to solve puzzle + } + return confirmRegistration(userId, solution); } else { - LogHelper.debug(ReturnYouTubeDislikeApi.class, "Registration response was " + connection.getResponseCode()); + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Registration response was " + responseCode); + connection.disconnect(); } - connection.disconnect(); } catch (Exception ex) { LogHelper.printException(ReturnYouTubeDislikeApi.class, "Failed to register userId", ex); } return null; } - private static String confirmRegistration(String userId, String solution, Registration registration) { + @Nullable + private static String confirmRegistration(String userId, String solution) { + ReVancedUtils.verifyOffMainThread(); + Objects.requireNonNull(userId); + Objects.requireNonNull(solution); try { + if (checkIfRateLimitInEffect("confirmRegistration")) { + return null; + } LogHelper.debug(ReturnYouTubeDislikeApi.class, "Trying to confirm registration for the following userId: " + userId + " with solution: " + solution); HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_REGISTRATION, userId); @@ -77,20 +184,25 @@ private static String confirmRegistration(String userId, String solution, Regist byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); os.write(input, 0, input.length); } - if (connection.getResponseCode() == 200) { + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); + return null; + } + + if (responseCode == 200) { String result = parseJson(connection); LogHelper.debug(ReturnYouTubeDislikeApi.class, "Registration confirmation result was " + result); + connection.disconnect(); if (result.equalsIgnoreCase("true")) { - registration.saveUserId(userId); LogHelper.debug(ReturnYouTubeDislikeApi.class, "Registration was successful for user " + userId); - return userId; } } else { - LogHelper.debug(ReturnYouTubeDislikeApi.class, "Registration confirmation response was " + connection.getResponseCode()); + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Registration confirmation response was " + responseCode); + connection.disconnect(); } - connection.disconnect(); } catch (Exception ex) { LogHelper.printException(ReturnYouTubeDislikeApi.class, "Failed to confirm registration", ex); } @@ -98,33 +210,50 @@ private static String confirmRegistration(String userId, String solution, Regist return null; } - public static boolean sendVote(String videoId, String userId, int vote) { + public static boolean sendVote(String videoId, String userId, ReturnYouTubeDislike.Vote vote) { + ReVancedUtils.verifyOffMainThread(); + Objects.requireNonNull(videoId); + Objects.requireNonNull(userId); + Objects.requireNonNull(vote); + + if (checkIfRateLimitInEffect("sendVote")) { + return false; + } + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Trying to vote the following video: " + + videoId + " with vote " + vote + " and userId: " + userId); try { HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.SEND_VOTE); applyCommonRequestSettings(connection); - String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote + "\"}"; + String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}"; try (OutputStream os = connection.getOutputStream()) { byte[] input = voteJsonString.getBytes(StandardCharsets.UTF_8); os.write(input, 0, input.length); } - if (connection.getResponseCode() == 200) { + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); + return false; + } + + if (responseCode == 200) { JSONObject json = getJSONObject(connection); String challenge = json.getString("challenge"); int difficulty = json.getInt("difficulty"); LogHelper.debug(ReturnYouTubeDislikeApi.class, "Vote challenge - " + challenge + " with difficulty of " + difficulty); + connection.disconnect(); // Solve the puzzle - String solution = Registration.solvePuzzle(challenge, difficulty); + String solution = solvePuzzle(challenge, difficulty); LogHelper.debug(ReturnYouTubeDislikeApi.class, "Vote confirmation solution is " + solution); // Confirm vote return confirmVote(videoId, userId, solution); } else { - LogHelper.debug(ReturnYouTubeDislikeApi.class, "Vote response was " + connection.getResponseCode()); + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Vote response was " + responseCode); + connection.disconnect(); } - connection.disconnect(); } catch (Exception ex) { LogHelper.printException(ReturnYouTubeDislikeApi.class, "Failed to send vote", ex); } @@ -132,6 +261,14 @@ public static boolean sendVote(String videoId, String userId, int vote) { } private static boolean confirmVote(String videoId, String userId, String solution) { + ReVancedUtils.verifyOffMainThread(); + Objects.requireNonNull(videoId); + Objects.requireNonNull(userId); + Objects.requireNonNull(solution); + + if (checkIfRateLimitInEffect("confirmVote")) { + return false; + } try { HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_VOTE); applyCommonRequestSettings(connection); @@ -141,20 +278,28 @@ private static boolean confirmVote(String videoId, String userId, String solutio byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); os.write(input, 0, input.length); } - if (connection.getResponseCode() == 200) { + final int responseCode = connection.getResponseCode(); + if (checkIfRateLimitWasHit(responseCode)) { + connection.disconnect(); + return false; + } + + if (responseCode == 200) { String result = parseJson(connection); LogHelper.debug(ReturnYouTubeDislikeApi.class, "Vote confirmation result was " + result); - + connection.disconnect(); if (result.equalsIgnoreCase("true")) { LogHelper.debug(ReturnYouTubeDislikeApi.class, "Vote was successful for user " + userId); - return true; + } else { + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Vote was unsuccessful for user " + userId); + return false; } } else { - LogHelper.debug(ReturnYouTubeDislikeApi.class, "Vote confirmation response was " + connection.getResponseCode()); + LogHelper.debug(ReturnYouTubeDislikeApi.class, "Vote confirmation response was " + responseCode); + connection.disconnect(); } - connection.disconnect(); } catch (Exception ex) { LogHelper.printException(ReturnYouTubeDislikeApi.class, "Failed to confirm vote", ex); } @@ -163,12 +308,12 @@ private static boolean confirmVote(String videoId, String userId, String solutio // utils - private static void applyCommonRequestSettings(HttpURLConnection connection) throws Exception { + private static void applyCommonRequestSettings(HttpURLConnection connection) throws ProtocolException { connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "application/json"); connection.setDoOutput(true); - connection.setConnectTimeout(5 * 1000); + connection.setConnectTimeout(HTTP_CONNECTION_DEFAULT_TIMEOUT); } // helpers @@ -180,4 +325,68 @@ private static HttpURLConnection getConnectionFromRoute(Route route, String... p private static JSONObject getJSONObject(HttpURLConnection connection) throws Exception { return Requester.getJSONObject(connection); } + + private static String solvePuzzle(String challenge, int difficulty) { + byte[] decodedChallenge = Base64.decode(challenge, Base64.NO_WRAP); + + byte[] buffer = new byte[20]; + for (int i = 4; i < 20; i++) { + buffer[i] = decodedChallenge[i - 4]; + } + + try { + int maxCount = (int) (Math.pow(2, difficulty + 1) * 5); + MessageDigest md = MessageDigest.getInstance("SHA-512"); + for (int i = 0; i < maxCount; i++) { + buffer[0] = (byte) i; + buffer[1] = (byte) (i >> 8); + buffer[2] = (byte) (i >> 16); + buffer[3] = (byte) (i >> 24); + byte[] messageDigest = md.digest(buffer); + + if (countLeadingZeroes(messageDigest) >= difficulty) { + return Base64.encodeToString(new byte[]{buffer[0], buffer[1], buffer[2], buffer[3]}, Base64.NO_WRAP); + } + } + } catch (Exception ex) { + LogHelper.printException(ReturnYouTubeDislikeApi.class, "Failed to solve puzzle", ex); + } + + return null; + } + + // https://stackoverflow.com/a/157202 + private static String randomString(int len) { + String AB = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + SecureRandom rnd = new SecureRandom(); + + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) + sb.append(AB.charAt(rnd.nextInt(AB.length()))); + return sb.toString(); + } + + private static int countLeadingZeroes(byte[] uInt8View) { + int zeroes = 0; + int value = 0; + for (int i = 0; i < uInt8View.length; i++) { + value = uInt8View[i] & 0xFF; + if (value == 0) { + zeroes += 8; + } else { + int count = 1; + if (value >>> 4 == 0) { + count += 4; + value <<= 4; + } + if (value >>> 6 == 0) { + count += 2; + value <<= 2; + } + zeroes += count - (value >>> 7); + break; + } + } + return zeroes; + } } 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 1d84385dbf..77eec9011f 100644 --- a/app/src/main/java/app/revanced/integrations/settings/SettingsEnum.java +++ b/app/src/main/java/app/revanced/integrations/settings/SettingsEnum.java @@ -140,7 +140,9 @@ public enum SettingsEnum { private final ReturnType returnType; private final boolean rebootApp; - private Object value = null; + // must be volatile, as some settings are changed from non-main threads + // of note, the object value is persistently stored using SharedPreferences (which is thread safe) + private volatile Object value; SettingsEnum(String path, Object defaultValue, ReturnType returnType) { this.path = path; diff --git a/app/src/main/java/app/revanced/integrations/utils/LogHelper.java b/app/src/main/java/app/revanced/integrations/utils/LogHelper.java index 43b0709d3f..515ff05b2b 100644 --- a/app/src/main/java/app/revanced/integrations/utils/LogHelper.java +++ b/app/src/main/java/app/revanced/integrations/utils/LogHelper.java @@ -25,4 +25,4 @@ public static void printException(Class clazz, String message) { public static void info(Class clazz, String message) { Log.i("revanced: " + (clazz != null ? clazz.getSimpleName() : ""), message); } -} +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java b/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java index 29e2ec22f8..9b812cd666 100644 --- a/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java +++ b/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java @@ -1,5 +1,6 @@ package app.revanced.integrations.utils; +import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Resources; import android.os.Handler; @@ -10,10 +11,14 @@ public class ReVancedUtils { private static PlayerType env; - public static boolean newVideo = false; + private static boolean newVideo = false; + @SuppressLint("StaticFieldLeak") public static Context context; + private ReVancedUtils() { + } // utility class + public static boolean containsAny(final String value, final String... targets) { for (String string : targets) if (!string.isEmpty() && value.contains(string)) return true; @@ -52,10 +57,6 @@ public static int getIdentifier(String name, String defType) { return context.getResources().getIdentifier(name, defType, context.getPackageName()); } - public static void runOnMainThread(Runnable runnable) { - new Handler(Looper.getMainLooper()).post(runnable); - } - public static Context getContext() { if (context != null) { return context; @@ -68,4 +69,33 @@ public static Context getContext() { public static boolean isTablet(Context context) { return context.getResources().getConfiguration().smallestScreenWidthDp >= 600; } + + public static void runOnMainThread(Runnable runnable) { + new Handler(Looper.getMainLooper()).post(runnable); + } + + /** + * @return if the calling thread is on the main thread + */ + public static boolean currentIsOnMainThread() { + return Looper.getMainLooper().isCurrentThread(); + } + + /** + * @throws IllegalStateException if the calling thread is _not_ on the main thread + */ + public static void verifyOnMainThread() throws IllegalStateException { + if (!currentIsOnMainThread()) { + throw new IllegalStateException("Must call _on_ the main thread"); + } + } + + /** + * @throws IllegalStateException if the calling thread _is_ on the main thread + */ + public static void verifyOffMainThread() throws IllegalStateException { + if (currentIsOnMainThread()) { + throw new IllegalStateException("Must call _off_ the main thread"); + } + } } \ No newline at end of file