fix(Spotify): Add Spoof client patch to fix various issues by using a web platform access token (#5173)

Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: brosssh <tiabroch@gmail.com>
Co-authored-by: Dawid Krajcarz <80264606+drobotk@users.noreply.github.com>
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
This commit is contained in:
oSumAtrIX
2025-06-26 19:51:18 +02:00
committed by GitHub
parent 1804bd9bfc
commit 1a8aacdff6
31 changed files with 1140 additions and 225 deletions

3
build.gradle.kts Normal file
View File

@@ -0,0 +1,3 @@
plugins {
alias(libs.plugins.android.library) apply false
}

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id("com.android.library") alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -1,7 +1,15 @@
plugins {
alias(libs.plugins.protobuf)
}
dependencies { dependencies {
compileOnly(project(":extensions:shared:library")) compileOnly(project(":extensions:shared:library"))
compileOnly(project(":extensions:spotify:stub")) compileOnly(project(":extensions:spotify:stub"))
compileOnly(libs.annotation) compileOnly(libs.annotation)
implementation(project(":extensions:spotify:utils"))
implementation(libs.nanohttpd)
implementation(libs.protobuf.javalite)
} }
android { android {
@@ -14,3 +22,19 @@ android {
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
} }
} }
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
}
}
}
}

View File

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

View File

@@ -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 +
')';
}
}

View File

@@ -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.
* <br>
* 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.
* <br>
* Launch login web view.
*/
public static void login(LayoutInflater inflater) {
try {
WebApp.login(inflater.getContext());
} catch (Exception ex) {
Logger.printException(() -> "login failure", ex);
}
}
}

View File

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

View File

@@ -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;
}

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

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

View File

@@ -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]+
;

View File

@@ -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();
}
}

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -1,7 +1,7 @@
android.namespace = "app.revanced.extension" android.namespace = "app.revanced.extension"
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -1,5 +1,5 @@
plugins { plugins {
id(libs.plugins.android.library.get().pluginId) alias(libs.plugins.android.library)
} }
android { android {

View File

@@ -11,15 +11,25 @@ appcompat = "1.7.0"
okhttp = "5.0.0-alpha.14" okhttp = "5.0.0-alpha.14"
retrofit = "2.11.0" retrofit = "2.11.0"
guava = "33.4.0-jre" 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" apksig = "8.10.1"
[libraries] [libraries]
annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } 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" } 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" } 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" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
guava = { module = "com.google.guava:guava", version.ref = "guava" } guava = { module = "com.google.guava:guava", version.ref = "guava" }
apksig = { group = "com.android.tools.build", name = "apksig", version.ref = "apksig" } apksig = { group = "com.android.tools.build", name = "apksig", version.ref = "apksig" }
[plugins] [plugins]
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library" }
protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }

View File

