diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 000000000..fcaeeb54c
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ alias(libs.plugins.android.library) apply false
+}
diff --git a/extensions/boostforreddit/stub/build.gradle.kts b/extensions/boostforreddit/stub/build.gradle.kts
index b0bb6f027..b4bee8809 100644
--- a/extensions/boostforreddit/stub/build.gradle.kts
+++ b/extensions/boostforreddit/stub/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/nunl/stub/build.gradle.kts b/extensions/nunl/stub/build.gradle.kts
index a8da923ed..7905271b2 100644
--- a/extensions/nunl/stub/build.gradle.kts
+++ b/extensions/nunl/stub/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/primevideo/stub/build.gradle.kts b/extensions/primevideo/stub/build.gradle.kts
index 2d9865785..7744c0eaa 100644
--- a/extensions/primevideo/stub/build.gradle.kts
+++ b/extensions/primevideo/stub/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/reddit/stub/build.gradle.kts b/extensions/reddit/stub/build.gradle.kts
index b0bb6f027..b4bee8809 100644
--- a/extensions/reddit/stub/build.gradle.kts
+++ b/extensions/reddit/stub/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/shared/library/build.gradle.kts b/extensions/shared/library/build.gradle.kts
index 3cbb56069..95969234f 100644
--- a/extensions/shared/library/build.gradle.kts
+++ b/extensions/shared/library/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id("com.android.library")
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/spotify/build.gradle.kts b/extensions/spotify/build.gradle.kts
index 39d58a022..eb963a007 100644
--- a/extensions/spotify/build.gradle.kts
+++ b/extensions/spotify/build.gradle.kts
@@ -1,7 +1,15 @@
+plugins {
+ alias(libs.plugins.protobuf)
+}
+
dependencies {
compileOnly(project(":extensions:shared:library"))
compileOnly(project(":extensions:spotify:stub"))
compileOnly(libs.annotation)
+
+ implementation(project(":extensions:spotify:utils"))
+ implementation(libs.nanohttpd)
+ implementation(libs.protobuf.javalite)
}
android {
@@ -14,3 +22,19 @@ android {
targetCompatibility = JavaVersion.VERSION_1_8
}
}
+
+protobuf {
+ protoc {
+ artifact = libs.protobuf.protoc.get().toString()
+ }
+
+ generateProtoTasks {
+ all().forEach { task ->
+ task.builtins {
+ create("java") {
+ option("lite")
+ }
+ }
+ }
+ }
+}
diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/LoginRequestListener.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/LoginRequestListener.java
new file mode 100644
index 000000000..613d82bbb
--- /dev/null
+++ b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/LoginRequestListener.java
@@ -0,0 +1,153 @@
+package app.revanced.extension.spotify.misc.fix;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.spotify.login5.v4.proto.Login5.*;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.MessageLite;
+import fi.iki.elonen.NanoHTTPD;
+
+import java.io.ByteArrayInputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Objects;
+
+import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR;
+
+class LoginRequestListener extends NanoHTTPD {
+ LoginRequestListener(int port) {
+ super(port);
+ }
+
+ @NonNull
+ @Override
+ public Response serve(IHTTPSession request) {
+ Logger.printInfo(() -> "Serving request for URI: " + request.getUri());
+
+ InputStream requestBodyInputStream = getRequestBodyInputStream(request);
+
+ LoginRequest loginRequest;
+ try {
+ loginRequest = LoginRequest.parseFrom(requestBodyInputStream);
+ } catch (IOException e) {
+ Logger.printException(() -> "Failed to parse LoginRequest", e);
+ return newResponse(INTERNAL_ERROR);
+ }
+
+ MessageLite loginResponse;
+
+ // A request may be made concurrently by Spotify,
+ // however a webview can only handle one request at a time due to singleton cookie manager.
+ // Therefore, synchronize to ensure that only one webview handles the request at a time.
+ synchronized (this) {
+ loginResponse = getLoginResponse(loginRequest);
+ }
+
+ if (loginResponse != null) {
+ return newResponse(Response.Status.OK, loginResponse);
+ }
+
+ return newResponse(INTERNAL_ERROR);
+ }
+
+
+ @Nullable
+ private static LoginResponse getLoginResponse(@NonNull LoginRequest loginRequest) {
+ Session session;
+
+ boolean isInitialLogin = !loginRequest.hasStoredCredential();
+ if (isInitialLogin) {
+ Logger.printInfo(() -> "Received request for initial login");
+ session = WebApp.currentSession; // Session obtained from WebApp.login.
+ } else {
+ Logger.printInfo(() -> "Received request to restore saved session");
+ session = Session.read(loginRequest.getStoredCredential().getUsername());
+ }
+
+ return toLoginResponse(session, isInitialLogin);
+ }
+
+
+ private static LoginResponse toLoginResponse(Session session, boolean isInitialLogin) {
+ LoginResponse.Builder builder = LoginResponse.newBuilder();
+
+ if (session == null) {
+ if (isInitialLogin) {
+ Logger.printInfo(() -> "Session is null, returning try again later error for initial login");
+ builder.setError(LoginError.TRY_AGAIN_LATER);
+ } else {
+ Logger.printInfo(() -> "Session is null, returning invalid credentials error for stored credential login");
+ builder.setError(LoginError.INVALID_CREDENTIALS);
+ }
+ } else if (session.username == null) {
+ Logger.printInfo(() -> "Session username is null, returning invalid credentials error");
+ builder.setError(LoginError.INVALID_CREDENTIALS);
+ } else if (session.accessTokenExpired()) {
+ Logger.printInfo(() -> "Access token has expired, renewing session");
+ WebApp.renewSession(session.cookies);
+ return toLoginResponse(WebApp.currentSession, isInitialLogin);
+ } else {
+ session.save();
+ Logger.printInfo(() -> "Returning session for username: " + session.username);
+ builder.setOk(LoginOk.newBuilder()
+ .setUsername(session.username)
+ .setAccessToken(session.accessToken)
+ .setStoredCredential(ByteString.fromHex("00")) // Placeholder, as it cannot be null or empty.
+ .setAccessTokenExpiresIn(session.accessTokenExpiresInSeconds())
+ .build());
+ }
+
+ return builder.build();
+ }
+
+ @NonNull
+ private static InputStream limitedInputStream(InputStream inputStream, long contentLength) {
+ return new FilterInputStream(inputStream) {
+ private long remaining = contentLength;
+
+ @Override
+ public int read() throws IOException {
+ if (remaining <= 0) return -1;
+ int result = super.read();
+ if (result != -1) remaining--;
+ return result;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ if (remaining <= 0) return -1;
+ len = (int) Math.min(len, remaining);
+ int result = super.read(b, off, len);
+ if (result != -1) remaining -= result;
+ return result;
+ }
+ };
+ }
+
+ @NonNull
+ private static InputStream getRequestBodyInputStream(@NonNull IHTTPSession request) {
+ long requestContentLength =
+ Long.parseLong(Objects.requireNonNull(request.getHeaders().get("content-length")));
+ return limitedInputStream(request.getInputStream(), requestContentLength);
+ }
+
+
+ @SuppressWarnings("SameParameterValue")
+ @NonNull
+ private static Response newResponse(Response.Status status) {
+ return newResponse(status, null);
+ }
+
+ @NonNull
+ private static Response newResponse(Response.IStatus status, MessageLite messageLite) {
+ if (messageLite == null) {
+ return newFixedLengthResponse(status, "application/x-protobuf", null);
+ }
+
+ byte[] messageBytes = messageLite.toByteArray();
+ InputStream stream = new ByteArrayInputStream(messageBytes);
+ return newFixedLengthResponse(status, "application/x-protobuf", stream, messageBytes.length);
+ }
+}
diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/Session.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/Session.java
new file mode 100644
index 000000000..6e7f38cde
--- /dev/null
+++ b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/Session.java
@@ -0,0 +1,124 @@
+package app.revanced.extension.spotify.misc.fix;
+
+import android.content.SharedPreferences;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import static android.content.Context.MODE_PRIVATE;
+
+class Session {
+ /**
+ * Username of the account. Null if this session does not have an authenticated user.
+ */
+ @Nullable
+ final String username;
+ /**
+ * Access token for this session.
+ */
+ final String accessToken;
+ /**
+ * Session expiration timestamp in milliseconds.
+ */
+ final Long expirationTime;
+ /**
+ * Authentication cookies for this session.
+ */
+ final String cookies;
+
+ /**
+ * @param username Username of the account. Empty if this session does not have an authenticated user.
+ * @param accessToken Access token for this session.
+ * @param cookies Authentication cookies for this session.
+ */
+ Session(@Nullable String username, String accessToken, String cookies) {
+ this(username, accessToken, System.currentTimeMillis() + 60 * 60 * 1000, cookies);
+ }
+
+ private Session(@Nullable String username, String accessToken, long expirationTime, String cookies) {
+ this.username = username;
+ this.accessToken = accessToken;
+ this.expirationTime = expirationTime;
+ this.cookies = cookies;
+ }
+
+ /**
+ * @return The number of milliseconds until the access token expires.
+ */
+ long accessTokenExpiresInMillis() {
+ long currentTime = System.currentTimeMillis();
+ return expirationTime - currentTime;
+ }
+
+ /**
+ * @return The number of seconds until the access token expires.
+ */
+ int accessTokenExpiresInSeconds() {
+ return (int) accessTokenExpiresInMillis() / 1000;
+ }
+
+ /**
+ * @return True if the access token has expired, false otherwise.
+ */
+ boolean accessTokenExpired() {
+ return accessTokenExpiresInMillis() <= 0;
+ }
+
+ void save() {
+ Logger.printInfo(() -> "Saving session: " + this);
+
+ SharedPreferences.Editor editor = Utils.getContext().getSharedPreferences("revanced", MODE_PRIVATE).edit();
+
+ String json;
+ try {
+ json = new JSONObject()
+ .put("accessToken", accessToken)
+ .put("expirationTime", expirationTime)
+ .put("cookies", cookies).toString();
+ } catch (JSONException ex) {
+ Logger.printException(() -> "Failed to convert session to stored credential", ex);
+ return;
+ }
+
+ editor.putString("session_" + username, json);
+ editor.apply();
+ }
+
+ @Nullable
+ static Session read(String username) {
+ Logger.printInfo(() -> "Reading saved session for username: " + username);
+
+ SharedPreferences sharedPreferences = Utils.getContext().getSharedPreferences("revanced", MODE_PRIVATE);
+ String savedJson = sharedPreferences.getString("session_" + username, null);
+ if (savedJson == null) {
+ Logger.printInfo(() -> "No session found in shared preferences");
+ return null;
+ }
+
+ try {
+ JSONObject json = new JSONObject(savedJson);
+ String accessToken = json.getString("accessToken");
+ long expirationTime = json.getLong("expirationTime");
+ String cookies = json.getString("cookies");
+
+ return new Session(username, accessToken, expirationTime, cookies);
+ } catch (JSONException ex) {
+ Logger.printException(() -> "Failed to read session from shared preferences", ex);
+ return null;
+ }
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "Session(" +
+ "username=" + username +
+ ", accessToken=" + accessToken +
+ ", expirationTime=" + expirationTime +
+ ", cookies=" + cookies +
+ ')';
+ }
+}
diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/SpoofClientPatch.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/SpoofClientPatch.java
new file mode 100644
index 000000000..8d01edaf7
--- /dev/null
+++ b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/SpoofClientPatch.java
@@ -0,0 +1,42 @@
+package app.revanced.extension.spotify.misc.fix;
+
+import android.view.LayoutInflater;
+import app.revanced.extension.shared.Logger;
+
+@SuppressWarnings("unused")
+public class SpoofClientPatch {
+ private static LoginRequestListener listener;
+
+ /**
+ * Injection point.
+ *
+ * Start login server.
+ */
+ public static void listen(int port) {
+ if (listener != null) {
+ Logger.printInfo(() -> "Listener already running on port " + port);
+ return;
+ }
+
+ try {
+ listener = new LoginRequestListener(port);
+ listener.start();
+ Logger.printInfo(() -> "Listener running on port " + port);
+ } catch (Exception ex) {
+ Logger.printException(() -> "listen failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * Launch login web view.
+ */
+ public static void login(LayoutInflater inflater) {
+ try {
+ WebApp.login(inflater.getContext());
+ } catch (Exception ex) {
+ Logger.printException(() -> "login failure", ex);
+ }
+ }
+}
diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/WebApp.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/WebApp.java
new file mode 100644
index 000000000..082c7833f
--- /dev/null
+++ b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/WebApp.java
@@ -0,0 +1,267 @@
+package app.revanced.extension.spotify.misc.fix;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.view.*;
+import android.webkit.*;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.spotify.UserAgent;
+
+class WebApp {
+ private static final String OPEN_SPOTIFY_COM = "open.spotify.com";
+ private static final String OPEN_SPOTIFY_COM_URL = "https://" + OPEN_SPOTIFY_COM;
+ private static final String OPEN_SPOTIFY_COM_PREFERENCES_URL = OPEN_SPOTIFY_COM_URL + "/preferences";
+ private static final String ACCOUNTS_SPOTIFY_COM_LOGIN_URL = "https://accounts.spotify.com/login?continue=" +
+ "https%3A%2F%2Fopen.spotify.com%2Fpreferences";
+
+ private static final int GET_SESSION_TIMEOUT_SECONDS = 10;
+ private static final String JAVASCRIPT_INTERFACE_NAME = "androidInterface";
+ private static final String USER_AGENT = getWebUserAgent();
+
+ /**
+ * Current webview in use. Any use of the object must be done on the main thread.
+ */
+ @SuppressLint("StaticFieldLeak")
+ private static volatile WebView currentWebView;
+
+ /**
+ * A session obtained from the webview after logging in or renewing the session.
+ */
+ @Nullable
+ static volatile Session currentSession;
+
+ static void login(Context context) {
+ Logger.printInfo(() -> "Starting login");
+
+ Dialog dialog = new Dialog(context, android.R.style.Theme_Black_NoTitleBar_Fullscreen);
+
+ // Ensure that the keyboard does not cover the webview content.
+ Window window = dialog.getWindow();
+ //noinspection StatementWithEmptyBody
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ window.getDecorView().setOnApplyWindowInsetsListener((v, insets) -> {
+ v.setPadding(0, 0, 0, insets.getInsets(WindowInsets.Type.ime()).bottom);
+
+ return WindowInsets.CONSUMED;
+ });
+ } else {
+ // TODO: Implement for lower Android versions.
+ }
+
+ newWebView(
+ // Can't use Utils.getContext() here, because autofill won't work.
+ // See https://stackoverflow.com/a/79182053/11213244.
+ context,
+ new WebViewCallback() {
+ @Override
+ void onInitialized(WebView webView) {
+ // Ensure that cookies are cleared before loading the login page.
+ CookieManager.getInstance().removeAllCookies((anyRemoved) -> {
+ Logger.printInfo(() -> "Loading URL: " + ACCOUNTS_SPOTIFY_COM_LOGIN_URL);
+ webView.loadUrl(ACCOUNTS_SPOTIFY_COM_LOGIN_URL);
+ });
+
+ dialog.setCancelable(false);
+ dialog.setContentView(webView);
+ dialog.show();
+ }
+
+ @Override
+ void onLoggedIn(String cookies) {
+ dialog.dismiss();
+ }
+
+ @Override
+ void onReceivedSession(WebView webView, Session session) {
+ Logger.printInfo(() -> "Received session from login: " + session);
+ currentSession = session;
+ currentWebView = null;
+ webView.stopLoading();
+ webView.destroy();
+ }
+ }
+ );
+ }
+
+ static void renewSession(String cookies) {
+ Logger.printInfo(() -> "Renewing session with cookies: " + cookies);
+
+ CountDownLatch getSessionLatch = new CountDownLatch(1);
+
+ newWebView(
+ Utils.getContext(),
+ new WebViewCallback() {
+ @Override
+ public void onInitialized(WebView webView) {
+ Logger.printInfo(() -> "Loading URL: " + OPEN_SPOTIFY_COM_PREFERENCES_URL +
+ " with cookies: " + cookies);
+ setCookies(cookies);
+ webView.loadUrl(OPEN_SPOTIFY_COM_PREFERENCES_URL);
+ }
+
+ @Override
+ public void onReceivedSession(WebView webView, Session session) {
+ Logger.printInfo(() -> "Received session: " + session);
+ currentSession = session;
+ getSessionLatch.countDown();
+ currentWebView = null;
+ webView.stopLoading();
+ webView.destroy();
+ }
+ }
+ );
+
+ try {
+ final boolean isAcquired = getSessionLatch.await(GET_SESSION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ if (!isAcquired) {
+ Logger.printException(() -> "Failed to retrieve session within " + GET_SESSION_TIMEOUT_SECONDS + " seconds");
+ }
+ } catch (InterruptedException e) {
+ Logger.printException(() -> "Interrupted while waiting to retrieve session", e);
+ Thread.currentThread().interrupt();
+ }
+
+ // Cleanup.
+ currentWebView = null;
+ }
+
+ /**
+ * All methods are called on the main thread.
+ */
+ abstract static class WebViewCallback {
+ void onInitialized(WebView webView) {
+ }
+
+ void onLoggedIn(String cookies) {
+ }
+
+ void onReceivedSession(WebView webView, Session session) {
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private static void newWebView(
+ Context context,
+ WebViewCallback webViewCallback
+ ) {
+ Utils.runOnMainThreadNowOrLater(() -> {
+ WebView webView = currentWebView;
+ if (webView != null) {
+ // Old webview is still hanging around.
+ // Could happen if the network request failed and thus no callback is made.
+ // But in practice this never happens.
+ Logger.printException(() -> "Cleaning up prior webview");
+ webView.stopLoading();
+ webView.destroy();
+ }
+
+ webView = new WebView(context);
+ WebSettings settings = webView.getSettings();
+ settings.setDomStorageEnabled(true);
+ settings.setJavaScriptEnabled(true);
+ settings.setUserAgentString(USER_AGENT);
+ // WebViewClient is always called off the main thread,
+ // but callback interface methods are called on the main thread.
+ webView.setWebViewClient(new WebViewClient() {
+ @Override
+ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
+ if (OPEN_SPOTIFY_COM.equals(request.getUrl().getHost())) {
+ Utils.runOnMainThread(() -> webViewCallback.onLoggedIn(getCurrentCookies()));
+ }
+
+ return super.shouldInterceptRequest(view, request);
+ }
+
+ @Override
+ public void onPageStarted(WebView view, String url, Bitmap favicon) {
+ Logger.printInfo(() -> "Page started loading: " + url);
+
+ if (!url.startsWith(OPEN_SPOTIFY_COM_URL)) {
+ return;
+ }
+
+ Logger.printInfo(() -> "Evaluating script to get session on url: " + url);
+ String getSessionScript = "Object.defineProperty(Object.prototype, \"_username\", {" +
+ " configurable: true," +
+ " set(username) {" +
+ " accessToken = this._builder?.accessToken;" +
+ " if (accessToken) {" +
+ " " + JAVASCRIPT_INTERFACE_NAME + ".getSession(username, accessToken);" +
+ " delete Object.prototype._username;" +
+ " }" +
+ " " +
+ " Object.defineProperty(this, \"_username\", {" +
+ " configurable: true," +
+ " enumerable: true," +
+ " writable: true," +
+ " value: username" +
+ " })" +
+ " " +
+ " }" +
+ "});";
+
+ view.evaluateJavascript(getSessionScript, null);
+ }
+ });
+
+ final WebView callbackWebView = webView;
+ webView.addJavascriptInterface(new Object() {
+ @SuppressWarnings("unused")
+ @JavascriptInterface
+ public void getSession(String username, String accessToken) {
+ Session session = new Session(username, accessToken, getCurrentCookies());
+ Utils.runOnMainThread(() -> webViewCallback.onReceivedSession(callbackWebView, session));
+ }
+ }, JAVASCRIPT_INTERFACE_NAME);
+
+ currentWebView = webView;
+
+ CookieManager.getInstance().removeAllCookies((anyRemoved) -> {
+ Logger.printInfo(() -> "WebView initialized with user agent: " + USER_AGENT);
+ webViewCallback.onInitialized(currentWebView);
+ });
+ });
+ }
+
+ private static String getWebUserAgent() {
+ String userAgentString = WebSettings.getDefaultUserAgent(Utils.getContext());
+ try {
+ return new UserAgent(userAgentString)
+ .withCommentReplaced("Android", "Windows NT 10.0; Win64; x64")
+ .withoutProduct("Mobile")
+ .toString();
+ } catch (IllegalArgumentException e) {
+ userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edge/137.0.0.0";
+ String fallback = userAgentString;
+ Logger.printException(() -> "Failed to get user agent, falling back to " + fallback, e);
+ }
+
+ return userAgentString;
+ }
+
+ private static String getCurrentCookies() {
+ CookieManager cookieManager = CookieManager.getInstance();
+ return cookieManager.getCookie(OPEN_SPOTIFY_COM_URL);
+ }
+
+ private static void setCookies(@NonNull String cookies) {
+ CookieManager cookieManager = CookieManager.getInstance();
+
+ String[] cookiesList = cookies.split(";");
+ for (String cookie : cookiesList) {
+ cookieManager.setCookie(OPEN_SPOTIFY_COM_URL, cookie);
+ }
+ }
+}
diff --git a/extensions/spotify/src/main/proto/login5.proto b/extensions/spotify/src/main/proto/login5.proto
new file mode 100644
index 000000000..7d8e38d24
--- /dev/null
+++ b/extensions/spotify/src/main/proto/login5.proto
@@ -0,0 +1,43 @@
+syntax = "proto3";
+
+package spotify.login5.v4;
+
+option optimize_for = LITE_RUNTIME;
+option java_package = "app.revanced.extension.spotify.login5.v4.proto";
+
+message StoredCredential {
+ string username = 1;
+ bytes data = 2;
+}
+
+message LoginRequest {
+ oneof login_method {
+ StoredCredential stored_credential = 100;
+ }
+}
+
+message LoginOk {
+ string username = 1;
+ string access_token = 2;
+ bytes stored_credential = 3;
+ int32 access_token_expires_in = 4;
+}
+
+message LoginResponse {
+ oneof response {
+ LoginOk ok = 1;
+ LoginError error = 2;
+ }
+}
+
+enum LoginError {
+ UNKNOWN_ERROR = 0;
+ INVALID_CREDENTIALS = 1;
+ BAD_REQUEST = 2;
+ UNSUPPORTED_LOGIN_PROTOCOL = 3;
+ TIMEOUT = 4;
+ UNKNOWN_IDENTIFIER = 5;
+ TOO_MANY_ATTEMPTS = 6;
+ INVALID_PHONENUMBER = 7;
+ TRY_AGAIN_LATER = 8;
+}
diff --git a/extensions/spotify/stub/build.gradle.kts b/extensions/spotify/stub/build.gradle.kts
index 489664c26..e31f1e322 100644
--- a/extensions/spotify/stub/build.gradle.kts
+++ b/extensions/spotify/stub/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/spotify/utils/build.gradle.kts b/extensions/spotify/utils/build.gradle.kts
new file mode 100644
index 000000000..3cdce1cc0
--- /dev/null
+++ b/extensions/spotify/utils/build.gradle.kts
@@ -0,0 +1,19 @@
+plugins {
+ java
+ antlr
+}
+
+dependencies {
+ antlr(libs.antlr4)
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+}
+
+tasks {
+ generateGrammarSource {
+ arguments = listOf("-visitor")
+ }
+}
diff --git a/extensions/spotify/utils/src/main/antlr/app/revanced/extension/spotify/UserAgent.g4 b/extensions/spotify/utils/src/main/antlr/app/revanced/extension/spotify/UserAgent.g4
new file mode 100644
index 000000000..afd3ba3f9
--- /dev/null
+++ b/extensions/spotify/utils/src/main/antlr/app/revanced/extension/spotify/UserAgent.g4
@@ -0,0 +1,35 @@
+grammar UserAgent;
+
+@header { package app.revanced.extension.spotify; }
+
+userAgent
+ : product (WS product)* EOF
+ ;
+
+product
+ : name ('/' version)? (WS comment)?
+ ;
+
+name
+ : STRING
+ ;
+
+version
+ : STRING ('.' STRING)*
+ ;
+
+comment
+ : COMMENT
+ ;
+
+COMMENT
+ : '(' ~ ')'* ')'
+ ;
+
+STRING
+ : [a-zA-Z0-9]+
+ ;
+
+WS
+ : [ \r\n]+
+ ;
\ No newline at end of file
diff --git a/extensions/spotify/utils/src/main/java/app/revanced/extension/spotify/UserAgent.java b/extensions/spotify/utils/src/main/java/app/revanced/extension/spotify/UserAgent.java
new file mode 100644
index 000000000..e25912f86
--- /dev/null
+++ b/extensions/spotify/utils/src/main/java/app/revanced/extension/spotify/UserAgent.java
@@ -0,0 +1,60 @@
+package app.revanced.extension.spotify;
+
+import org.antlr.v4.runtime.CharStream;
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.TokenStreamRewriter;
+import org.antlr.v4.runtime.tree.ParseTreeWalker;
+
+public class UserAgent {
+ private final UserAgentParser.UserAgentContext tree;
+ private final TokenStreamRewriter rewriter;
+ private final ParseTreeWalker walker;
+
+ public UserAgent(String userAgentString) {
+ CharStream input = CharStreams.fromString(userAgentString);
+ UserAgentLexer lexer = new UserAgentLexer(input);
+ CommonTokenStream tokens = new CommonTokenStream(lexer);
+
+ tree = new UserAgentParser(tokens).userAgent();
+ walker = new ParseTreeWalker();
+ rewriter = new TokenStreamRewriter(tokens);
+ }
+
+ public UserAgent withoutProduct(String name) {
+ walker.walk(new UserAgentBaseListener() {
+ @Override
+ public void exitProduct(UserAgentParser.ProductContext ctx) {
+ if (!ctx.name().getText().contains(name)) return;
+
+ int startIndex = ctx.getStart().getTokenIndex();
+ if (startIndex != 0) startIndex -= 1; // Also remove the preceding whitespace.
+
+ int stopIndex = ctx.getStop().getTokenIndex();
+
+
+ rewriter.delete(startIndex, stopIndex);
+ }
+ }, tree);
+
+ return new UserAgent(rewriter.getText().trim());
+ }
+
+ public UserAgent withCommentReplaced(String containing, String replacement) {
+ walker.walk(new UserAgentBaseListener() {
+ @Override
+ public void exitComment(UserAgentParser.CommentContext ctx) {
+ if (ctx.getText().contains(containing)) {
+ rewriter.replace(ctx.getStart(), ctx.getStop(), "(" + replacement + ")");
+ }
+ }
+ }, tree);
+
+ return new UserAgent(rewriter.getText());
+ }
+
+ @Override
+ public String toString() {
+ return rewriter.getText();
+ }
+}
diff --git a/extensions/syncforreddit/stub/build.gradle.kts b/extensions/syncforreddit/stub/build.gradle.kts
index b0bb6f027..b4bee8809 100644
--- a/extensions/syncforreddit/stub/build.gradle.kts
+++ b/extensions/syncforreddit/stub/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/tiktok/stub/build.gradle.kts b/extensions/tiktok/stub/build.gradle.kts
index 798b5a681..de3358345 100644
--- a/extensions/tiktok/stub/build.gradle.kts
+++ b/extensions/tiktok/stub/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/tumblr/stub/build.gradle.kts b/extensions/tumblr/stub/build.gradle.kts
index 3b8616f8e..c1e0b1722 100644
--- a/extensions/tumblr/stub/build.gradle.kts
+++ b/extensions/tumblr/stub/build.gradle.kts
@@ -1,7 +1,7 @@
android.namespace = "app.revanced.extension"
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/twitch/stub/build.gradle.kts b/extensions/twitch/stub/build.gradle.kts
index 42cf7d038..ffdfac5a6 100644
--- a/extensions/twitch/stub/build.gradle.kts
+++ b/extensions/twitch/stub/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/extensions/youtube/stub/build.gradle.kts b/extensions/youtube/stub/build.gradle.kts
index 3ec9f09a8..318046019 100644
--- a/extensions/youtube/stub/build.gradle.kts
+++ b/extensions/youtube/stub/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.android.library.get().pluginId)
+ alias(libs.plugins.android.library)
}
android {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index bac3e0dd9..6af604481 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,15 +11,25 @@ appcompat = "1.7.0"
okhttp = "5.0.0-alpha.14"
retrofit = "2.11.0"
guava = "33.4.0-jre"
+protobuf-javalite = "4.31.1"
+protoc = "4.31.1"
+protobuf = "0.9.5"
+antlr4 = "4.13.2"
+nanohttpd = "2.3.1"
apksig = "8.10.1"
[libraries]
annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" }
+antlr4 = { module = "org.antlr:antlr4", version.ref = "antlr4" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
+protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf-javalite" }
+protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protoc" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
guava = { module = "com.google.guava:guava", version.ref = "guava" }
apksig = { group = "com.android.tools.build", name = "apksig", version.ref = "apksig" }
[plugins]
-android-library = { id = "com.android.library", version.ref = "agp" }
+android-library = { id = "com.android.library" }
+protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 68e8816d7..5205bd795 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,8 +1,6 @@
+#Mon Jun 16 14:39:32 CEST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
-networkTimeout=10000
-validateDistributionUrl=true
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/patches/api/patches.api b/patches/api/patches.api
index 69981ad6c..bbb98099c 100644
--- a/patches/api/patches.api
+++ b/patches/api/patches.api
@@ -668,17 +668,39 @@ public final class app/revanced/patches/shared/misc/gms/GmsCoreSupportPatchKt {
public static synthetic fun gmsCoreSupportResourcePatch$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lapp/revanced/patcher/patch/Option;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patcher/patch/ResourcePatch;
}
-public final class app/revanced/patches/shared/misc/hex/HexPatchKt {
- public static final fun hexPatch (Lkotlin/jvm/functions/Function0;)Lapp/revanced/patcher/patch/RawResourcePatch;
+public final class app/revanced/patches/shared/misc/hex/HexPatchBuilder : java/util/Set, kotlin/jvm/internal/markers/KMappedMarker {
+ public fun ()V
+ public fun add (Lapp/revanced/patches/shared/misc/hex/Replacement;)Z
+ public synthetic fun add (Ljava/lang/Object;)Z
+ public fun addAll (Ljava/util/Collection;)Z
+ public final fun asPatternTo (Ljava/lang/String;Ljava/lang/String;)Lkotlin/Pair;
+ public fun clear ()V
+ public fun contains (Lapp/revanced/patches/shared/misc/hex/Replacement;)Z
+ public final fun contains (Ljava/lang/Object;)Z
+ public fun containsAll (Ljava/util/Collection;)Z
+ public fun getSize ()I
+ public final fun inFile (Lkotlin/Pair;Ljava/lang/String;)V
+ public fun isEmpty ()Z
+ public fun iterator ()Ljava/util/Iterator;
+ public fun remove (Ljava/lang/Object;)Z
+ public fun removeAll (Ljava/util/Collection;)Z
+ public fun retainAll (Ljava/util/Collection;)Z
+ public final fun size ()I
+ public fun toArray ()[Ljava/lang/Object;
+ public fun toArray ([Ljava/lang/Object;)[Ljava/lang/Object;
+}
+
+public final class app/revanced/patches/shared/misc/hex/HexPatchBuilderKt {
+ public static final fun hexPatch (ZLkotlin/jvm/functions/Function0;)Lapp/revanced/patcher/patch/RawResourcePatch;
+ public static final fun hexPatch (ZLkotlin/jvm/functions/Function1;)Lapp/revanced/patcher/patch/RawResourcePatch;
+ public static synthetic fun hexPatch$default (ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lapp/revanced/patcher/patch/RawResourcePatch;
+ public static synthetic fun hexPatch$default (ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patcher/patch/RawResourcePatch;
}
public final class app/revanced/patches/shared/misc/hex/Replacement {
- public static final field Companion Lapp/revanced/patches/shared/misc/hex/Replacement$Companion;
public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
- public final fun replacePattern ([B)V
-}
-
-public final class app/revanced/patches/shared/misc/hex/Replacement$Companion {
+ public fun ([B[BLjava/lang/String;)V
+ public final fun getReplacementBytesPadded ()[B
}
public final class app/revanced/patches/shared/misc/mapping/ResourceElement {
@@ -922,6 +944,10 @@ public final class app/revanced/patches/spotify/misc/extension/ExtensionPatchKt
public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
}
+public final class app/revanced/patches/spotify/misc/fix/SpoofClientPatchKt {
+ public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
+}
+
public final class app/revanced/patches/spotify/misc/fix/SpoofPackageInfoPatchKt {
public static final fun getSpoofPackageInfoPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
}
diff --git a/patches/src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt b/patches/src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt
index a8bb80517..9a9e427ec 100644
--- a/patches/src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/all/misc/hex/HexPatch.kt
@@ -3,7 +3,7 @@ package app.revanced.patches.all.misc.hex
import app.revanced.patcher.patch.PatchException
import app.revanced.patcher.patch.rawResourcePatch
import app.revanced.patcher.patch.stringsOption
-import app.revanced.patches.shared.misc.hex.Replacement
+import app.revanced.patches.shared.misc.hex.HexPatchBuilder
import app.revanced.patches.shared.misc.hex.hexPatch
import app.revanced.util.Utils.trimIndentMultiline
@@ -13,44 +13,42 @@ val hexPatch = rawResourcePatch(
description = "Replaces a hexadecimal patterns of bytes of files in an APK.",
use = false,
) {
- // TODO: Instead of stringArrayOption, use a custom option type to work around
- // https://github.com/ReVanced/revanced-library/issues/48.
- // Replace the custom option type with a stringArrayOption once the issue is resolved.
val replacements by stringsOption(
key = "replacements",
title = "Replacements",
description = """
Hexadecimal patterns to search for and replace with another in a target file.
-
+
A pattern is a sequence of case insensitive strings, each representing hexadecimal bytes, separated by spaces.
An example pattern is 'aa 01 02 FF'.
Every pattern must be followed by a pipe ('|'), the replacement pattern,
another pipe ('|'), and the path to the file to make the changes in relative to the APK root.
- The replacement pattern must have the same length as the original pattern.
+ The replacement pattern must be shorter or equal in length to the pattern.
- Full example of a valid input:
- 'aa 01 02 FF|00 00 00 00|path/to/file'
+ Full example of a valid replacement:
+ '01 02 aa FF|03 04|path/to/file'
""".trimIndentMultiline(),
required = true,
)
dependsOn(
- hexPatch {
- replacements!!.map { from ->
- val (pattern, replacementPattern, targetFilePath) = try {
- from.split("|", limit = 3)
- } catch (e: Exception) {
- throw PatchException(
- "Invalid input: $from.\n" +
- "Every pattern must be followed by a pipe ('|'), " +
- "the replacement pattern, another pipe ('|'), " +
- "and the path to the file to make the changes in relative to the APK root. ",
- )
+ hexPatch(
+ block = fun HexPatchBuilder.() {
+ replacements!!.forEach { replacement ->
+ try {
+ val (pattern, replacementPattern, targetFilePath) = replacement.split("|", limit = 3)
+ pattern asPatternTo replacementPattern inFile targetFilePath
+ } catch (e: Exception) {
+ throw PatchException(
+ "Invalid replacement: $replacement.\n" +
+ "Every pattern must be followed by a pipe ('|'), " +
+ "the replacement pattern, another pipe ('|'), " +
+ "and the path to the file to make the changes in relative to the APK root. ",
+ )
+ }
}
-
- Replacement(pattern, replacementPattern, targetFilePath)
- }.toSet()
- },
+ },
+ )
)
}
diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/hex/HexPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/hex/HexPatch.kt
deleted file mode 100644
index d361a5571..000000000
--- a/patches/src/main/kotlin/app/revanced/patches/shared/misc/hex/HexPatch.kt
+++ /dev/null
@@ -1,123 +0,0 @@
-package app.revanced.patches.shared.misc.hex
-
-import app.revanced.patcher.patch.PatchException
-import app.revanced.patcher.patch.rawResourcePatch
-import kotlin.math.max
-
-// The replacements being passed using a function is intended.
-// Previously the replacements were a property of the patch. Getter were being delegated to that property.
-// This late evaluation was being leveraged in app.revanced.patches.all.misc.hex.HexPatch.
-// Without the function, the replacements would be evaluated at the time of patch creation.
-// This isn't possible because the delegated property is not accessible at that time.
-fun hexPatch(replacementsSupplier: () -> Set) = rawResourcePatch {
- execute {
- replacementsSupplier().groupBy { it.targetFilePath }.forEach { (targetFilePath, replacements) ->
- val targetFile = try {
- get(targetFilePath, true)
- } catch (e: Exception) {
- throw PatchException("Could not find target file: $targetFilePath")
- }
-
- // TODO: Use a file channel to read and write the file instead of reading the whole file into memory,
- // in order to reduce memory usage.
- val targetFileBytes = targetFile.readBytes()
-
- replacements.forEach { replacement ->
- replacement.replacePattern(targetFileBytes)
- }
-
- targetFile.writeBytes(targetFileBytes)
- }
- }
-}
-
-/**
- * Represents a pattern to search for and its replacement pattern.
- *
- * @property pattern The pattern to search for.
- * @property replacementPattern The pattern to replace the [pattern] with.
- * @property targetFilePath The path to the file to make the changes in relative to the APK root.
- */
-class Replacement(
- private val pattern: String,
- replacementPattern: String,
- internal val targetFilePath: String,
-) {
- private val patternBytes = pattern.toByteArrayPattern()
- private val replacementPattern = replacementPattern.toByteArrayPattern()
-
- init {
- if (this.patternBytes.size != this.replacementPattern.size) {
- throw PatchException("Pattern and replacement pattern must have the same length: $pattern")
- }
- }
-
- /**
- * Replaces the [patternBytes] with the [replacementPattern] in the [targetFileBytes].
- *
- * @param targetFileBytes The bytes of the file to make the changes in.
- */
- fun replacePattern(targetFileBytes: ByteArray) {
- val startIndex = indexOfPatternIn(targetFileBytes)
-
- if (startIndex == -1) {
- throw PatchException("Pattern not found in target file: $pattern")
- }
-
- replacementPattern.copyInto(targetFileBytes, startIndex)
- }
-
- // TODO: Allow searching in a file channel instead of a byte array to reduce memory usage.
- /**
- * Returns the index of the first occurrence of [patternBytes] in the haystack
- * using the Boyer-Moore algorithm.
- *
- * @param haystack The array to search in.
- *
- * @return The index of the first occurrence of the [patternBytes] in the haystack or -1
- * if the [patternBytes] is not found.
- */
- private fun indexOfPatternIn(haystack: ByteArray): Int {
- val needle = patternBytes
-
- val haystackLength = haystack.size - 1
- val needleLength = needle.size - 1
- val right = IntArray(256) { -1 }
-
- for (i in 0 until needleLength) right[needle[i].toInt().and(0xFF)] = i
-
- var skip: Int
- for (i in 0..haystackLength - needleLength) {
- skip = 0
-
- for (j in needleLength - 1 downTo 0) {
- if (needle[j] != haystack[i + j]) {
- skip = max(1, j - right[haystack[i + j].toInt().and(0xFF)])
-
- break
- }
- }
-
- if (skip == 0) return i
- }
- return -1
- }
-
- companion object {
- /**
- * Convert a string representing a pattern of hexadecimal bytes to a byte array.
- *
- * @return The byte array representing the pattern.
- * @throws PatchException If the pattern is invalid.
- */
- private fun String.toByteArrayPattern() = try {
- split(" ").map { it.toInt(16).toByte() }.toByteArray()
- } catch (e: NumberFormatException) {
- throw PatchException(
- "Could not parse pattern: $this. A pattern is a sequence of case insensitive strings " +
- "representing hexadecimal bytes separated by spaces",
- e,
- )
- }
- }
-}
diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/misc/hex/HexPatchBuilder.kt b/patches/src/main/kotlin/app/revanced/patches/shared/misc/hex/HexPatchBuilder.kt
new file mode 100644
index 000000000..8b6bc4dc2
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/shared/misc/hex/HexPatchBuilder.kt
@@ -0,0 +1,154 @@
+package app.revanced.patches.shared.misc.hex
+
+import app.revanced.patcher.patch.PatchException
+import app.revanced.patcher.patch.rawResourcePatch
+import kotlin.collections.component1
+import kotlin.collections.component2
+import kotlin.math.max
+
+fun hexPatch(ignoreMissingTargetFiles: Boolean = false, block: HexPatchBuilder.() -> Unit) =
+ hexPatch(ignoreMissingTargetFiles, fun(): Set = HexPatchBuilder().apply(block))
+
+@Suppress("JavaDefaultMethodsNotOverriddenByDelegation")
+class HexPatchBuilder internal constructor(
+ private val replacements: MutableSet = mutableSetOf(),
+) : Set by replacements {
+ infix fun String.asPatternTo(replacementPattern: String) = byteArrayOf(this) to byteArrayOf(replacementPattern)
+
+ infix fun Pair.inFile(filePath: String) {
+ if (first is String && second is String) {
+ val first = first as String
+ val second = second as String
+
+ replacements += Replacement(
+ first.toByteArray(), second.toByteArray(),
+ filePath
+ )
+ } else if (first is ByteArray && second is ByteArray) {
+ val first = first as ByteArray
+ val second = second as ByteArray
+
+ replacements += Replacement(first, second, filePath)
+ } else {
+ throw PatchException("Unsupported types for pattern and replacement: $first, $second")
+ }
+ }
+}
+
+// The replacements being passed using a function is intended.
+// Previously the replacements were a property of the patch. Getter were being delegated to that property.
+// This late evaluation was being leveraged in app.revanced.patches.all.misc.hex.HexPatch.
+// Without the function, the replacements would be evaluated at the time of patch creation.
+// This isn't possible because the delegated property is not accessible at that time.
+@Deprecated("Use the hexPatch function with the builder parameter instead.")
+fun hexPatch(ignoreMissingTargetFiles: Boolean = false, replacementsSupplier: () -> Set) =
+ rawResourcePatch {
+ execute {
+ replacementsSupplier().groupBy { it.targetFilePath }.forEach { (targetFilePath, replacements) ->
+ val targetFile = get(targetFilePath, true)
+ if (ignoreMissingTargetFiles && !targetFile.exists()) return@forEach
+
+ // TODO: Use a file channel to read and write the file instead of reading the whole file into memory,
+ // in order to reduce memory usage.
+ val targetFileBytes = targetFile.readBytes()
+ replacements.forEach { it.replacePattern(targetFileBytes) }
+ targetFile.writeBytes(targetFileBytes)
+ }
+ }
+ }
+
+/**
+ * Represents a pattern to search for and its replacement pattern in a file.
+ *
+ * @property bytes The bytes to search for.
+ * @property replacementBytes The bytes to replace the [bytes] with.
+ * @property targetFilePath The path to the file to make the changes in relative to the APK root.
+ */
+class Replacement(
+ private val bytes: ByteArray,
+ replacementBytes: ByteArray,
+ internal val targetFilePath: String,
+) {
+ val replacementBytesPadded = replacementBytes + ByteArray(bytes.size - replacementBytes.size)
+
+ @Deprecated("Use the constructor with ByteArray parameters instead.")
+ constructor(
+ pattern: String,
+ replacementPattern: String,
+ targetFilePath: String,
+ ) : this(
+ byteArrayOf(pattern),
+ byteArrayOf(replacementPattern),
+ targetFilePath
+ )
+
+ /**
+ * Replaces the [bytes] with the [replacementBytes] in the [targetFileBytes].
+ *
+ * @param targetFileBytes The bytes of the file to make the changes in.
+ */
+ internal fun replacePattern(targetFileBytes: ByteArray) {
+ val startIndex = indexOfPatternIn(targetFileBytes)
+
+ if (startIndex == -1) {
+ throw PatchException(
+ "Pattern not found in target file: " +
+ bytes.joinToString(" ") { "%02x".format(it) }
+ )
+ }
+
+ replacementBytesPadded.copyInto(targetFileBytes, startIndex)
+ }
+
+ // TODO: Allow searching in a file channel instead of a byte array to reduce memory usage.
+ /**
+ * Returns the index of the first occurrence of [bytes] in the haystack
+ * using the Boyer-Moore algorithm.
+ *
+ * @param haystack The array to search in.
+ *
+ * @return The index of the first occurrence of the [bytes] in the haystack or -1
+ * if the [bytes] is not found.
+ */
+ private fun indexOfPatternIn(haystack: ByteArray): Int {
+ val needle = bytes
+
+ val haystackLength = haystack.size - 1
+ val needleLength = needle.size - 1
+ val right = IntArray(256) { -1 }
+
+ for (i in 0 until needleLength) right[needle[i].toInt().and(0xFF)] = i
+
+ var skip: Int
+ for (i in 0..haystackLength - needleLength) {
+ skip = 0
+
+ for (j in needleLength - 1 downTo 0) {
+ if (needle[j] != haystack[i + j]) {
+ skip = max(1, j - right[haystack[i + j].toInt().and(0xFF)])
+
+ break
+ }
+ }
+
+ if (skip == 0) return i
+ }
+ return -1
+ }
+}
+
+/**
+ * Convert a string representing a pattern of hexadecimal bytes to a byte array.
+ *
+ * @return The byte array representing the pattern.
+ * @throws PatchException If the pattern is invalid.
+ */
+private fun byteArrayOf(pattern: String) = try {
+ pattern.split(" ").map { it.toInt(16).toByte() }.toByteArray()
+} catch (e: NumberFormatException) {
+ throw PatchException(
+ "Could not parse pattern: $pattern. A pattern is a sequence of case insensitive strings " +
+ "representing hexadecimal bytes separated by spaces",
+ e,
+ )
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/Fingerprints.kt
index 6b25bc630..f1600861a 100644
--- a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/Fingerprints.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/Fingerprints.kt
@@ -1,9 +1,21 @@
package app.revanced.patches.spotify.misc.fix
import app.revanced.patcher.fingerprint
+import com.android.tools.smali.dexlib2.AccessFlags
internal val getPackageInfoFingerprint = fingerprint {
strings(
"Failed to get the application signatures"
)
}
+
+internal val startLiborbitFingerprint = fingerprint {
+ strings("/liborbit-jni-spotify.so")
+}
+
+internal val startupPageLayoutInflateFingerprint = fingerprint {
+ accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
+ returns("Landroid/view/View;")
+ parameters("Landroid/view/LayoutInflater;", "Landroid/view/ViewGroup;", "Landroid/os/Bundle;")
+ strings("blueprintContainer", "gradient", "valuePropositionTextView")
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofClientPatch.kt
new file mode 100644
index 000000000..70339813d
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofClientPatch.kt
@@ -0,0 +1,122 @@
+package app.revanced.patches.spotify.misc.fix
+
+import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
+import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
+import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
+import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction
+import app.revanced.patcher.patch.bytecodePatch
+import app.revanced.patcher.patch.intOption
+import app.revanced.patches.shared.misc.hex.HexPatchBuilder
+import app.revanced.patches.shared.misc.hex.hexPatch
+import app.revanced.patches.spotify.misc.extension.sharedExtensionPatch
+import app.revanced.util.findInstructionIndicesReversedOrThrow
+import app.revanced.util.getReference
+import app.revanced.util.indexOfFirstInstructionReversedOrThrow
+import com.android.tools.smali.dexlib2.Opcode
+import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
+import com.android.tools.smali.dexlib2.iface.reference.MethodReference
+
+internal const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/spotify/misc/fix/SpoofClientPatch;"
+
+@Suppress("unused")
+val spoofClientPatch = bytecodePatch(
+ name = "Spoof client",
+ description = "Spoofs the client to fix various functions of the app.",
+) {
+ val port by intOption(
+ key = "port",
+ default = 4345,
+ title = " Login request listener port",
+ description = "The port to use for the listener that intercepts and handles login requests. " +
+ "Port must be between 0 and 65535.",
+ required = true,
+ validator = {
+ it!!
+ !(it < 0 || it > 65535)
+ }
+ )
+
+ dependsOn(
+ sharedExtensionPatch,
+ hexPatch(ignoreMissingTargetFiles = true, block = fun HexPatchBuilder.() {
+ listOf(
+ "arm64-v8a",
+ "armeabi-v7a",
+ "x86",
+ "x86_64"
+ ).forEach { architecture ->
+ "https://login5.spotify.com/v3/login" to "http://127.0.0.1:$port/v3/login" inFile
+ "lib/$architecture/liborbit-jni-spotify.so"
+
+ "https://login5.spotify.com/v4/login" to "http://127.0.0.1:$port/v4/login" inFile
+ "lib/$architecture/liborbit-jni-spotify.so"
+ }
+ })
+ )
+
+ compatibleWith("com.spotify.music")
+
+ execute {
+ getPackageInfoFingerprint.method.apply {
+ // region Spoof signature.
+
+ val failedToGetSignaturesStringIndex =
+ getPackageInfoFingerprint.stringMatches!!.first().index
+
+ val concatSignaturesIndex = indexOfFirstInstructionReversedOrThrow(
+ failedToGetSignaturesStringIndex,
+ Opcode.MOVE_RESULT_OBJECT,
+ )
+
+ val signatureRegister = getInstruction(concatSignaturesIndex).registerA
+ val expectedSignature = "d6a6dced4a85f24204bf9505ccc1fce114cadb32"
+
+ replaceInstruction(concatSignaturesIndex, "const-string v$signatureRegister, \"$expectedSignature\"")
+
+ // endregion
+
+ // region Spoof installer name.
+
+ val expectedInstallerName = "com.android.vending"
+
+ findInstructionIndicesReversedOrThrow {
+ val reference = getReference()
+ reference?.name == "getInstallerPackageName" || reference?.name == "getInstallingPackageName"
+ }.forEach { index ->
+ val returnObjectIndex = index + 1
+
+ val installerPackageNameRegister = getInstruction(
+ returnObjectIndex
+ ).registerA
+
+ addInstruction(
+ returnObjectIndex + 1,
+ "const-string v$installerPackageNameRegister, \"$expectedInstallerName\""
+ )
+ }
+
+ // endregion
+ }
+
+ startLiborbitFingerprint.method.addInstructions(
+ 0,
+ """
+ const/16 v0, $port
+ invoke-static { v0 }, $EXTENSION_CLASS_DESCRIPTOR->listen(I)V
+ """
+ )
+
+ startupPageLayoutInflateFingerprint.method.apply {
+ val openLoginWebViewDescriptor =
+ "$EXTENSION_CLASS_DESCRIPTOR->login(Landroid/view/LayoutInflater;)V"
+
+ addInstructions(
+ 0,
+ """
+ move-object/from16 v3, p1
+ invoke-static { v3 }, $openLoginWebViewDescriptor
+ """
+ )
+ }
+ }
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofPackageInfoPatch.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofPackageInfoPatch.kt
index 58606777d..20f9b3b4e 100644
--- a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofPackageInfoPatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofPackageInfoPatch.kt
@@ -1,63 +1,11 @@
package app.revanced.patches.spotify.misc.fix
-import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
-import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
-import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction
import app.revanced.patcher.patch.bytecodePatch
-import app.revanced.util.findInstructionIndicesReversedOrThrow
-import app.revanced.util.getReference
-import app.revanced.util.indexOfFirstInstructionReversedOrThrow
-import com.android.tools.smali.dexlib2.Opcode
-import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
-import com.android.tools.smali.dexlib2.iface.reference.MethodReference
+@Deprecated("Superseded by spoofClientPatch", ReplaceWith("spoofClientPatch"))
@Suppress("unused")
val spoofPackageInfoPatch = bytecodePatch(
- name = "Spoof package info",
description = "Spoofs the package info of the app to fix various functions of the app.",
) {
- compatibleWith("com.spotify.music")
-
- execute {
- getPackageInfoFingerprint.method.apply {
- // region Spoof signature.
-
- val failedToGetSignaturesStringIndex =
- getPackageInfoFingerprint.stringMatches!!.first().index
-
- val concatSignaturesIndex = indexOfFirstInstructionReversedOrThrow(
- failedToGetSignaturesStringIndex,
- Opcode.MOVE_RESULT_OBJECT,
- )
-
- val signatureRegister = getInstruction(concatSignaturesIndex).registerA
- val expectedSignature = "d6a6dced4a85f24204bf9505ccc1fce114cadb32"
-
- replaceInstruction(concatSignaturesIndex, "const-string v$signatureRegister, \"$expectedSignature\"")
-
- // endregion
-
- // region Spoof installer name.
-
- val expectedInstallerName = "com.android.vending"
-
- findInstructionIndicesReversedOrThrow {
- val reference = getReference()
- reference?.name == "getInstallerPackageName" || reference?.name == "getInstallingPackageName"
- }.forEach { index ->
- val returnObjectIndex = index + 1
-
- val installerPackageNameRegister = getInstruction(
- returnObjectIndex
- ).registerA
-
- addInstruction(
- returnObjectIndex + 1,
- "const-string v$installerPackageNameRegister, \"$expectedInstallerName\""
- )
- }
-
- // endregion
- }
- }
+ dependsOn(spoofClientPatch)
}
diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofSignaturePatch.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofSignaturePatch.kt
index d59969c6c..238da0f41 100644
--- a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofSignaturePatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofSignaturePatch.kt
@@ -2,10 +2,10 @@ package app.revanced.patches.spotify.misc.fix
import app.revanced.patcher.patch.bytecodePatch
-@Deprecated("Superseded by spoofPackageInfoPatch", ReplaceWith("spoofPackageInfoPatch"))
+@Deprecated("Superseded by spoofClientPatch", ReplaceWith("spoofClientPatch"))
@Suppress("unused")
val spoofSignaturePatch = bytecodePatch(
description = "Spoofs the signature of the app fix various functions of the app.",
) {
- dependsOn(spoofPackageInfoPatch)
+ dependsOn(spoofClientPatch)
}