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.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;
class LoginRequestListener extends NanoHTTPD {
LoginRequestListener(int 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
@@ -31,8 +39,8 @@ class LoginRequestListener extends NanoHTTPD {
LoginRequest loginRequest;
try {
loginRequest = LoginRequest.parseFrom(requestBodyInputStream);
} catch (IOException e) {
Logger.printException(() -> "Failed to parse LoginRequest", e);
} catch (IOException ex) {
Logger.printException(() -> "Failed to parse LoginRequest", ex);
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.
// Therefore, synchronize to ensure that only one webview handles the request at a time.
synchronized (this) {
loginResponse = getLoginResponse(loginRequest);
try {
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(INTERNAL_ERROR);
return newResponse(Response.Status.OK, loginResponse);
}
@Nullable
private static LoginResponse getLoginResponse(@NonNull LoginRequest loginRequest) {
Session session;
boolean isInitialLogin = !loginRequest.hasStoredCredential();
if (isInitialLogin) {
if (!loginRequest.hasStoredCredential()) {
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 {
Logger.printInfo(() -> "Received request to restore saved session");
session = Session.read(loginRequest.getStoredCredential().getUsername());
}
return toLoginResponse(session, isInitialLogin);
return toLoginResponse(session);
}
private static LoginResponse toLoginResponse(Session session, boolean isInitialLogin) {
private static LoginResponse toLoginResponse(@Nullable Session session) {
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);
Logger.printException(() -> "Session is null. An initial login may still be in progress, returning try again later error");
builder.setError(LoginError.TRY_AGAIN_LATER);
} else if (session.accessTokenExpired()) {
Logger.printInfo(() -> "Access token has expired, renewing session");
WebApp.renewSession(session.cookies);
return toLoginResponse(WebApp.currentSession, isInitialLogin);
Logger.printInfo(() -> "Access token expired, renewing session");
WebApp.renewSessionBlocking(session.cookies);
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 {
session.save();
Logger.printInfo(() -> "Returning session for username: " + session.username);

View File

@@ -29,6 +29,11 @@ class Session {
*/
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 accessToken Access token for this session.
@@ -87,6 +92,13 @@ class Session {
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
static Session read(String username) {
Logger.printInfo(() -> "Reading saved session for username: " + username);

View File

@@ -10,20 +10,19 @@ public class SpoofClientPatch {
/**
* Injection point.
* <br>
* Start login server.
* Launch login server.
*/
public static void listen(int port) {
public static void launchListener(int port) {
if (listener != null) {
Logger.printInfo(() -> "Listener already running on port " + port);
return;
}
try {
Logger.printInfo(() -> "Launching listener on port " + port);
listener = new LoginRequestListener(port);
listener.start();
Logger.printInfo(() -> "Listener running on port " + port);
} catch (Exception ex) {
Logger.printException(() -> "listen failure", ex);
Logger.printException(() -> "launchListener failure", ex);
}
}
@@ -32,11 +31,11 @@ public class SpoofClientPatch {
* <br>
* Launch login web view.
*/
public static void login(LayoutInflater inflater) {
public static void launchLogin(LayoutInflater inflater) {
try {
WebApp.login(inflater.getContext());
WebApp.launchLogin(inflater.getContext());
} 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.graphics.Bitmap;
import android.os.Build;
import android.view.*;
import android.view.Window;
import android.view.WindowInsets;
import android.webkit.*;
import androidx.annotation.NonNull;
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.TimeUnit;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.spotify.UserAgent;
import static app.revanced.extension.spotify.misc.fix.Session.FAILED_TO_RENEW_SESSION;
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 String ACCOUNTS_SPOTIFY_COM_LOGIN_URL = "https://accounts.spotify.com/login?allow_password=1"
+ "&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();
/**
* 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.
*/
@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 launchLogin(Context context) {
final Dialog dialog = newDialog(context);
static void login(Context context) {
Logger.printInfo(() -> "Starting login");
Utils.runOnBackgroundThread(() -> {
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.
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);
// A session must be obtained from a login. Repeat until a session is acquired.
boolean isAcquired = false;
do {
CountDownLatch onLoggedInLatch = new CountDownLatch(1);
CountDownLatch getSessionLatch = new CountDownLatch(1);
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() {
launchWebView(context, ACCOUNTS_SPOTIFY_COM_LOGIN_URL, 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);
});
super.onInitialized(webView);
dialog.setCancelable(false);
dialog.setContentView(webView);
dialog.show();
}
@Override
void onLoggedIn(String cookies) {
dialog.dismiss();
onLoggedInLatch.countDown();
}
@Override
void onReceivedSession(WebView webView, Session session) {
Logger.printInfo(() -> "Received session from login: " + session);
currentSession = session;
currentWebView = null;
webView.stopLoading();
webView.destroy();
void onReceivedSession(Session session) {
super.onReceivedSession(session);
getSessionLatch.countDown();
dialog.dismiss();
}
});
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);
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");
launchWebView(Utils.getContext(), OPEN_SPOTIFY_COM_PREFERENCES_URL, new WebViewCallback() {
@Override
public void onInitialized(WebView webView) {
setCookies(cookies);
super.onInitialized(webView);
}
} catch (InterruptedException e) {
Logger.printException(() -> "Interrupted while waiting to retrieve session", e);
public void onReceivedSession(Session session) {
super.onReceivedSession(session);
getSessionLatch.countDown();
}
});
boolean isAcquired = false;
try {
isAcquired = getSessionLatch.await(GET_SESSION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException ex) {
Logger.printException(() -> "Session renewal interrupted", ex);
Thread.currentThread().interrupt();
}
// Cleanup.
currentWebView = null;
if (!isAcquired) {
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 {
void onInitialized(WebView webView) {
currentWebView = webView;
currentSession = null; // Reset current session.
}
void onLoggedIn(String cookies) {
}
void onReceivedSession(WebView webView, Session session) {
void onReceivedSession(Session session) {
Logger.printInfo(() -> "Received session: " + session);
currentSession = session;
destructWebView();
}
}
@SuppressLint("SetJavaScriptEnabled")
private static void newWebView(
private static void launchWebView(
Context context,
String initialUrl,
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);
WebView 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() {
@@ -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);
}
});
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));
Utils.runOnMainThread(() -> webViewCallback.onReceivedSession(session));
}
}, JAVASCRIPT_INTERFACE_NAME);
currentWebView = webView;
CookieManager.getInstance().removeAllCookies((anyRemoved) -> {
Logger.printInfo(() -> "Loading URL: " + initialUrl);
webView.loadUrl(initialUrl);
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() {
String userAgentString = WebSettings.getDefaultUserAgent(Utils.getContext());
try {
@@ -241,16 +240,36 @@ class WebApp {
.withCommentReplaced("Android", "Windows NT 10.0; Win64; x64")
.withoutProduct("Mobile")
.toString();
} catch (IllegalArgumentException e) {
} catch (IllegalArgumentException ex) {
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);
Logger.printException(() -> "Failed to get user agent, falling back to " + fallback, ex);
}
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() {
CookieManager cookieManager = CookieManager.getInstance();
return cookieManager.getCookie(OPEN_SPOTIFY_COM_URL);

View File

@@ -32,4 +32,4 @@ STRING
WS
: [ \r\n]+
;
;

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 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/Function1;Lkotlin/jvm/functions/Function1;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/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)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/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/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/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 ([Lapp/revanced/patches/shared/misc/extension/ExtensionHook;)Lapp/revanced/patcher/patch/BytecodePatch;
}

View File

@@ -87,8 +87,8 @@ fun sharedExtensionPatch(
class ExtensionHook internal constructor(
internal val fingerprint: Fingerprint,
private val insertIndexResolver: ((Method) -> Int),
private val contextRegisterResolver: (Method) -> String,
private val insertIndexResolver: BytecodePatchContext.(Method) -> Int,
private val contextRegisterResolver: BytecodePatchContext.(Method) -> String,
) {
context(BytecodePatchContext)
operator fun invoke(extensionClassDescriptor: String) {
@@ -98,19 +98,19 @@ class ExtensionHook internal constructor(
fingerprint.method.addInstruction(
insertIndex,
"invoke-static/range { $contextRegister .. $contextRegister }, " +
"$extensionClassDescriptor->setContext(Landroid/content/Context;)V",
"$extensionClassDescriptor->setContext(Landroid/content/Context;)V",
)
}
}
fun extensionHook(
insertIndexResolver: ((Method) -> Int) = { 0 },
contextRegisterResolver: (Method) -> String = { "p0" },
insertIndexResolver: BytecodePatchContext.(Method) -> Int = { 0 },
contextRegisterResolver: BytecodePatchContext.(Method) -> String = { "p0" },
fingerprint: Fingerprint,
) = ExtensionHook(fingerprint, insertIndexResolver, contextRegisterResolver)
fun extensionHook(
insertIndexResolver: ((Method) -> Int) = { 0 },
contextRegisterResolver: (Method) -> String = { "p0" },
insertIndexResolver: BytecodePatchContext.(Method) -> Int = { 0 },
contextRegisterResolver: BytecodePatchContext.(Method) -> String = { "p0" },
fingerprintBuilderBlock: FingerprintBuilder.() -> Unit,
) = 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
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
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
import app.revanced.patches.shared.misc.extension.extensionHook
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 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
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.Opcode
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
internal val getPackageInfoFingerprint = fingerprint {
strings(
@@ -9,7 +13,7 @@ internal val getPackageInfoFingerprint = fingerprint {
)
}
internal val startLiborbitFingerprint = fingerprint {
internal val loadOrbitLibraryFingerprint = fingerprint {
strings("/liborbit-jni-spotify.so")
}
@@ -20,10 +24,22 @@ internal val startupPageLayoutInflateFingerprint = fingerprint {
strings("blueprintContainer", "gradient", "valuePropositionTextView")
}
internal val standardIntegrityTokenProviderBuilderFingerprint = fingerprint {
strings(
"standard_pi_init",
"outcome",
"success"
internal val runIntegrityVerificationFingerprint = fingerprint {
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
returns("V")
opcodes(
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",
description = "Spoofs the client to fix various functions of the app.",
) {
val port by intOption(
key = "port",
val requestListenerPort by intOption(
key = "requestListenerPort",
default = 4345,
title = " Login request listener port",
description = "The port to use for the listener that intercepts and handles login requests. " +
@@ -46,10 +46,10 @@ val spoofClientPatch = bytecodePatch(
"x86",
"x86_64"
).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"
"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"
}
})
@@ -58,6 +58,8 @@ val spoofClientPatch = bytecodePatch(
compatibleWith("com.spotify.music")
execute {
// region Spoof package info.
getPackageInfoFingerprint.method.apply {
// region Spoof signature.
@@ -99,28 +101,33 @@ val spoofClientPatch = bytecodePatch(
// endregion
}
startLiborbitFingerprint.method.addInstructions(
// endregion
// region Spoof client.
loadOrbitLibraryFingerprint.method.addInstructions(
0,
"""
const/16 v0, $port
invoke-static { v0 }, $EXTENSION_CLASS_DESCRIPTOR->listen(I)V
const/16 v0, $requestListenerPort
invoke-static { v0 }, $EXTENSION_CLASS_DESCRIPTOR->launchListener(I)V
"""
)
startupPageLayoutInflateFingerprint.method.apply {
val openLoginWebViewDescriptor =
"$EXTENSION_CLASS_DESCRIPTOR->login(Landroid/view/LayoutInflater;)V"
"$EXTENSION_CLASS_DESCRIPTOR->launchLogin(Landroid/view/LayoutInflater;)V"
addInstructions(
0,
"""
move-object/from16 v3, p1
invoke-static { v3 }, $openLoginWebViewDescriptor
invoke-static/range { p1 .. p1 }, $openLoginWebViewDescriptor
"""
)
}
// 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.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;"
@@ -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 val mainActivityOnCreateFingerprint = fingerprint {
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
returns("V")
parameters("Landroid/os/Bundle;")
custom { method, classDef ->
method.name == "onCreate" && (classDef.type == SPOTIFY_MAIN_ACTIVITY
|| classDef.type == SPOTIFY_MAIN_ACTIVITY_LEGACY)
@@ -26,9 +29,10 @@ private var isLegacyAppTarget: Boolean? = null
* supports Spotify integration on Kenwood/Pioneer car stereos.
*/
context(BytecodePatchContext)
internal val IS_SPOTIFY_LEGACY_APP_TARGET get(): Boolean {
if (isLegacyAppTarget == null) {
isLegacyAppTarget = mainActivityOnCreateHook.fingerprint.originalClassDef.type == SPOTIFY_MAIN_ACTIVITY_LEGACY
internal val IS_SPOTIFY_LEGACY_APP_TARGET
get(): Boolean {
if (isLegacyAppTarget == null) {
isLegacyAppTarget = mainActivityOnCreateFingerprint.originalClassDef.type == SPOTIFY_MAIN_ACTIVITY_LEGACY
}
return isLegacyAppTarget!!
}
return isLegacyAppTarget!!
}