diff --git a/app/src/main/java/app/revanced/integrations/requests/Requester.java b/app/src/main/java/app/revanced/integrations/requests/Requester.java index c0b013cb..6837ce76 100644 --- a/app/src/main/java/app/revanced/integrations/requests/Requester.java +++ b/app/src/main/java/app/revanced/integrations/requests/Requester.java @@ -1,6 +1,7 @@ package app.revanced.integrations.requests; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import java.io.BufferedReader; @@ -24,32 +25,58 @@ public class Requester { return connection; } + /** + * Parse, and then disconnect the {@link HttpURLConnection} + * + * TODO: rename this to #parseJsonAndDisconnect + */ public static String parseJson(HttpURLConnection connection) throws IOException { - return parseJson(connection.getInputStream(), false); + String result = parseJson(connection.getInputStream(), false); + connection.disconnect(); + return result; } + /** + * Parse, and then close the {@link InputStream} + * + * TODO: rename this to #parseJsonAndCloseStream + */ public static String parseJson(InputStream inputStream, boolean isError) throws IOException { - StringBuilder jsonBuilder = new StringBuilder(); - BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); - String line; - while ((line = reader.readLine()) != null) { - jsonBuilder.append(line); - if (isError) - jsonBuilder.append("\n"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + StringBuilder jsonBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + jsonBuilder.append(line); + if (isError) + jsonBuilder.append("\n"); + } + return jsonBuilder.toString(); } - inputStream.close(); - return jsonBuilder.toString(); } + /** + * Parse, and then do NOT disconnect the {@link HttpURLConnection} + */ public static String parseErrorJson(HttpURLConnection connection) throws IOException { + // TODO: make this also disconnect, and rename method to #parseErrorJsonAndDisconnect return parseJson(connection.getErrorStream(), true); } - public static JSONObject getJSONObject(HttpURLConnection connection) throws Exception { + /** + * Parse, and then disconnect the {@link HttpURLConnection} + * + * TODO: rename this to #getJSONObjectAndDisconnect + */ + public static JSONObject getJSONObject(HttpURLConnection connection) throws JSONException, IOException { return new JSONObject(parseJsonAndDisconnect(connection)); } - public static JSONArray getJSONArray(HttpURLConnection connection) throws Exception { + /** + * Parse, and then disconnect the {@link HttpURLConnection} + * + * TODO: rename this to #getJSONArrayAndDisconnect + */ + public static JSONArray getJSONArray(HttpURLConnection connection) throws JSONException, IOException { return new JSONArray(parseJsonAndDisconnect(connection)); } 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 ce940dbd..22ce1dcf 100644 --- a/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/returnyoutubedislike/ReturnYouTubeDislike.java @@ -10,6 +10,11 @@ import androidx.annotation.Nullable; import java.util.Locale; import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import app.revanced.integrations.returnyoutubedislike.requests.ReturnYouTubeDislikeApi; @@ -20,18 +25,35 @@ 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 + * Maximum amount of time to block the UI from updates while waiting for dislike network call to complete. + * + * Must be less than 5 seconds, as per: + * https://developer.android.com/topic/performance/vitals/anr */ - private static final long MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE = 5000; + private static final long MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE = 4000; - // Different threads read and write these fields. access to them must be synchronized - @GuardedBy("this") + /** + * Used to send votes, one by one, in the same order the user created them + */ + private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor(); + + // Must be volatile, since non-main threads read this field. + private static volatile boolean isEnabled = SettingsEnum.RYD_ENABLED.getBoolean(); + + /** + * Used to guard {@link #currentVideoId} and {@link #dislikeFetchFuture}, + * as multiple threads access this class. + */ + private static final Object videoIdLockObject = new Object(); + + @GuardedBy("videoIdLockObject") private static String currentVideoId; - @GuardedBy("this") - private static Integer dislikeCount; - private static boolean isEnabled; - private static boolean segmentedButton; + /** + * Stores the results of the dislike fetch, and used as a barrier to wait until fetch completes + */ + @GuardedBy("videoIdLockObject") + private static Future dislikeFetchFuture; public enum Vote { LIKE(1), @@ -45,172 +67,113 @@ public class ReturnYouTubeDislike { } } - private static Thread _dislikeFetchThread = null; - private static Thread _votingThread = null; - private static CompactDecimalFormat compactNumberFormatter; - - static { - isEnabled = SettingsEnum.RYD_ENABLED.getBoolean(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Context context = ReVancedUtils.getContext(); - Locale locale = context.getResources().getConfiguration().locale; - LogHelper.printDebug(() -> "Locale: " + locale); - compactNumberFormatter = CompactDecimalFormat.getInstance( - locale, - CompactDecimalFormat.CompactStyle.SHORT - ); - } - } - private ReturnYouTubeDislike() { } // only static methods + /** + * Used to format like/dislike count. + */ + @GuardedBy("ReturnYouTubeDislike.class") // number formatter is not thread safe + private static CompactDecimalFormat compactNumberFormatter; + public static void onEnabledChange(boolean enabled) { isEnabled = enabled; } - public static synchronized String getCurrentVideoId() { - return currentVideoId; - } - - public static synchronized void setCurrentVideoId(String videoId) { - Objects.requireNonNull(videoId); - currentVideoId = videoId; - dislikeCount = null; - } - - /** - * @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 { - Thread.State dislikeFetchThreadState = _dislikeFetchThread.getState(); - if (dislikeFetchThreadState != Thread.State.TERMINATED) { - LogHelper.printDebug(() -> "Interrupting the fetch dislike thread of state: " + dislikeFetchThreadState); - _dislikeFetchThread.interrupt(); - } - } catch (Exception ex) { - LogHelper.printException(() -> "Error in the fetch dislike thread", ex); + private static String getCurrentVideoId() { + synchronized (videoIdLockObject) { + return currentVideoId; } } - private static void interruptVoteThreadIfRunning() { - if (_votingThread == null) return; - try { - Thread.State voteThreadState = _votingThread.getState(); - if (voteThreadState != Thread.State.TERMINATED) { - LogHelper.printDebug(() -> "Interrupting the voting thread of state: " + voteThreadState); - _votingThread.interrupt(); - } - } catch (Exception ex) { - LogHelper.printException(() -> "Error in the voting thread", ex); + private static Future getDislikeFetchFuture() { + synchronized (videoIdLockObject) { + return dislikeFetchFuture; } } + // It is unclear if this method is always called on the main thread (since the YouTube app is the one making the call) + // treat this as if any thread could call this method public static void newVideoLoaded(String videoId) { if (!isEnabled) return; - LogHelper.printDebug(() -> "New video loaded: " + videoId); + try { + Objects.requireNonNull(videoId); + LogHelper.printDebug(() -> "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.printDebug(() -> "Ignoring stale dislike fetched call for video " + videoId); - } - } catch (Exception ex) { - LogHelper.printException(() -> "Failed to fetch dislikes for videoId: " + videoId, ex); + synchronized (videoIdLockObject) { + 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)); } - }); - _dislikeFetchThread.start(); + } catch (Exception ex) { + LogHelper.printException(() -> "Failed to load new video: " + videoId, ex); + } } + // BEWARE! This method is sometimes called on the main thread, but it usually is called _off_ the main thread! public static void onComponentCreated(Object conversionContext, AtomicReference textRef) { if (!isEnabled) return; try { var conversionContextString = conversionContext.toString(); + boolean isSegmentedButton = false; // Check for new component if (conversionContextString.contains("|segmented_like_dislike_button.eml|")) { - segmentedButton = true; + isSegmentedButton = true; } else if (!conversionContextString.contains("|dislike_button.eml|")) { - LogHelper.printDebug(() -> "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(MILLISECONDS_TO_BLOCK_UI_WHILE_WAITING_FOR_DISLIKE_FETCH_TO_COMPLETE); + Integer dislikeCount; + try { + dislikeCount = getDislikeFetchFuture().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; } - - Integer fetchedDislikeCount = getDislikeCount(); - if (fetchedDislikeCount == null) { + if (dislikeCount == null) { LogHelper.printDebug(() -> "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.printDebug(() -> "Updated text on component" + conversionContextString); + updateDislike(textRef, isSegmentedButton, dislikeCount); + LogHelper.printDebug(() -> "Updated text on component: " + conversionContextString); } catch (Exception ex) { - LogHelper.printException(() -> "Error while trying to set dislikes text", ex); + LogHelper.printException(() -> "Error while trying to update dislikes text", ex); } } public static void sendVote(Vote vote) { if (!isEnabled) return; - - Context context = Objects.requireNonNull(ReVancedUtils.getContext()); - if (SharedPrefHelper.getBoolean(context, SharedPrefHelper.SharedPrefNames.YOUTUBE, "user_signed_out", true)) - return; - - // 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 { - ReturnYouTubeDislikeApi.sendVote(videoIdToVoteFor, getUserId(), vote); - } catch (Exception ex) { - LogHelper.printException(() -> "Failed to send vote", ex); + try { + Objects.requireNonNull(vote); + Context context = Objects.requireNonNull(ReVancedUtils.getContext()); + if (SharedPrefHelper.getBoolean(context, SharedPrefHelper.SharedPrefNames.YOUTUBE, "user_signed_out", true)) { + return; } - }); - _votingThread.start(); - } - /** - * Lock used exclusively by {@link #getUserId()} - */ - private static final Object rydUserIdLock = new Object(); + // Must make a local copy of videoId, since it may change between now and when the vote thread runs + String videoIdToVoteFor = getCurrentVideoId(); + + voteSerialExecutor.execute(() -> { + // must wrap in try/catch to properly log exceptions + try { + String userId = getUserId(); + if (userId != null) { + ReturnYouTubeDislikeApi.sendVote(videoIdToVoteFor, userId, vote); + } + } catch (Exception ex) { + LogHelper.printException(() -> "Failed to send vote", ex); + } + }); + } catch (Exception ex) { + LogHelper.printException(() -> "Error while trying to send vote", ex); + } + } /** * Must call off main thread, as this will make a network call if user has not yet been registered @@ -222,48 +185,76 @@ public class ReturnYouTubeDislike { 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); - } + 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) { + private static void updateDislike(AtomicReference textRef, boolean isSegmentedButton, int dislikeCount) { SpannableString oldSpannableString = (SpannableString) textRef.get(); + String newDislikeString = formatDislikeCount(dislikeCount); - // 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]; + if (isSegmentedButton) { // both likes and dislikes are on a custom segmented button + // parse out the like count as a string + String oldLikesString = oldSpannableString.toString().split(" \\| ")[0]; - var dislikeString = formatDislikes(dislikeCount); - SpannableString newString = new SpannableString( - segmentedButton ? (oldButtonString + " | " + dislikeString) : dislikeString - ); + // YouTube creators can hide the like count on a video, + // and the like count appears as a device language specific string that says 'Like' + // check if the first character is not a number + if (!Character.isDigit(oldLikesString.charAt(0))) { + // likes are hidden. + // RYD does not provide usable data for these types of videos, + // and the API returns bogus data (zero likes and zero dislikes) + // + // example video: https://www.youtube.com/watch?v=UnrU5vxCHxw + // RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw + // + // discussion about this: https://github.com/Anarios/return-youtube-dislike/discussions/530 + // + // Change the "Likes" string to show that likes and dislikes are hidden + // + newDislikeString = "Hidden"; // for now, this is not localized + LogHelper.printDebug(() -> "Like count is hidden by video creator. " + + "RYD does not provide data for videos with hidden likes."); + } else { + // temporary fix for https://github.com/revanced/revanced-integrations/issues/118 + newDislikeString = oldLikesString + " | " + newDislikeString; + } + } + + SpannableString newSpannableString = new SpannableString(newDislikeString); // Copy style (foreground color, etc) to new string Object[] spans = oldSpannableString.getSpans(0, oldSpannableString.length(), Object.class); - for (Object span : spans) - newString.setSpan(span, 0, newString.length(), oldSpannableString.getSpanFlags(span)); - - textRef.set(newString); + for (Object span : spans) { + newSpannableString.setSpan(span, 0, newDislikeString.length(), oldSpannableString.getSpanFlags(span)); + } + textRef.set(newSpannableString); } - private static String formatDislikes(int dislikes) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && compactNumberFormatter != null) { - final String formatted = compactNumberFormatter.format(dislikes); - LogHelper.printDebug(() -> "Formatting dislikes - " + dislikes + " - " + formatted); + private static String formatDislikeCount(int 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) { + Locale locale = ReVancedUtils.getContext().getResources().getConfiguration().locale; + LogHelper.printDebug(() -> "Locale: " + locale); + compactNumberFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); + } + formatted = compactNumberFormatter.format(dislikeCount); + } + LogHelper.printDebug(() -> "Dislike count: " + dislikeCount + " formatted as: " + formatted); return formatted; } - LogHelper.printDebug(() -> "Could not format dislikes, using the unformatted count - " + dislikes); - return String.valueOf(dislikes); + + // never will be reached, as the oldest supported YouTube app requires Android N or greater + return String.valueOf(dislikeCount); } } 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 e7ce98df..6984cd2a 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 @@ -1,7 +1,5 @@ package app.revanced.integrations.returnyoutubedislike.requests; -import static app.revanced.integrations.requests.Requester.parseJson; - import android.util.Base64; import androidx.annotation.Nullable; @@ -14,6 +12,7 @@ import java.net.HttpURLConnection; import java.net.ProtocolException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Objects; @@ -26,7 +25,23 @@ 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; + /** + * Default connection and response timeout for {@link #fetchDislikes(String)} + */ + private static final int API_GET_DISLIKE_DEFAULT_TIMEOUT_MILLISECONDS = 5000; + + /** + * Default connection and response timeout for voting and registration. + * + * Voting and user registration runs in the background and has has no urgency + * so this can be a larger value. + */ + private static final int API_REGISTER_VOTE_DEFAULT_TIMEOUT_MILLISECONDS = 90000; + + /** + * Response code of a successful API call + */ + private static final int SUCCESS_HTTP_STATUS_CODE = 200; /** * Indicates a client rate limit has been reached @@ -34,7 +49,7 @@ public class ReturnYouTubeDislikeApi { private static final int RATE_LIMIT_HTTP_STATUS_CODE = 429; /** - * How long wait until API calls are resumed, if a rate limit is hit + * How long to 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; @@ -43,11 +58,36 @@ public class ReturnYouTubeDislikeApi { * 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 static volatile long lastTimeLimitWasHit; // must be volatile, since different threads read/write to this private ReturnYouTubeDislikeApi() { } // utility class + /** + * Only for local debugging to simulate a slow api call. + * Does this by doing meaningless calculations. + * + * @param maximumTimeToWait maximum time to wait + */ + private static long randomlyWaitIfLocallyDebugging(long maximumTimeToWait) { + final boolean DEBUG_RANDOMLY_DELAY_NETWORK_CALLS = false; + if (DEBUG_RANDOMLY_DELAY_NETWORK_CALLS) { + final long amountOfTimeToWaste = (long) (Math.random() * maximumTimeToWait); + final long timeCalculationStarted = System.currentTimeMillis(); + LogHelper.printDebug(() -> "Artificially creating network delay of: " + amountOfTimeToWaste + " ms"); + + long meaninglessValue = 0; + while (System.currentTimeMillis() - timeCalculationStarted < amountOfTimeToWaste) { + // could do a thread sleep, but that will trigger an exception if the thread is interrupted + meaninglessValue += Long.numberOfLeadingZeros((long) (Math.random() * Long.MAX_VALUE)); + } + // return the value, otherwise the compiler or VM might optimize and remove the meaningless time wasting work, + // leaving an empty loop that hammers on the System.currentTimeMillis native call + return meaninglessValue; + } + return 0; + } + /** * @return True, if api rate limit is in effect. */ @@ -68,7 +108,7 @@ public class ReturnYouTubeDislikeApi { * @return True, if the rate limit was reached. */ private static boolean checkIfRateLimitWasHit(int httpResponseCode) { - // set to true, to verify rate limit logic is working. + // 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 @@ -89,7 +129,7 @@ public class ReturnYouTubeDislikeApi { /** * @return The number of dislikes. - * Returns NULL if fetch failed, calling thread is interrupted, or rate limit is in effect. + * Returns NULL if fetch failed, or a rate limit is in effect. */ @Nullable public static Integer fetchDislikes(String videoId) { @@ -99,23 +139,36 @@ public class ReturnYouTubeDislikeApi { if (checkIfRateLimitInEffect("fetchDislikes")) { return null; } - LogHelper.printDebug(() -> "Fetching dislikes for " + videoId); + LogHelper.printDebug(() -> "Fetching dislikes for: " + videoId); + HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_DISLIKES, videoId); - connection.setConnectTimeout(HTTP_CONNECTION_DEFAULT_TIMEOUT); + // request headers, as per https://returnyoutubedislike.com/docs/fetching + // the documentation says to use 'Accept:text/html', but the RYD browser plugin uses 'Accept:application/json' + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Connection", "keep-alive"); // keep-alive is on by default with http 1.1, but specify anyways + connection.setRequestProperty("Pragma", "no-cache"); + connection.setRequestProperty("Cache-Control", "no-cache"); + connection.setUseCaches(false); + connection.setConnectTimeout(API_GET_DISLIKE_DEFAULT_TIMEOUT_MILLISECONDS); // timeout for TCP connection to server + connection.setReadTimeout(API_GET_DISLIKE_DEFAULT_TIMEOUT_MILLISECONDS); // timeout for server response + + randomlyWaitIfLocallyDebugging(2 * API_GET_DISLIKE_DEFAULT_TIMEOUT_MILLISECONDS); + final int responseCode = connection.getResponseCode(); if (checkIfRateLimitWasHit(responseCode)) { connection.disconnect(); return null; - } else if (responseCode == 200) { - JSONObject json = getJSONObject(connection); - Integer fetchedDislikeCount = json.getInt("dislikes"); - LogHelper.printDebug(() -> "Dislikes fetched: " + fetchedDislikeCount); - connection.disconnect(); - return fetchedDislikeCount; - } else { - LogHelper.printDebug(() -> "Dislikes fetch response was " + responseCode); - connection.disconnect(); } + 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; + } + LogHelper.printDebug(() -> "Failed to fetch dislikes for video: " + videoId + + " response code was: " + responseCode); + connection.disconnect(); } catch (Exception ex) { LogHelper.printException(() -> "Failed to fetch dislikes", ex); } @@ -133,35 +186,31 @@ public class ReturnYouTubeDislikeApi { return null; } String userId = randomString(36); - LogHelper.printDebug(() -> "Trying to register the following userId: " + userId); + LogHelper.printDebug(() -> "Trying to register new user: " + userId); HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_REGISTRATION, userId); - connection.setConnectTimeout(HTTP_CONNECTION_DEFAULT_TIMEOUT); + connection.setRequestProperty("Accept", "application/json"); + connection.setConnectTimeout(API_REGISTER_VOTE_DEFAULT_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(API_REGISTER_VOTE_DEFAULT_TIMEOUT_MILLISECONDS); + final int responseCode = connection.getResponseCode(); if (checkIfRateLimitWasHit(responseCode)) { connection.disconnect(); return null; - } else if (responseCode == 200) { - JSONObject json = getJSONObject(connection); + } + if (responseCode == SUCCESS_HTTP_STATUS_CODE) { + JSONObject json = Requester.getJSONObject(connection); // also disconnects String challenge = json.getString("challenge"); int difficulty = json.getInt("difficulty"); - LogHelper.printDebug(() -> "Registration challenge - " + challenge + " with difficulty of " + difficulty); - connection.disconnect(); - - // Solve the puzzle String solution = solvePuzzle(challenge, difficulty); - LogHelper.printDebug(() -> "Registration confirmation solution is " + solution); - if (solution == null) { - return null; // failed to solve puzzle - } return confirmRegistration(userId, solution); - } else { - LogHelper.printDebug(() -> "Registration response was " + responseCode); - connection.disconnect(); } + LogHelper.printDebug(() -> "Failed to register new user: " + userId + + " response code was: " + responseCode); + connection.disconnect(); } catch (Exception ex) { - LogHelper.printException(() -> "Failed to register userId", ex); + LogHelper.printException(() -> "Failed to register user", ex); } return null; } @@ -175,10 +224,10 @@ public class ReturnYouTubeDislikeApi { if (checkIfRateLimitInEffect("confirmRegistration")) { return null; } - LogHelper.printDebug(() -> "Trying to confirm registration for the following userId: " + userId + " with solution: " + solution); + LogHelper.printDebug(() -> "Trying to confirm registration for user: " + userId + " with solution: " + solution); HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_REGISTRATION, userId); - applyCommonRequestSettings(connection); + applyCommonPostRequestSettings(connection); String jsonInputString = "{\"solution\": \"" + solution + "\"}"; try (OutputStream os = connection.getOutputStream()) { @@ -190,22 +239,22 @@ public class ReturnYouTubeDislikeApi { connection.disconnect(); return null; } - - if (responseCode == 200) { - String result = parseJson(connection); - LogHelper.printDebug(() -> "Registration confirmation result was " + result); - connection.disconnect(); - + if (responseCode == SUCCESS_HTTP_STATUS_CODE) { + String result = Requester.parseJson(connection); // also disconnects if (result.equalsIgnoreCase("true")) { - LogHelper.printDebug(() -> "Registration was successful for user " + userId); + LogHelper.printDebug(() -> "Registration confirmation successful for user: " + userId); return userId; } - } else { - LogHelper.printDebug(() -> "Registration confirmation response was " + responseCode); - connection.disconnect(); + LogHelper.printDebug(() -> "Failed to confirm registration for user: " + userId + + " solution: " + solution + " response string was: " + result); + return null; } + LogHelper.printDebug(() -> "Failed to confirm registration for user: " + userId + + " solution: " + solution + " response code was: " + responseCode); + connection.disconnect(); } catch (Exception ex) { - LogHelper.printException(() -> "Failed to confirm registration", ex); + LogHelper.printException(() -> "Failed to confirm registration for user: " + userId + + "solution: " + solution, ex); } return null; @@ -217,14 +266,15 @@ public class ReturnYouTubeDislikeApi { Objects.requireNonNull(userId); Objects.requireNonNull(vote); - if (checkIfRateLimitInEffect("sendVote")) { - return false; - } - LogHelper.printDebug(() -> "Trying to vote the following video: " - + videoId + " with vote " + vote + " and userId: " + userId); try { + if (checkIfRateLimitInEffect("sendVote")) { + return false; + } + LogHelper.printDebug(() -> "Trying to vote for video: " + + videoId + " with vote: " + vote + " user: " + userId); + HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.SEND_VOTE); - applyCommonRequestSettings(connection); + applyCommonPostRequestSettings(connection); String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}"; try (OutputStream os = connection.getOutputStream()) { @@ -237,26 +287,20 @@ public class ReturnYouTubeDislikeApi { connection.disconnect(); return false; } - - if (responseCode == 200) { - JSONObject json = getJSONObject(connection); + if (responseCode == SUCCESS_HTTP_STATUS_CODE) { + JSONObject json = Requester.getJSONObject(connection); // also disconnects String challenge = json.getString("challenge"); int difficulty = json.getInt("difficulty"); - LogHelper.printDebug(() -> "Vote challenge - " + challenge + " with difficulty of " + difficulty); - connection.disconnect(); - // Solve the puzzle String solution = solvePuzzle(challenge, difficulty); - LogHelper.printDebug(() -> "Vote confirmation solution is " + solution); - - // Confirm vote return confirmVote(videoId, userId, solution); - } else { - LogHelper.printDebug(() -> "Vote response was " + responseCode); - connection.disconnect(); } + LogHelper.printDebug(() -> "Failed to send vote for video: " + videoId + + " userId: " + userId + " vote: " + vote + " response code was: " + responseCode); + connection.disconnect(); } catch (Exception ex) { - LogHelper.printException(() -> "Failed to send vote", ex); + LogHelper.printException(() -> "Failed to send vote for video: " + videoId + + " user: " + userId + " vote: " + vote, ex); } return false; } @@ -267,12 +311,14 @@ public class ReturnYouTubeDislikeApi { Objects.requireNonNull(userId); Objects.requireNonNull(solution); - if (checkIfRateLimitInEffect("confirmVote")) { - return false; - } try { + if (checkIfRateLimitInEffect("confirmVote")) { + return false; + } + LogHelper.printDebug(() -> "Trying to confirm vote for video: " + + videoId + " user: " + userId + " solution: " + solution); HttpURLConnection connection = getConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_VOTE); - applyCommonRequestSettings(connection); + applyCommonPostRequestSettings(connection); String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}"; try (OutputStream os = connection.getOutputStream()) { @@ -285,36 +331,38 @@ public class ReturnYouTubeDislikeApi { return false; } - if (responseCode == 200) { - String result = parseJson(connection); - LogHelper.printDebug(() -> "Vote confirmation result was " + result); - connection.disconnect(); - + if (responseCode == SUCCESS_HTTP_STATUS_CODE) { + String result = Requester.parseJson(connection); // also disconnects if (result.equalsIgnoreCase("true")) { - LogHelper.printDebug(() -> "Vote was successful for user " + userId); + LogHelper.printDebug(() -> "Vote confirm successful for video: " + videoId); return true; - } else { - LogHelper.printDebug(() -> "Vote was unsuccessful for user " + userId); - return false; } - } else { - LogHelper.printDebug(() -> "Vote confirmation response was " + responseCode); - connection.disconnect(); + LogHelper.printDebug(() -> "Failed to confirm vote for video: " + videoId + + " user: " + userId + " solution: " + solution + " response string was: " + result); + return false; } + LogHelper.printDebug(() -> "Failed to confirm vote for video: " + videoId + + " user: " + userId + " solution: " + solution + " response code was: " + responseCode); + connection.disconnect(); } catch (Exception ex) { - LogHelper.printException(() -> "Failed to confirm vote", ex); + LogHelper.printException(() -> "Failed to confirm vote for video: " + videoId + + " user: " + userId + " solution: " + solution, ex); } return false; } // utils - private static void applyCommonRequestSettings(HttpURLConnection connection) throws ProtocolException { + private static void applyCommonPostRequestSettings(HttpURLConnection connection) throws ProtocolException { connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Pragma", "no-cache"); + connection.setRequestProperty("Cache-Control", "no-cache"); + connection.setUseCaches(false); connection.setDoOutput(true); - connection.setConnectTimeout(HTTP_CONNECTION_DEFAULT_TIMEOUT); + connection.setConnectTimeout(API_REGISTER_VOTE_DEFAULT_TIMEOUT_MILLISECONDS); // timeout for TCP connection to server + connection.setReadTimeout(API_REGISTER_VOTE_DEFAULT_TIMEOUT_MILLISECONDS); // timeout for server response } // helpers @@ -323,11 +371,8 @@ public class ReturnYouTubeDislikeApi { return Requester.getConnectionFromRoute(RYD_API_URL, route, params); } - private static JSONObject getJSONObject(HttpURLConnection connection) throws Exception { - return Requester.getJSONObject(connection); - } - private static String solvePuzzle(String challenge, int difficulty) { + final long timeSolveStarted = System.currentTimeMillis(); byte[] decodedChallenge = Base64.decode(challenge, Base64.NO_WRAP); byte[] buffer = new byte[20]; @@ -335,25 +380,31 @@ public class ReturnYouTubeDislikeApi { buffer[i] = decodedChallenge[i - 4]; } + MessageDigest md; 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(() -> "Failed to solve puzzle", ex); + md = MessageDigest.getInstance("SHA-512"); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException(ex); // should never happen } - return null; + final int maxCount = (int) (Math.pow(2, difficulty + 1) * 5); + 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 solution = Base64.encodeToString(new byte[]{buffer[0], buffer[1], buffer[2], buffer[3]}, Base64.NO_WRAP); + LogHelper.printDebug(() -> "Found puzzle solution: " + solution + " of difficulty: " + difficulty + + " in: " + (System.currentTimeMillis() - timeSolveStarted) + " ms"); + return solution; + } + } + + // should never be reached + throw new IllegalStateException("Failed to solve puzzle challenge: " + challenge + " of difficulty: " + difficulty); } // https://stackoverflow.com/a/157202 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 f1a9d419..193e3e50 100644 --- a/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java +++ b/app/src/main/java/app/revanced/integrations/utils/ReVancedUtils.java @@ -6,6 +6,12 @@ import android.content.res.Resources; import android.os.Handler; import android.os.Looper; +import java.util.concurrent.Callable; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + import app.revanced.integrations.sponsorblock.player.PlayerType; public class ReVancedUtils { @@ -19,6 +25,44 @@ public class ReVancedUtils { private ReVancedUtils() { } // utility class + /** + * Maximum number of background threads run concurrently + */ + private static final int SHARED_THREAD_POOL_MAXIMUM_BACKGROUND_THREADS = 20; + + /** + * General purpose pool for network calls and other background tasks. + */ + private static final ThreadPoolExecutor backgroundThreadPool = new ThreadPoolExecutor( + 2, // minimum 2 threads always ready to be used + 10, // For any threads over the minimum, keep them alive 10 seconds after they go idle + SHARED_THREAD_POOL_MAXIMUM_BACKGROUND_THREADS, + TimeUnit.SECONDS, + new LinkedBlockingQueue()); + + private static void checkIfPoolHasReachedLimit() { + if (backgroundThreadPool.getActiveCount() >= SHARED_THREAD_POOL_MAXIMUM_BACKGROUND_THREADS) { + // Something is wrong. Background threads are piling up and not completing as expected, + // or some ReVanced code is submitting an unexpected number of background tasks. + LogHelper.printException(() -> "Reached maximum background thread count of " + + SHARED_THREAD_POOL_MAXIMUM_BACKGROUND_THREADS + " threads"); + + // Because this condition will manifest as a slow running app or a memory leak, + // it might be best to show the user a toast or some other suggestion to restart the app. + // TODO? if debug is enabled, show a toast? + } + } + + public static void runOnBackgroundThread(Runnable task) { + checkIfPoolHasReachedLimit(); + backgroundThreadPool.execute(task); + } + + public static Future submitOnBackgroundThread(Callable call) { + checkIfPoolHasReachedLimit(); + return backgroundThreadPool.submit(call); + } + public static boolean containsAny(final String value, final String... targets) { for (String string : targets) if (!string.isEmpty() && value.contains(string)) return true;