@@ -1,8 +1,6 @@
#Mon Jun 16 14:39:32 CEST 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@@ -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 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 final class app/revanced/patches/shared/misc/hex/HexPatchBuilder : java/util/Set, kotlin/jvm/internal/markers/KMappedMarker {
public static final fun hexPatch (Lkotlin/jvm/functions/Function0;)Lapp/revanced/patcher/patch/RawResourcePatch; public fun <init> ()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 final class app/revanced/patches/shared/misc/hex/Replacement {
public static final field Companion Lapp/revanced/patches/shared/misc/hex/Replacement$Companion;
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
public final fun replacePattern ([B)V public fun <init> ([B[BLjava/lang/String;)V
} public final fun getReplacementBytesPadded ()[B
public final class app/revanced/patches/shared/misc/hex/Replacement$Companion {
} }
public final class app/revanced/patches/shared/misc/mapping/ResourceElement { 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 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 final class app/revanced/patches/spotify/misc/fix/SpoofPackageInfoPatchKt {
public static final fun getSpoofPackageInfoPatch ()Lapp/revanced/patcher/patch/BytecodePatch; public static final fun getSpoofPackageInfoPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
} }

View File

@@ -3,7 +3,7 @@ package app.revanced.patches.all.misc.hex
import app.revanced.patcher.patch.PatchException import app.revanced.patcher.patch.PatchException
import app.revanced.patcher.patch.rawResourcePatch import app.revanced.patcher.patch.rawResourcePatch
import app.revanced.patcher.patch.stringsOption 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.patches.shared.misc.hex.hexPatch
import app.revanced.util.Utils.trimIndentMultiline import app.revanced.util.Utils.trimIndentMultiline
@@ -13,9 +13,6 @@ val hexPatch = rawResourcePatch(
description = "Replaces a hexadecimal patterns of bytes of files in an APK.", description = "Replaces a hexadecimal patterns of bytes of files in an APK.",
use = false, 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( val replacements by stringsOption(
key = "replacements", key = "replacements",
title = "Replacements", title = "Replacements",
@@ -27,30 +24,31 @@ val hexPatch = rawResourcePatch(
Every pattern must be followed by a pipe ('|'), the replacement pattern, 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. 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: Full example of a valid replacement:
'aa 01 02 FF|00 00 00 00|path/to/file' '01 02 aa FF|03 04|path/to/file'
""".trimIndentMultiline(), """.trimIndentMultiline(),
required = true, required = true,
) )
dependsOn( dependsOn(
hexPatch { hexPatch(
replacements!!.map { from -> block = fun HexPatchBuilder.() {
val (pattern, replacementPattern, targetFilePath) = try { replacements!!.forEach { replacement ->
from.split("|", limit = 3) try {
} catch (e: Exception) { val (pattern, replacementPattern, targetFilePath) = replacement.split("|", limit = 3)
throw PatchException( pattern asPatternTo replacementPattern inFile targetFilePath
"Invalid input: $from.\n" + } catch (e: Exception) {
"Every pattern must be followed by a pipe ('|'), " + throw PatchException(
"the replacement pattern, another pipe ('|'), " + "Invalid replacement: $replacement.\n" +
"and the path to the file to make the changes in relative to the APK root. ", "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()
},
) )
} }

View File

@@ -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<Replacement>) = 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,
)
}
}
}

View File

@@ -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<Replacement> = HexPatchBuilder().apply(block))
@Suppress("JavaDefaultMethodsNotOverriddenByDelegation")
class HexPatchBuilder internal constructor(
private val replacements: MutableSet<Replacement> = mutableSetOf(),
) : Set<Replacement> by replacements {
infix fun String.asPatternTo(replacementPattern: String) = byteArrayOf(this) to byteArrayOf(replacementPattern)
infix fun <T> Pair<T, T>.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<Replacement>) =
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,
)
}

View File

@@ -1,9 +1,21 @@
package app.revanced.patches.spotify.misc.fix package app.revanced.patches.spotify.misc.fix
import app.revanced.patcher.fingerprint import app.revanced.patcher.fingerprint
import com.android.tools.smali.dexlib2.AccessFlags
internal val getPackageInfoFingerprint = fingerprint { internal val getPackageInfoFingerprint = fingerprint {
strings( strings(
"Failed to get the application signatures" "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")
}

View File

@@ -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<OneRegisterInstruction>(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<MethodReference>()
reference?.name == "getInstallerPackageName" || reference?.name == "getInstallingPackageName"
}.forEach { index ->
val returnObjectIndex = index + 1
val installerPackageNameRegister = getInstruction<OneRegisterInstruction>(
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
"""
)
}
}
}

View File

@@ -1,63 +1,11 @@
package app.revanced.patches.spotify.misc.fix 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.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") @Suppress("unused")
val spoofPackageInfoPatch = bytecodePatch( val spoofPackageInfoPatch = bytecodePatch(
name = "Spoof package info",
description = "Spoofs the package info of the app to fix various functions of the app.", description = "Spoofs the package info of the app to fix various functions of the app.",
) { ) {
compatibleWith("com.spotify.music") dependsOn(spoofClientPatch)
execute {
getPackageInfoFingerprint.method.apply {
// region Spoof signature.
val failedToGetSignaturesStringIndex =
getPackageInfoFingerprint.stringMatches!!.first().index
val concatSignaturesIndex = indexOfFirstInstructionReversedOrThrow(
failedToGetSignaturesStringIndex,
Opcode.MOVE_RESULT_OBJECT,
)
val signatureRegister = getInstruction<OneRegisterInstruction>(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<MethodReference>()
reference?.name == "getInstallerPackageName" || reference?.name == "getInstallingPackageName"
}.forEach { index ->
val returnObjectIndex = index + 1
val installerPackageNameRegister = getInstruction<OneRegisterInstruction>(
returnObjectIndex
).registerA
addInstruction(
returnObjectIndex + 1,
"const-string v$installerPackageNameRegister, \"$expectedInstallerName\""
)
}
// endregion
}
}
} }

View File

@@ -2,10 +2,10 @@ package app.revanced.patches.spotify.misc.fix
import app.revanced.patcher.patch.bytecodePatch import app.revanced.patcher.patch.bytecodePatch
@Deprecated("Superseded by spoofPackageInfoPatch", ReplaceWith("spoofPackageInfoPatch")) @Deprecated("Superseded by spoofClientPatch", ReplaceWith("spoofClientPatch"))
@Suppress("unused") @Suppress("unused")
val spoofSignaturePatch = bytecodePatch( val spoofSignaturePatch = bytecodePatch(
description = "Spoofs the signature of the app fix various functions of the app.", description = "Spoofs the signature of the app fix various functions of the app.",
) { ) {
dependsOn(spoofPackageInfoPatch) dependsOn(spoofClientPatch)
} }