fix(Spotify - Spoof client): Handle remaining edge cases to obtain a session (#5285)

Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
This commit is contained in:
oSumAtrIX 2025-07-01 23:11:05 +02:00 committed by GitHub
parent d3ec219a29
commit b2e601f0f0
13 changed files with 262 additions and 169 deletions

View file

@ -14,11 +14,19 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Objects; import java.util.Objects;
import static app.revanced.extension.spotify.misc.fix.Session.FAILED_TO_RENEW_SESSION;
import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR; import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR;
class LoginRequestListener extends NanoHTTPD { class LoginRequestListener extends NanoHTTPD {
LoginRequestListener(int port) { LoginRequestListener(int port) {
super(port); super(port);
try {
start();
} catch (IOException ex) {
Logger.printException(() -> "Failed to start login request listener on port " + port, ex);
throw new RuntimeException(ex);
}
} }
@NonNull @NonNull
@ -31,8 +39,8 @@ class LoginRequestListener extends NanoHTTPD {
LoginRequest loginRequest; LoginRequest loginRequest;
try { try {
loginRequest = LoginRequest.parseFrom(requestBodyInputStream); loginRequest = LoginRequest.parseFrom(requestBodyInputStream);
} catch (IOException e) { } catch (IOException ex) {
Logger.printException(() -> "Failed to parse LoginRequest", e); Logger.printException(() -> "Failed to parse LoginRequest", ex);
return newResponse(INTERNAL_ERROR); return newResponse(INTERNAL_ERROR);
} }
@ -42,52 +50,49 @@ class LoginRequestListener extends NanoHTTPD {
// however a webview can only handle one request at a time due to singleton cookie manager. // 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. // Therefore, synchronize to ensure that only one webview handles the request at a time.
synchronized (this) { synchronized (this) {
try {
loginResponse = getLoginResponse(loginRequest); loginResponse = getLoginResponse(loginRequest);
} catch (Exception ex) {
Logger.printException(() -> "Failed to get login response", ex);
return newResponse(INTERNAL_ERROR);
}
} }
if (loginResponse != null) {
return newResponse(Response.Status.OK, loginResponse); return newResponse(Response.Status.OK, loginResponse);
} }
return newResponse(INTERNAL_ERROR);
}
@Nullable
private static LoginResponse getLoginResponse(@NonNull LoginRequest loginRequest) { private static LoginResponse getLoginResponse(@NonNull LoginRequest loginRequest) {
Session session; Session session;
boolean isInitialLogin = !loginRequest.hasStoredCredential(); if (!loginRequest.hasStoredCredential()) {
if (isInitialLogin) {
Logger.printInfo(() -> "Received request for initial login"); Logger.printInfo(() -> "Received request for initial login");
session = WebApp.currentSession; // Session obtained from WebApp.login. session = WebApp.currentSession; // Session obtained from WebApp.launchLogin, can be null if still in progress.
} else { } else {
Logger.printInfo(() -> "Received request to restore saved session"); Logger.printInfo(() -> "Received request to restore saved session");
session = Session.read(loginRequest.getStoredCredential().getUsername()); session = Session.read(loginRequest.getStoredCredential().getUsername());
} }
return toLoginResponse(session, isInitialLogin); return toLoginResponse(session);
} }
private static LoginResponse toLoginResponse(@Nullable Session session) {
private static LoginResponse toLoginResponse(Session session, boolean isInitialLogin) {
LoginResponse.Builder builder = LoginResponse.newBuilder(); LoginResponse.Builder builder = LoginResponse.newBuilder();
if (session == null) { if (session == null) {
if (isInitialLogin) { Logger.printException(() -> "Session is null. An initial login may still be in progress, returning try again later error");
Logger.printInfo(() -> "Session is null, returning try again later error for initial login");
builder.setError(LoginError.TRY_AGAIN_LATER); 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()) { } else if (session.accessTokenExpired()) {
Logger.printInfo(() -> "Access token has expired, renewing session"); Logger.printInfo(() -> "Access token expired, renewing session");
WebApp.renewSession(session.cookies); WebApp.renewSessionBlocking(session.cookies);
return toLoginResponse(WebApp.currentSession, isInitialLogin); return toLoginResponse(WebApp.currentSession);
} else if (session.username == null) {
Logger.printException(() -> "Session username is null, likely caused by invalid cookies, returning invalid credentials error");
session.delete();
builder.setError(LoginError.INVALID_CREDENTIALS);
} else if (session == FAILED_TO_RENEW_SESSION) {
Logger.printException(() -> "Failed to renew session, likely caused by a timeout, returning try again later error");
builder.setError(LoginError.TRY_AGAIN_LATER);
} else { } else {
session.save(); session.save();
Logger.printInfo(() -> "Returning session for username: " + session.username); Logger.printInfo(() -> "Returning session for username: " + session.username);

View file

@ -29,6 +29,11 @@ class Session {
*/ */
final String cookies; final String cookies;
/**
* Session that represents a failed attempt to renew the session.
*/
static final Session FAILED_TO_RENEW_SESSION = new Session("", "", "");
/** /**
* @param username Username of the account. Empty if this session does not have an authenticated user. * @param username Username of the account. Empty if this session does not have an authenticated user.
* @param accessToken Access token for this session. * @param accessToken Access token for this session.
@ -87,6 +92,13 @@ class Session {
editor.apply(); editor.apply();
} }
void delete() {
Logger.printInfo(() -> "Deleting saved session for username: " + username);
SharedPreferences.Editor editor = Utils.getContext().getSharedPreferences("revanced", MODE_PRIVATE).edit();
editor.remove("session_" + username);
editor.apply();
}
@Nullable @Nullable
static Session read(String username) { static Session read(String username) {
Logger.printInfo(() -> "Reading saved session for username: " + username); Logger.printInfo(() -> "Reading saved session for username: " + username);

View file

@ -10,20 +10,19 @@ public class SpoofClientPatch {
/** /**
* Injection point. * Injection point.
* <br> * <br>
* Start login server. * Launch login server.
*/ */
public static void listen(int port) { public static void launchListener(int port) {
if (listener != null) { if (listener != null) {
Logger.printInfo(() -> "Listener already running on port " + port); Logger.printInfo(() -> "Listener already running on port " + port);
return; return;
} }
try { try {
Logger.printInfo(() -> "Launching listener on port " + port);
listener = new LoginRequestListener(port); listener = new LoginRequestListener(port);
listener.start();
Logger.printInfo(() -> "Listener running on port " + port);
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "listen failure", ex); Logger.printException(() -> "launchListener failure", ex);
} }
} }
@ -32,11 +31,11 @@ public class SpoofClientPatch {
* <br> * <br>
* Launch login web view. * Launch login web view.
*/ */
public static void login(LayoutInflater inflater) { public static void launchLogin(LayoutInflater inflater) {
try { try {
WebApp.login(inflater.getContext()); WebApp.launchLogin(inflater.getContext());
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "login failure", ex); Logger.printException(() -> "launchLogin failure", ex);
} }
} }
} }

View file

@ -5,135 +5,125 @@ import android.app.Dialog;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.os.Build; import android.os.Build;
import android.view.*; import android.view.Window;
import android.view.WindowInsets;
import android.webkit.*; import android.webkit.*;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.spotify.UserAgent;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import app.revanced.extension.shared.Logger; import static app.revanced.extension.spotify.misc.fix.Session.FAILED_TO_RENEW_SESSION;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.spotify.UserAgent;
class WebApp { class WebApp {
private static final String OPEN_SPOTIFY_COM = "open.spotify.com"; 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_URL = "https://" + OPEN_SPOTIFY_COM;
private static final String OPEN_SPOTIFY_COM_PREFERENCES_URL = OPEN_SPOTIFY_COM_URL + "/preferences"; 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=" + private static final String ACCOUNTS_SPOTIFY_COM_LOGIN_URL = "https://accounts.spotify.com/login?allow_password=1"
"https%3A%2F%2Fopen.spotify.com%2Fpreferences"; + "&continue=https%3A%2F%2Fopen.spotify.com%2Fpreferences";
private static final int GET_SESSION_TIMEOUT_SECONDS = 10; private static final int GET_SESSION_TIMEOUT_SECONDS = 10;
private static final String JAVASCRIPT_INTERFACE_NAME = "androidInterface"; private static final String JAVASCRIPT_INTERFACE_NAME = "androidInterface";
private static final String USER_AGENT = getWebUserAgent(); private static final String USER_AGENT = getWebUserAgent();
/**
* A session obtained from the webview after logging in.
*/
@Nullable
static volatile Session currentSession = null;
/** /**
* Current webview in use. Any use of the object must be done on the main thread. * Current webview in use. Any use of the object must be done on the main thread.
*/ */
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
private static volatile WebView currentWebView; private static volatile WebView currentWebView;
/** static void launchLogin(Context context) {
* A session obtained from the webview after logging in or renewing the session. final Dialog dialog = newDialog(context);
*/
@Nullable
static volatile Session currentSession;
static void login(Context context) { Utils.runOnBackgroundThread(() -> {
Logger.printInfo(() -> "Starting login"); Logger.printInfo(() -> "Launching login");
Dialog dialog = new Dialog(context, android.R.style.Theme_Black_NoTitleBar_Fullscreen);
// Ensure that the keyboard does not cover the webview content. // A session must be obtained from a login. Repeat until a session is acquired.
Window window = dialog.getWindow(); boolean isAcquired = false;
//noinspection StatementWithEmptyBody do {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { CountDownLatch onLoggedInLatch = new CountDownLatch(1);
window.getDecorView().setOnApplyWindowInsetsListener((v, insets) -> { CountDownLatch getSessionLatch = new CountDownLatch(1);
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. // Can't use Utils.getContext() here, because autofill won't work.
// See https://stackoverflow.com/a/79182053/11213244. // See https://stackoverflow.com/a/79182053/11213244.
context, launchWebView(context, ACCOUNTS_SPOTIFY_COM_LOGIN_URL, new WebViewCallback() {
new WebViewCallback() {
@Override @Override
void onInitialized(WebView webView) { void onInitialized(WebView webView) {
// Ensure that cookies are cleared before loading the login page. super.onInitialized(webView);
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.setContentView(webView);
dialog.show(); dialog.show();
} }
@Override @Override
void onLoggedIn(String cookies) { void onLoggedIn(String cookies) {
dialog.dismiss(); onLoggedInLatch.countDown();
} }
@Override @Override
void onReceivedSession(WebView webView, Session session) { void onReceivedSession(Session session) {
Logger.printInfo(() -> "Received session from login: " + session); super.onReceivedSession(session);
currentSession = session;
currentWebView = null; getSessionLatch.countDown();
webView.stopLoading(); dialog.dismiss();
webView.destroy();
} }
});
try {
// Wait indefinitely until the user logs in.
onLoggedInLatch.await();
// Wait until the session is received, or timeout.
isAcquired = getSessionLatch.await(GET_SESSION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException ex) {
Logger.printException(() -> "Login interrupted", ex);
Thread.currentThread().interrupt();
} }
); } while (!isAcquired);
});
} }
static void renewSession(String cookies) { static void renewSessionBlocking(String cookies) {
Logger.printInfo(() -> "Renewing session with cookies: " + cookies); Logger.printInfo(() -> "Renewing session with cookies: " + cookies);
CountDownLatch getSessionLatch = new CountDownLatch(1); CountDownLatch getSessionLatch = new CountDownLatch(1);
newWebView( launchWebView(Utils.getContext(), OPEN_SPOTIFY_COM_PREFERENCES_URL, new WebViewCallback() {
Utils.getContext(),
new WebViewCallback() {
@Override @Override
public void onInitialized(WebView webView) { public void onInitialized(WebView webView) {
Logger.printInfo(() -> "Loading URL: " + OPEN_SPOTIFY_COM_PREFERENCES_URL +
" with cookies: " + cookies);
setCookies(cookies); setCookies(cookies);
webView.loadUrl(OPEN_SPOTIFY_COM_PREFERENCES_URL); super.onInitialized(webView);
} }
@Override public void onReceivedSession(Session session) {
public void onReceivedSession(WebView webView, Session session) { super.onReceivedSession(session);
Logger.printInfo(() -> "Received session: " + session);
currentSession = session;
getSessionLatch.countDown(); getSessionLatch.countDown();
currentWebView = null;
webView.stopLoading();
webView.destroy();
} }
} });
);
boolean isAcquired = false;
try { try {
final boolean isAcquired = getSessionLatch.await(GET_SESSION_TIMEOUT_SECONDS, TimeUnit.SECONDS); isAcquired = getSessionLatch.await(GET_SESSION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!isAcquired) { } catch (InterruptedException ex) {
Logger.printException(() -> "Failed to retrieve session within " + GET_SESSION_TIMEOUT_SECONDS + " seconds"); Logger.printException(() -> "Session renewal interrupted", ex);
}
} catch (InterruptedException e) {
Logger.printException(() -> "Interrupted while waiting to retrieve session", e);
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
// Cleanup. if (!isAcquired) {
currentWebView = null; Logger.printException(() -> "Failed to retrieve session within " + GET_SESSION_TIMEOUT_SECONDS + " seconds");
currentSession = FAILED_TO_RENEW_SESSION;
destructWebView();
}
} }
/** /**
@ -141,36 +131,34 @@ class WebApp {
*/ */
abstract static class WebViewCallback { abstract static class WebViewCallback {
void onInitialized(WebView webView) { void onInitialized(WebView webView) {
currentWebView = webView;
currentSession = null; // Reset current session.
} }
void onLoggedIn(String cookies) { void onLoggedIn(String cookies) {
} }
void onReceivedSession(WebView webView, Session session) { void onReceivedSession(Session session) {
Logger.printInfo(() -> "Received session: " + session);
currentSession = session;
destructWebView();
} }
} }
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
private static void newWebView( private static void launchWebView(
Context context, Context context,
String initialUrl,
WebViewCallback webViewCallback WebViewCallback webViewCallback
) { ) {
Utils.runOnMainThreadNowOrLater(() -> { Utils.runOnMainThreadNowOrLater(() -> {
WebView webView = currentWebView; WebView webView = new WebView(context);
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(); WebSettings settings = webView.getSettings();
settings.setDomStorageEnabled(true); settings.setDomStorageEnabled(true);
settings.setJavaScriptEnabled(true); settings.setJavaScriptEnabled(true);
settings.setUserAgentString(USER_AGENT); settings.setUserAgentString(USER_AGENT);
// WebViewClient is always called off the main thread, // WebViewClient is always called off the main thread,
// but callback interface methods are called on the main thread. // but callback interface methods are called on the main thread.
webView.setWebViewClient(new WebViewClient() { webView.setWebViewClient(new WebViewClient() {
@ -209,31 +197,42 @@ class WebApp {
" })" + " })" +
" " + " " +
" }" + " }" +
"});"; "});" +
"if (new URLSearchParams(window.location.search).get('_authfailed') != null) {" +
" " + JAVASCRIPT_INTERFACE_NAME + ".getSession(null, null);" +
"}";
view.evaluateJavascript(getSessionScript, null); view.evaluateJavascript(getSessionScript, null);
} }
}); });
final WebView callbackWebView = webView;
webView.addJavascriptInterface(new Object() { webView.addJavascriptInterface(new Object() {
@SuppressWarnings("unused") @SuppressWarnings("unused")
@JavascriptInterface @JavascriptInterface
public void getSession(String username, String accessToken) { public void getSession(String username, String accessToken) {
Session session = new Session(username, accessToken, getCurrentCookies()); Session session = new Session(username, accessToken, getCurrentCookies());
Utils.runOnMainThread(() -> webViewCallback.onReceivedSession(callbackWebView, session)); Utils.runOnMainThread(() -> webViewCallback.onReceivedSession(session));
} }
}, JAVASCRIPT_INTERFACE_NAME); }, JAVASCRIPT_INTERFACE_NAME);
currentWebView = webView;
CookieManager.getInstance().removeAllCookies((anyRemoved) -> { CookieManager.getInstance().removeAllCookies((anyRemoved) -> {
Logger.printInfo(() -> "Loading URL: " + initialUrl);
webView.loadUrl(initialUrl);
Logger.printInfo(() -> "WebView initialized with user agent: " + USER_AGENT); Logger.printInfo(() -> "WebView initialized with user agent: " + USER_AGENT);
webViewCallback.onInitialized(currentWebView); webViewCallback.onInitialized(webView);
}); });
}); });
} }
private static void destructWebView() {
Utils.runOnMainThreadNowOrLater(() -> {
currentWebView.stopLoading();
currentWebView.destroy();
currentWebView = null;
});
}
private static String getWebUserAgent() { private static String getWebUserAgent() {
String userAgentString = WebSettings.getDefaultUserAgent(Utils.getContext()); String userAgentString = WebSettings.getDefaultUserAgent(Utils.getContext());
try { try {
@ -241,16 +240,36 @@ class WebApp {
.withCommentReplaced("Android", "Windows NT 10.0; Win64; x64") .withCommentReplaced("Android", "Windows NT 10.0; Win64; x64")
.withoutProduct("Mobile") .withoutProduct("Mobile")
.toString(); .toString();
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException ex) {
userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + 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"; "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edge/137.0.0.0";
String fallback = userAgentString; String fallback = userAgentString;
Logger.printException(() -> "Failed to get user agent, falling back to " + fallback, e); Logger.printException(() -> "Failed to get user agent, falling back to " + fallback, ex);
} }
return userAgentString; return userAgentString;
} }
@NonNull
private static Dialog newDialog(Context context) {
Dialog dialog = new Dialog(context, android.R.style.Theme_Black_NoTitleBar_Fullscreen);
dialog.setCancelable(false);
// 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.
}
return dialog;
}
private static String getCurrentCookies() { private static String getCurrentCookies() {
CookieManager cookieManager = CookieManager.getInstance(); CookieManager cookieManager = CookieManager.getInstance();
return cookieManager.getCookie(OPEN_SPOTIFY_COM_URL); return cookieManager.getCookie(OPEN_SPOTIFY_COM_URL);

View file

@ -645,10 +645,10 @@ public final class app/revanced/patches/shared/misc/extension/ExtensionHook {
} }
public final class app/revanced/patches/shared/misc/extension/SharedExtensionPatchKt { public final class app/revanced/patches/shared/misc/extension/SharedExtensionPatchKt {
public static final fun extensionHook (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lapp/revanced/patcher/Fingerprint;)Lapp/revanced/patches/shared/misc/extension/ExtensionHook; public static final fun extensionHook (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lapp/revanced/patcher/Fingerprint;)Lapp/revanced/patches/shared/misc/extension/ExtensionHook;
public static final fun extensionHook (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patches/shared/misc/extension/ExtensionHook; public static final fun extensionHook (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lapp/revanced/patches/shared/misc/extension/ExtensionHook;
public static synthetic fun extensionHook$default (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lapp/revanced/patcher/Fingerprint;ILjava/lang/Object;)Lapp/revanced/patches/shared/misc/extension/ExtensionHook; public static synthetic fun extensionHook$default (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lapp/revanced/patcher/Fingerprint;ILjava/lang/Object;)Lapp/revanced/patches/shared/misc/extension/ExtensionHook;
public static synthetic fun extensionHook$default (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patches/shared/misc/extension/ExtensionHook; public static synthetic fun extensionHook$default (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lapp/revanced/patches/shared/misc/extension/ExtensionHook;
public static final fun sharedExtensionPatch (Ljava/lang/String;[Lapp/revanced/patches/shared/misc/extension/ExtensionHook;)Lapp/revanced/patcher/patch/BytecodePatch; public static final fun sharedExtensionPatch (Ljava/lang/String;[Lapp/revanced/patches/shared/misc/extension/ExtensionHook;)Lapp/revanced/patcher/patch/BytecodePatch;
public static final fun sharedExtensionPatch ([Lapp/revanced/patches/shared/misc/extension/ExtensionHook;)Lapp/revanced/patcher/patch/BytecodePatch; public static final fun sharedExtensionPatch ([Lapp/revanced/patches/shared/misc/extension/ExtensionHook;)Lapp/revanced/patcher/patch/BytecodePatch;
} }

View file

@ -87,8 +87,8 @@ fun sharedExtensionPatch(
class ExtensionHook internal constructor( class ExtensionHook internal constructor(
internal val fingerprint: Fingerprint, internal val fingerprint: Fingerprint,
private val insertIndexResolver: ((Method) -> Int), private val insertIndexResolver: BytecodePatchContext.(Method) -> Int,
private val contextRegisterResolver: (Method) -> String, private val contextRegisterResolver: BytecodePatchContext.(Method) -> String,
) { ) {
context(BytecodePatchContext) context(BytecodePatchContext)
operator fun invoke(extensionClassDescriptor: String) { operator fun invoke(extensionClassDescriptor: String) {
@ -104,13 +104,13 @@ class ExtensionHook internal constructor(
} }
fun extensionHook( fun extensionHook(
insertIndexResolver: ((Method) -> Int) = { 0 }, insertIndexResolver: BytecodePatchContext.(Method) -> Int = { 0 },
contextRegisterResolver: (Method) -> String = { "p0" }, contextRegisterResolver: BytecodePatchContext.(Method) -> String = { "p0" },
fingerprint: Fingerprint, fingerprint: Fingerprint,
) = ExtensionHook(fingerprint, insertIndexResolver, contextRegisterResolver) ) = ExtensionHook(fingerprint, insertIndexResolver, contextRegisterResolver)
fun extensionHook( fun extensionHook(
insertIndexResolver: ((Method) -> Int) = { 0 }, insertIndexResolver: BytecodePatchContext.(Method) -> Int = { 0 },
contextRegisterResolver: (Method) -> String = { "p0" }, contextRegisterResolver: BytecodePatchContext.(Method) -> String = { "p0" },
fingerprintBuilderBlock: FingerprintBuilder.() -> Unit, fingerprintBuilderBlock: FingerprintBuilder.() -> Unit,
) = extensionHook(insertIndexResolver, contextRegisterResolver, fingerprint(block = fingerprintBuilderBlock)) ) = extensionHook(insertIndexResolver, contextRegisterResolver, fingerprint(block = fingerprintBuilderBlock))

View file

@ -2,4 +2,8 @@ package app.revanced.patches.spotify.misc.extension
import app.revanced.patches.shared.misc.extension.sharedExtensionPatch import app.revanced.patches.shared.misc.extension.sharedExtensionPatch
val sharedExtensionPatch = sharedExtensionPatch("spotify", mainActivityOnCreateHook) val sharedExtensionPatch = sharedExtensionPatch(
"spotify",
mainActivityOnCreateHook,
loadOrbitLibraryHook
)

View file

@ -0,0 +1,7 @@
package app.revanced.patches.spotify.misc.extension
import app.revanced.patcher.fingerprint
internal val loadOrbitLibraryFingerprint = fingerprint {
strings("OrbitLibraryLoader", "cst")
}

View file

@ -1,6 +1,26 @@
package app.revanced.patches.spotify.misc.extension package app.revanced.patches.spotify.misc.extension
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
import app.revanced.patches.shared.misc.extension.extensionHook import app.revanced.patches.shared.misc.extension.extensionHook
import app.revanced.patches.spotify.shared.mainActivityOnCreateFingerprint import app.revanced.patches.spotify.shared.mainActivityOnCreateFingerprint
import app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstruction
import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
internal val mainActivityOnCreateHook = extensionHook(fingerprint = mainActivityOnCreateFingerprint) internal val mainActivityOnCreateHook = extensionHook(fingerprint = mainActivityOnCreateFingerprint)
internal val loadOrbitLibraryHook = extensionHook(
insertIndexResolver = {
loadOrbitLibraryFingerprint.stringMatches!!.last().index
},
contextRegisterResolver = { method ->
val contextReferenceIndex = method.indexOfFirstInstruction {
getReference<FieldReference>()?.type == "Landroid/content/Context;"
}
val contextRegister = method.getInstruction<TwoRegisterInstruction>(contextReferenceIndex).registerA
"v$contextRegister"
},
fingerprint = loadOrbitLibraryFingerprint,
)

View file

@ -1,7 +1,11 @@
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 app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstruction
import com.android.tools.smali.dexlib2.AccessFlags import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
internal val getPackageInfoFingerprint = fingerprint { internal val getPackageInfoFingerprint = fingerprint {
strings( strings(
@ -9,7 +13,7 @@ internal val getPackageInfoFingerprint = fingerprint {
) )
} }
internal val startLiborbitFingerprint = fingerprint { internal val loadOrbitLibraryFingerprint = fingerprint {
strings("/liborbit-jni-spotify.so") strings("/liborbit-jni-spotify.so")
} }
@ -20,10 +24,22 @@ internal val startupPageLayoutInflateFingerprint = fingerprint {
strings("blueprintContainer", "gradient", "valuePropositionTextView") strings("blueprintContainer", "gradient", "valuePropositionTextView")
} }
internal val standardIntegrityTokenProviderBuilderFingerprint = fingerprint { internal val runIntegrityVerificationFingerprint = fingerprint {
strings( accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
"standard_pi_init", returns("V")
"outcome", opcodes(
"success" Opcode.CHECK_CAST,
Opcode.INVOKE_VIRTUAL,
Opcode.INVOKE_STATIC, // Calendar.getInstance()
Opcode.MOVE_RESULT_OBJECT,
Opcode.INVOKE_VIRTUAL, // instance.get(6)
Opcode.MOVE_RESULT,
Opcode.IF_EQ, // if (x == instance.get(6)) return
) )
custom { method, _ ->
method.indexOfFirstInstruction {
val reference = getReference<MethodReference>()
reference?.definingClass == "Ljava/util/Calendar;" && reference.name == "get"
} >= 0
}
} }

View file

@ -24,8 +24,8 @@ val spoofClientPatch = bytecodePatch(
name = "Spoof client", name = "Spoof client",
description = "Spoofs the client to fix various functions of the app.", description = "Spoofs the client to fix various functions of the app.",
) { ) {
val port by intOption( val requestListenerPort by intOption(
key = "port", key = "requestListenerPort",
default = 4345, default = 4345,
title = " Login request listener port", title = " Login request listener port",
description = "The port to use for the listener that intercepts and handles login requests. " + description = "The port to use for the listener that intercepts and handles login requests. " +
@ -46,10 +46,10 @@ val spoofClientPatch = bytecodePatch(
"x86", "x86",
"x86_64" "x86_64"
).forEach { architecture -> ).forEach { architecture ->
"https://login5.spotify.com/v3/login" to "http://127.0.0.1:$port/v3/login" inFile "https://login5.spotify.com/v3/login" to "http://127.0.0.1:$requestListenerPort/v3/login" inFile
"lib/$architecture/liborbit-jni-spotify.so" "lib/$architecture/liborbit-jni-spotify.so"
"https://login5.spotify.com/v4/login" to "http://127.0.0.1:$port/v4/login" inFile "https://login5.spotify.com/v4/login" to "http://127.0.0.1:$requestListenerPort/v4/login" inFile
"lib/$architecture/liborbit-jni-spotify.so" "lib/$architecture/liborbit-jni-spotify.so"
} }
}) })
@ -58,6 +58,8 @@ val spoofClientPatch = bytecodePatch(
compatibleWith("com.spotify.music") compatibleWith("com.spotify.music")
execute { execute {
// region Spoof package info.
getPackageInfoFingerprint.method.apply { getPackageInfoFingerprint.method.apply {
// region Spoof signature. // region Spoof signature.
@ -99,28 +101,33 @@ val spoofClientPatch = bytecodePatch(
// endregion // endregion
} }
startLiborbitFingerprint.method.addInstructions( // endregion
// region Spoof client.
loadOrbitLibraryFingerprint.method.addInstructions(
0, 0,
""" """
const/16 v0, $port const/16 v0, $requestListenerPort
invoke-static { v0 }, $EXTENSION_CLASS_DESCRIPTOR->listen(I)V invoke-static { v0 }, $EXTENSION_CLASS_DESCRIPTOR->launchListener(I)V
""" """
) )
startupPageLayoutInflateFingerprint.method.apply { startupPageLayoutInflateFingerprint.method.apply {
val openLoginWebViewDescriptor = val openLoginWebViewDescriptor =
"$EXTENSION_CLASS_DESCRIPTOR->login(Landroid/view/LayoutInflater;)V" "$EXTENSION_CLASS_DESCRIPTOR->launchLogin(Landroid/view/LayoutInflater;)V"
addInstructions( addInstructions(
0, 0,
""" """
move-object/from16 v3, p1 invoke-static/range { p1 .. p1 }, $openLoginWebViewDescriptor
invoke-static { v3 }, $openLoginWebViewDescriptor
""" """
) )
} }
// Early return to block sending bad verdicts to the API. // Early return to block sending bad verdicts to the API.
standardIntegrityTokenProviderBuilderFingerprint.method.returnEarly() runIntegrityVerificationFingerprint.method.returnEarly()
// endregion
} }
} }

View file

@ -2,7 +2,7 @@ package app.revanced.patches.spotify.shared
import app.revanced.patcher.fingerprint import app.revanced.patcher.fingerprint
import app.revanced.patcher.patch.BytecodePatchContext import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patches.spotify.misc.extension.mainActivityOnCreateHook import com.android.tools.smali.dexlib2.AccessFlags
private const val SPOTIFY_MAIN_ACTIVITY = "Lcom/spotify/music/SpotifyMainActivity;" private const val SPOTIFY_MAIN_ACTIVITY = "Lcom/spotify/music/SpotifyMainActivity;"
@ -12,6 +12,9 @@ private const val SPOTIFY_MAIN_ACTIVITY = "Lcom/spotify/music/SpotifyMainActivit
internal const val SPOTIFY_MAIN_ACTIVITY_LEGACY = "Lcom/spotify/music/MainActivity;" internal const val SPOTIFY_MAIN_ACTIVITY_LEGACY = "Lcom/spotify/music/MainActivity;"
internal val mainActivityOnCreateFingerprint = fingerprint { internal val mainActivityOnCreateFingerprint = fingerprint {
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
returns("V")
parameters("Landroid/os/Bundle;")
custom { method, classDef -> custom { method, classDef ->
method.name == "onCreate" && (classDef.type == SPOTIFY_MAIN_ACTIVITY method.name == "onCreate" && (classDef.type == SPOTIFY_MAIN_ACTIVITY
|| classDef.type == SPOTIFY_MAIN_ACTIVITY_LEGACY) || classDef.type == SPOTIFY_MAIN_ACTIVITY_LEGACY)
@ -26,9 +29,10 @@ private var isLegacyAppTarget: Boolean? = null
* supports Spotify integration on Kenwood/Pioneer car stereos. * supports Spotify integration on Kenwood/Pioneer car stereos.
*/ */
context(BytecodePatchContext) context(BytecodePatchContext)
internal val IS_SPOTIFY_LEGACY_APP_TARGET get(): Boolean { internal val IS_SPOTIFY_LEGACY_APP_TARGET
get(): Boolean {
if (isLegacyAppTarget == null) { if (isLegacyAppTarget == null) {
isLegacyAppTarget = mainActivityOnCreateHook.fingerprint.originalClassDef.type == SPOTIFY_MAIN_ACTIVITY_LEGACY isLegacyAppTarget = mainActivityOnCreateFingerprint.originalClassDef.type == SPOTIFY_MAIN_ACTIVITY_LEGACY
} }
return isLegacyAppTarget!! return isLegacyAppTarget!!
} }