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) }