diff --git a/extensions/spotify/build.gradle.kts b/extensions/spotify/build.gradle.kts index eb963a007..fd346d93b 100644 --- a/extensions/spotify/build.gradle.kts +++ b/extensions/spotify/build.gradle.kts @@ -7,7 +7,6 @@ dependencies { compileOnly(project(":extensions:spotify:stub")) compileOnly(libs.annotation) - implementation(project(":extensions:spotify:utils")) implementation(libs.nanohttpd) implementation(libs.protobuf.javalite) } diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/ClientTokenService.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/ClientTokenService.java new file mode 100644 index 000000000..0345b19ef --- /dev/null +++ b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/ClientTokenService.java @@ -0,0 +1,115 @@ +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.misc.fix.clienttoken.data.v0.ClienttokenHttp.*; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +import static app.revanced.extension.spotify.misc.fix.Constants.*; + +class ClientTokenService { + private static final String IOS_CLIENT_ID = "58bd3c95768941ea9eb4350aaa033eb3"; + private static final String IOS_USER_AGENT; + + static { + String clientVersion = getClientVersion(); + int commitHashIndex = clientVersion.lastIndexOf("."); + String version = clientVersion.substring( + clientVersion.indexOf("-") + 1, + clientVersion.lastIndexOf(".", commitHashIndex - 1) + ); + + IOS_USER_AGENT = "Spotify/" + version + " iOS/" + getSystemVersion() + " (" + getHardwareMachine() + ")"; + } + + private static final ConnectivitySdkData.Builder IOS_CONNECTIVITY_SDK_DATA = + ConnectivitySdkData.newBuilder() + .setPlatformSpecificData(PlatformSpecificData.newBuilder() + .setIos(NativeIOSData.newBuilder() + .setHwMachine(getHardwareMachine()) + .setSystemVersion(getSystemVersion()) + ) + ); + + private static final ClientDataRequest.Builder IOS_CLIENT_DATA_REQUEST = + ClientDataRequest.newBuilder() + .setClientVersion(getClientVersion()) + .setClientId(IOS_CLIENT_ID); + + private static final ClientTokenRequest.Builder IOS_CLIENT_TOKEN_REQUEST = + ClientTokenRequest.newBuilder() + .setRequestType(ClientTokenRequestType.REQUEST_CLIENT_DATA_REQUEST); + + + @NonNull + static ClientTokenRequest newIOSClientTokenRequest(String deviceId) { + Logger.printInfo(() -> "Creating new iOS client token request with device ID: " + deviceId); + + return IOS_CLIENT_TOKEN_REQUEST + .setClientData(IOS_CLIENT_DATA_REQUEST + .setConnectivitySdkData(IOS_CONNECTIVITY_SDK_DATA + .setDeviceId(deviceId) + ) + ) + .build(); + } + + @Nullable + static ClientTokenResponse getClientTokenResponse(@NonNull ClientTokenRequest request) { + if (request.getRequestType() == ClientTokenRequestType.REQUEST_CLIENT_DATA_REQUEST) { + Logger.printInfo(() -> "Requesting iOS client token"); + String deviceId = request.getClientData().getConnectivitySdkData().getDeviceId(); + request = newIOSClientTokenRequest(deviceId); + } + + ClientTokenResponse response; + try { + response = requestClientToken(request); + } catch (IOException ex) { + Logger.printException(() -> "Failed to handle request", ex); + return null; + } + + return response; + } + + @NonNull + private static ClientTokenResponse requestClientToken(@NonNull ClientTokenRequest request) throws IOException { + HttpURLConnection urlConnection = (HttpURLConnection) new URL(CLIENT_TOKEN_API_URL).openConnection(); + urlConnection.setRequestMethod("POST"); + urlConnection.setDoOutput(true); + urlConnection.setRequestProperty("Content-Type", "application/x-protobuf"); + urlConnection.setRequestProperty("Accept", "application/x-protobuf"); + urlConnection.setRequestProperty("User-Agent", IOS_USER_AGENT); + + byte[] requestArray = request.toByteArray(); + urlConnection.setFixedLengthStreamingMode(requestArray.length); + urlConnection.getOutputStream().write(requestArray); + + try (InputStream inputStream = urlConnection.getInputStream()) { + return ClientTokenResponse.parseFrom(inputStream); + } + } + + @Nullable + static ClientTokenResponse serveClientTokenRequest(@NonNull InputStream inputStream) { + ClientTokenRequest request; + try { + request = ClientTokenRequest.parseFrom(inputStream); + } catch (IOException ex) { + Logger.printException(() -> "Failed to parse request from input stream", ex); + return null; + } + Logger.printInfo(() -> "Request of type: " + request.getRequestType()); + + ClientTokenResponse response = getClientTokenResponse(request); + if (response != null) Logger.printInfo(() -> "Response of type: " + response.getResponseType()); + + return response; + } +} diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/Constants.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/Constants.java new file mode 100644 index 000000000..4928da7ad --- /dev/null +++ b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/Constants.java @@ -0,0 +1,26 @@ +package app.revanced.extension.spotify.misc.fix; + +import androidx.annotation.NonNull; + +class Constants { + static final String CLIENT_TOKEN_API_PATH = "/v1/clienttoken"; + static final String CLIENT_TOKEN_API_URL = "https://clienttoken.spotify.com" + CLIENT_TOKEN_API_PATH; + + // Modified by a patch. Do not touch. + @NonNull + static String getClientVersion() { + return ""; + } + + // Modified by a patch. Do not touch. + @NonNull + static String getSystemVersion() { + return ""; + } + + // Modified by a patch. Do not touch. + @NonNull + static String getHardwareMachine() { + return ""; + } +} diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/LoginRequestListener.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/LoginRequestListener.java deleted file mode 100644 index 2a6107c31..000000000 --- a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/LoginRequestListener.java +++ /dev/null @@ -1,158 +0,0 @@ -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 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 - @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 ex) { - Logger.printException(() -> "Failed to parse LoginRequest", ex); - 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) { - try { - loginResponse = getLoginResponse(loginRequest); - } catch (Exception ex) { - Logger.printException(() -> "Failed to get login response", ex); - return newResponse(INTERNAL_ERROR); - } - } - - return newResponse(Response.Status.OK, loginResponse); - } - - - private static LoginResponse getLoginResponse(@NonNull LoginRequest loginRequest) { - Session session; - - if (!loginRequest.hasStoredCredential()) { - Logger.printInfo(() -> "Received request for initial 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); - } - - private static LoginResponse toLoginResponse(@Nullable Session session) { - LoginResponse.Builder builder = LoginResponse.newBuilder(); - - if (session == null) { - 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 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); - builder.setOk(LoginOk.newBuilder() - .setUsername(session.username) - .setAccessToken(session.accessToken) - .setStoredCredential(ByteString.fromHex("00")) // Placeholder, as it cannot be null or empty. - .setAccessTokenExpiresIn(session.accessTokenExpiresInSeconds()) - .build()); - } - - return builder.build(); - } - - @NonNull - private static InputStream limitedInputStream(InputStream inputStream, long contentLength) { - return new FilterInputStream(inputStream) { - private long remaining = contentLength; - - @Override - public int read() throws IOException { - if (remaining <= 0) return -1; - int result = super.read(); - if (result != -1) remaining--; - return result; - } - - @Override - public int read(byte[] b, int off, int len) throws IOException { - if (remaining <= 0) return -1; - len = (int) Math.min(len, remaining); - int result = super.read(b, off, len); - if (result != -1) remaining -= result; - return result; - } - }; - } - - @NonNull - private static InputStream getRequestBodyInputStream(@NonNull IHTTPSession request) { - long requestContentLength = - Long.parseLong(Objects.requireNonNull(request.getHeaders().get("content-length"))); - return limitedInputStream(request.getInputStream(), requestContentLength); - } - - - @SuppressWarnings("SameParameterValue") - @NonNull - private static Response newResponse(Response.Status status) { - return newResponse(status, null); - } - - @NonNull - private static Response newResponse(Response.IStatus status, MessageLite messageLite) { - if (messageLite == null) { - return newFixedLengthResponse(status, "application/x-protobuf", null); - } - - byte[] messageBytes = messageLite.toByteArray(); - InputStream stream = new ByteArrayInputStream(messageBytes); - return newFixedLengthResponse(status, "application/x-protobuf", stream, messageBytes.length); - } -} diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/RequestListener.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/RequestListener.java new file mode 100644 index 000000000..2de6caa3e --- /dev/null +++ b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/RequestListener.java @@ -0,0 +1,94 @@ +package app.revanced.extension.spotify.misc.fix; + +import androidx.annotation.NonNull; +import app.revanced.extension.shared.Logger; +import app.revanced.extension.spotify.misc.fix.clienttoken.data.v0.ClienttokenHttp.ClientTokenResponse; +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 app.revanced.extension.spotify.misc.fix.ClientTokenService.serveClientTokenRequest; +import static app.revanced.extension.spotify.misc.fix.Constants.CLIENT_TOKEN_API_PATH; +import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR; + +class RequestListener extends NanoHTTPD { + RequestListener(int port) { + super(port); + + try { + start(); + } catch (IOException ex) { + Logger.printException(() -> "Failed to start request listener on port " + port, ex); + throw new RuntimeException(ex); + } + } + + @NonNull + @Override + public Response serve(@NonNull IHTTPSession session) { + String uri = session.getUri(); + if (!uri.equals(CLIENT_TOKEN_API_PATH)) return INTERNAL_ERROR_RESPONSE; + + Logger.printInfo(() -> "Serving request for URI: " + uri); + + ClientTokenResponse response = serveClientTokenRequest(getInputStream(session)); + if (response != null) return newResponse(Response.Status.OK, response); + + Logger.printException(() -> "Failed to serve client token request"); + return INTERNAL_ERROR_RESPONSE; + } + + @NonNull + private static InputStream newLimitedInputStream(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 getInputStream(@NonNull IHTTPSession session) { + long requestContentLength = Long.parseLong(Objects.requireNonNull(session.getHeaders().get("content-length"))); + return newLimitedInputStream(session.getInputStream(), requestContentLength); + } + + private static final Response INTERNAL_ERROR_RESPONSE = newResponse(INTERNAL_ERROR); + + @SuppressWarnings("SameParameterValue") + @NonNull + private static Response newResponse(Response.Status status) { + return newResponse(status, null); + } + + @NonNull + private static Response newResponse(Response.IStatus status, MessageLite messageLite) { + if (messageLite == null) { + return newFixedLengthResponse(status, "application/x-protobuf", null); + } + + byte[] messageBytes = messageLite.toByteArray(); + InputStream stream = new ByteArrayInputStream(messageBytes); + return newFixedLengthResponse(status, "application/x-protobuf", stream, messageBytes.length); + } +} diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/Session.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/Session.java deleted file mode 100644 index 0860253f6..000000000 --- a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/Session.java +++ /dev/null @@ -1,136 +0,0 @@ -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; - - /** - * 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. - * @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(); - } - - 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); - - SharedPreferences sharedPreferences = Utils.getContext().getSharedPreferences("revanced", MODE_PRIVATE); - String savedJson = sharedPreferences.getString("session_" + username, null); - if (savedJson == null) { - Logger.printInfo(() -> "No session found in shared preferences"); - return null; - } - - try { - JSONObject json = new JSONObject(savedJson); - String accessToken = json.getString("accessToken"); - long expirationTime = json.getLong("expirationTime"); - String cookies = json.getString("cookies"); - - return new Session(username, accessToken, expirationTime, cookies); - } catch (JSONException ex) { - Logger.printException(() -> "Failed to read session from shared preferences", ex); - return null; - } - } - - @NonNull - @Override - public String toString() { - return "Session(" + - "username=" + username + - ", accessToken=" + accessToken + - ", expirationTime=" + expirationTime + - ", cookies=" + cookies + - ')'; - } -} diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/SpoofClientPatch.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/SpoofClientPatch.java index fb5e08113..fee58b26c 100644 --- a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/SpoofClientPatch.java +++ b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/SpoofClientPatch.java @@ -1,19 +1,15 @@ package app.revanced.extension.spotify.misc.fix; -import android.view.LayoutInflater; -import android.view.View; import app.revanced.extension.shared.Logger; @SuppressWarnings("unused") public class SpoofClientPatch { - private static LoginRequestListener listener; + private static RequestListener listener; /** - * Injection point. - *
- * Launch login server. + * Injection point. Launch requests listener server. */ - public static void launchListener(int port) { + public synchronized static void launchListener(int port) { if (listener != null) { Logger.printInfo(() -> "Listener already running on port " + port); return; @@ -21,34 +17,9 @@ public class SpoofClientPatch { try { Logger.printInfo(() -> "Launching listener on port " + port); - listener = new LoginRequestListener(port); + listener = new RequestListener(port); } catch (Exception ex) { Logger.printException(() -> "launchListener failure", ex); } } - - /** - * Injection point. - *
- * Launch login web view. - */ - public static void launchLogin(LayoutInflater inflater) { - try { - WebApp.launchLogin(inflater.getContext()); - } catch (Exception ex) { - Logger.printException(() -> "launchLogin failure", ex); - } - } - - /** - * Injection point. - *
- * Set handler to call the native login after the webview login. - */ - public static void setNativeLoginHandler(View startLoginButton) { - WebApp.nativeLoginHandler = (() -> { - startLoginButton.setSoundEffectsEnabled(false); - startLoginButton.performClick(); - }); - } } diff --git a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/WebApp.java b/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/WebApp.java deleted file mode 100644 index fd11ae7a8..000000000 --- a/extensions/spotify/src/main/java/app/revanced/extension/spotify/misc/fix/WebApp.java +++ /dev/null @@ -1,297 +0,0 @@ -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.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 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?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; - - interface NativeLoginHandler { - void login(); - } - - static NativeLoginHandler nativeLoginHandler; - - static void launchLogin(Context context) { - final Dialog dialog = newDialog(context); - - Utils.runOnBackgroundThread(() -> { - Logger.printInfo(() -> "Launching login"); - - // 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); - - // Can't use Utils.getContext() here, because autofill won't work. - // See https://stackoverflow.com/a/79182053/11213244. - launchWebView(context, ACCOUNTS_SPOTIFY_COM_LOGIN_URL, new WebViewCallback() { - @Override - void onInitialized(WebView webView) { - super.onInitialized(webView); - - dialog.setContentView(webView); - dialog.show(); - } - - @Override - void onLoggedIn(String cookies) { - onLoggedInLatch.countDown(); - } - - @Override - void onReceivedSession(Session session) { - super.onReceivedSession(session); - - getSessionLatch.countDown(); - dialog.dismiss(); - - try { - nativeLoginHandler.login(); - } catch (Exception ex) { - Logger.printException(() -> "nativeLoginHandler failure", ex); - } - } - }); - - 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 renewSessionBlocking(String cookies) { - Logger.printInfo(() -> "Renewing session with cookies: " + cookies); - - CountDownLatch getSessionLatch = new CountDownLatch(1); - - launchWebView(Utils.getContext(), OPEN_SPOTIFY_COM_PREFERENCES_URL, new WebViewCallback() { - @Override - public void onInitialized(WebView webView) { - setCookies(cookies); - super.onInitialized(webView); - } - - 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(); - } - - if (!isAcquired) { - Logger.printException(() -> "Failed to retrieve session within " + GET_SESSION_TIMEOUT_SECONDS + " seconds"); - currentSession = FAILED_TO_RENEW_SESSION; - destructWebView(); - } - } - - /** - * All methods are called on the main thread. - */ - abstract static class WebViewCallback { - void onInitialized(WebView webView) { - currentWebView = webView; - currentSession = null; // Reset current session. - } - - void onLoggedIn(String cookies) { - } - - void onReceivedSession(Session session) { - Logger.printInfo(() -> "Received session: " + session); - currentSession = session; - - destructWebView(); - } - } - - @SuppressLint("SetJavaScriptEnabled") - private static void launchWebView( - Context context, - String initialUrl, - WebViewCallback webViewCallback - ) { - Utils.runOnMainThreadNowOrLater(() -> { - 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() { - @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" + - " })" + - " " + - " }" + - "});" + - "if (new URLSearchParams(window.location.search).get('_authfailed') != null) {" + - " " + JAVASCRIPT_INTERFACE_NAME + ".getSession(null, null);" + - "}"; - - view.evaluateJavascript(getSessionScript, null); - } - }); - - 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(session)); - } - }, JAVASCRIPT_INTERFACE_NAME); - - CookieManager.getInstance().removeAllCookies((anyRemoved) -> { - Logger.printInfo(() -> "Loading URL: " + initialUrl); - webView.loadUrl(initialUrl); - - Logger.printInfo(() -> "WebView initialized with user agent: " + USER_AGENT); - 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 { - return new UserAgent(userAgentString) - .withCommentReplaced("Android", "Windows NT 10.0; Win64; x64") - .withoutProduct("Mobile") - .toString(); - } 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, 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); - } - - private static void setCookies(@NonNull String cookies) { - CookieManager cookieManager = CookieManager.getInstance(); - - String[] cookiesList = cookies.split(";"); - for (String cookie : cookiesList) { - cookieManager.setCookie(OPEN_SPOTIFY_COM_URL, cookie); - } - } -} diff --git a/extensions/spotify/src/main/proto/app/revanced/extension/spotify/misc/fix/clienttoken_http.proto b/extensions/spotify/src/main/proto/app/revanced/extension/spotify/misc/fix/clienttoken_http.proto new file mode 100644 index 000000000..8e7f242b7 --- /dev/null +++ b/extensions/spotify/src/main/proto/app/revanced/extension/spotify/misc/fix/clienttoken_http.proto @@ -0,0 +1,73 @@ +syntax = "proto3"; + +package spotify.clienttoken.data.v0; + +option optimize_for = LITE_RUNTIME; +option java_package = "app.revanced.extension.spotify.misc.fix.clienttoken.data.v0"; + +message ClientTokenRequest { + ClientTokenRequestType request_type = 1; + + oneof request { + ClientDataRequest client_data = 2; + } +} + +enum ClientTokenRequestType { + REQUEST_UNKNOWN = 0; + REQUEST_CLIENT_DATA_REQUEST = 1; + REQUEST_CHALLENGE_ANSWERS_REQUEST = 2; +} + +message ClientDataRequest { + string client_version = 1; + string client_id = 2; + + oneof data { + ConnectivitySdkData connectivity_sdk_data = 3; + } +} + +message ConnectivitySdkData { + PlatformSpecificData platform_specific_data = 1; + string device_id = 2; +} + +message PlatformSpecificData { + oneof data { + NativeIOSData ios = 2; + } +} + +message NativeIOSData { + int32 user_interface_idiom = 1; + bool target_iphone_simulator = 2; + string hw_machine = 3; + string system_version = 4; + string simulator_model_identifier = 5; +} + +message ClientTokenResponse { + ClientTokenResponseType response_type = 1; + + oneof response { + GrantedTokenResponse granted_token = 2; + } +} + +enum ClientTokenResponseType { + RESPONSE_UNKNOWN = 0; + RESPONSE_GRANTED_TOKEN_RESPONSE = 1; + RESPONSE_CHALLENGES_RESPONSE = 2; +} + +message GrantedTokenResponse { + string token = 1; + int32 expires_after_seconds = 2; + int32 refresh_after_seconds = 3; + repeated TokenDomain domains = 4; +} + +message TokenDomain { + string domain = 1; +} diff --git a/extensions/spotify/src/main/proto/login5.proto b/extensions/spotify/src/main/proto/login5.proto deleted file mode 100644 index 7d8e38d24..000000000 --- a/extensions/spotify/src/main/proto/login5.proto +++ /dev/null @@ -1,43 +0,0 @@ -syntax = "proto3"; - -package spotify.login5.v4; - -option optimize_for = LITE_RUNTIME; -option java_package = "app.revanced.extension.spotify.login5.v4.proto"; - -message StoredCredential { - string username = 1; - bytes data = 2; -} - -message LoginRequest { - oneof login_method { - StoredCredential stored_credential = 100; - } -} - -message LoginOk { - string username = 1; - string access_token = 2; - bytes stored_credential = 3; - int32 access_token_expires_in = 4; -} - -message LoginResponse { - oneof response { - LoginOk ok = 1; - LoginError error = 2; - } -} - -enum LoginError { - UNKNOWN_ERROR = 0; - INVALID_CREDENTIALS = 1; - BAD_REQUEST = 2; - UNSUPPORTED_LOGIN_PROTOCOL = 3; - TIMEOUT = 4; - UNKNOWN_IDENTIFIER = 5; - TOO_MANY_ATTEMPTS = 6; - INVALID_PHONENUMBER = 7; - TRY_AGAIN_LATER = 8; -} diff --git a/extensions/spotify/utils/build.gradle.kts b/extensions/spotify/utils/build.gradle.kts deleted file mode 100644 index 3cdce1cc0..000000000 --- a/extensions/spotify/utils/build.gradle.kts +++ /dev/null @@ -1,19 +0,0 @@ -plugins { - java - antlr -} - -dependencies { - antlr(libs.antlr4) -} - -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -tasks { - generateGrammarSource { - arguments = listOf("-visitor") - } -} diff --git a/extensions/spotify/utils/src/main/antlr/app/revanced/extension/spotify/UserAgent.g4 b/extensions/spotify/utils/src/main/antlr/app/revanced/extension/spotify/UserAgent.g4 deleted file mode 100644 index b46799359..000000000 --- a/extensions/spotify/utils/src/main/antlr/app/revanced/extension/spotify/UserAgent.g4 +++ /dev/null @@ -1,35 +0,0 @@ -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]+ - ; diff --git a/extensions/spotify/utils/src/main/java/app/revanced/extension/spotify/UserAgent.java b/extensions/spotify/utils/src/main/java/app/revanced/extension/spotify/UserAgent.java deleted file mode 100644 index e25912f86..000000000 --- a/extensions/spotify/utils/src/main/java/app/revanced/extension/spotify/UserAgent.java +++ /dev/null @@ -1,60 +0,0 @@ -package app.revanced.extension.spotify; - -import org.antlr.v4.runtime.CharStream; -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.TokenStreamRewriter; -import org.antlr.v4.runtime.tree.ParseTreeWalker; - -public class UserAgent { - private final UserAgentParser.UserAgentContext tree; - private final TokenStreamRewriter rewriter; - private final ParseTreeWalker walker; - - public UserAgent(String userAgentString) { - CharStream input = CharStreams.fromString(userAgentString); - UserAgentLexer lexer = new UserAgentLexer(input); - CommonTokenStream tokens = new CommonTokenStream(lexer); - - tree = new UserAgentParser(tokens).userAgent(); - walker = new ParseTreeWalker(); - rewriter = new TokenStreamRewriter(tokens); - } - - public UserAgent withoutProduct(String name) { - walker.walk(new UserAgentBaseListener() { - @Override - public void exitProduct(UserAgentParser.ProductContext ctx) { - if (!ctx.name().getText().contains(name)) return; - - int startIndex = ctx.getStart().getTokenIndex(); - if (startIndex != 0) startIndex -= 1; // Also remove the preceding whitespace. - - int stopIndex = ctx.getStop().getTokenIndex(); - - - rewriter.delete(startIndex, stopIndex); - } - }, tree); - - return new UserAgent(rewriter.getText().trim()); - } - - public UserAgent withCommentReplaced(String containing, String replacement) { - walker.walk(new UserAgentBaseListener() { - @Override - public void exitComment(UserAgentParser.CommentContext ctx) { - if (ctx.getText().contains(containing)) { - rewriter.replace(ctx.getStart(), ctx.getStop(), "(" + replacement + ")"); - } - } - }, tree); - - return new UserAgent(rewriter.getText()); - } - - @Override - public String toString() { - return rewriter.getText(); - } -} diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/Fingerprints.kt index 3cc57181b..abe67ad15 100644 --- a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/Fingerprints.kt +++ b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/Fingerprints.kt @@ -7,37 +7,12 @@ 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( - "Failed to get the application signatures" - ) -} - internal val loadOrbitLibraryFingerprint = 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") -} - -internal val renderStartLoginScreenFingerprint = fingerprint { - strings("authenticationButtonFactory", "MORE_OPTIONS") -} - -internal val renderSecondLoginScreenFingerprint = fingerprint { - strings("authenticationButtonFactory", "intent_login") -} - -internal val renderThirdLoginScreenFingerprint = fingerprint { - strings("EMAIL_OR_USERNAME", "listener") -} - -internal val thirdLoginScreenLoginOnClickFingerprint = fingerprint { - strings("login", "listener", "none") +internal val extensionFixConstantsFingerprint = fingerprint { + custom { _, classDef -> classDef.type == "Lapp/revanced/extension/spotify/misc/fix/Constants;" } } internal val runIntegrityVerificationFingerprint = fingerprint { diff --git a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofClientPatch.kt b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofClientPatch.kt index c35dc1346..49bb19704 100644 --- a/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofClientPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/spotify/misc/fix/SpoofClientPatch.kt @@ -1,19 +1,13 @@ 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.patcher.patch.stringOption 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.* -import com.android.tools.smali.dexlib2.Opcode -import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction -import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction -import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import app.revanced.util.returnEarly internal const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/spotify/misc/fix/SpoofClientPatch;" @@ -25,16 +19,40 @@ val spoofClientPatch = bytecodePatch( 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. " + - "Port must be between 0 and 65535.", - required = true, + title = "Request listener port", + description = "The port to use for the listener that intercepts and handles spoofed requests. " + + "Port must be between 0 and 65535. " + + "Do not change this option, if you do not know what you are doing.", validator = { it!! !(it < 0 || it > 65535) } ) + val clientVersion by stringOption( + key = "clientVersion", + default = "iphone-9.0.58.558.g200011c", + title = "Client version", + description = "The client version used for spoofing the client token. " + + "Do not change this option, if you do not know what you are doing." + ) + + val hardwareMachine by stringOption( + key = "hardwareMachine", + default = "iPhone16,1", + title = "Hardware machine", + description = "The hardware machine used for spoofing the client token. " + + "Do not change this option, if you do not know what you are doing." + ) + + val systemVersion by stringOption( + key = "systemVersion", + default = "17.7.2", + title = "System version", + description = "The system version used for spoofing the client token. " + + "Do not change this option, if you do not know what you are doing." + ) + dependsOn( sharedExtensionPatch, hexPatch(ignoreMissingTargetFiles = true, block = fun HexPatchBuilder.() { @@ -44,10 +62,8 @@ val spoofClientPatch = bytecodePatch( "x86", "x86_64" ).forEach { architecture -> - "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:$requestListenerPort/v4/login" inFile + "https://clienttoken.spotify.com/v1/clienttoken" to + "http://127.0.0.1:$requestListenerPort/v1/clienttoken" inFile "lib/$architecture/liborbit-jni-spotify.so" } }) @@ -56,51 +72,6 @@ val spoofClientPatch = bytecodePatch( compatibleWith("com.spotify.music") execute { - // region Spoof package info. - - getPackageInfoFingerprint.method.apply { - // region Spoof signature. - - val failedToGetSignaturesStringIndex = - getPackageInfoFingerprint.stringMatches!!.first().index - - val concatSignaturesIndex = indexOfFirstInstructionReversedOrThrow( - failedToGetSignaturesStringIndex, - Opcode.MOVE_RESULT_OBJECT, - ) - - val signatureRegister = getInstruction(concatSignaturesIndex).registerA - val expectedSignature = "d6a6dced4a85f24204bf9505ccc1fce114cadb32" - - replaceInstruction(concatSignaturesIndex, "const-string v$signatureRegister, \"$expectedSignature\"") - - // endregion - - // region Spoof installer name. - - val expectedInstallerName = "com.android.vending" - - findInstructionIndicesReversedOrThrow { - val reference = getReference() - reference?.name == "getInstallerPackageName" || reference?.name == "getInstallingPackageName" - }.forEach { index -> - val returnObjectIndex = index + 1 - - val installerPackageNameRegister = getInstruction( - returnObjectIndex - ).registerA - - addInstruction( - returnObjectIndex + 1, - "const-string v$installerPackageNameRegister, \"$expectedInstallerName\"" - ) - } - - // endregion - } - - // endregion - // region Spoof client. loadOrbitLibraryFingerprint.method.addInstructions( @@ -111,72 +82,12 @@ val spoofClientPatch = bytecodePatch( """ ) - startupPageLayoutInflateFingerprint.method.apply { - val openLoginWebViewDescriptor = - "$EXTENSION_CLASS_DESCRIPTOR->launchLogin(Landroid/view/LayoutInflater;)V" - - addInstructions( - 0, - "invoke-static/range { p1 .. p1 }, $openLoginWebViewDescriptor" - ) - } - - renderStartLoginScreenFingerprint.method.apply { - val onEventIndex = indexOfFirstInstructionOrThrow { - opcode == Opcode.INVOKE_INTERFACE && getReference()?.name == "getView" - } - - val buttonRegister = getInstruction(onEventIndex + 1).registerA - - addInstruction( - onEventIndex + 2, - "invoke-static { v$buttonRegister }, $EXTENSION_CLASS_DESCRIPTOR->setNativeLoginHandler(Landroid/view/View;)V" - ) - } - - renderSecondLoginScreenFingerprint.method.apply { - val getViewIndex = indexOfFirstInstructionOrThrow { - opcode == Opcode.INVOKE_INTERFACE && getReference()?.name == "getView" - } - - val buttonRegister = getInstruction(getViewIndex + 1).registerA - - // Early return the render for loop since the first item of the loop is the login button. - addInstructions( - getViewIndex + 2, - """ - invoke-virtual { v$buttonRegister }, Landroid/view/View;->performClick()Z - return-void - """ - ) - } - - renderThirdLoginScreenFingerprint.method.apply { - val invokeSetListenerIndex = indexOfFirstInstructionOrThrow { - val reference = getReference() - reference?.definingClass == "Landroid/view/View;" && reference.name == "setOnClickListener" - } - - val buttonRegister = getInstruction(invokeSetListenerIndex).registerC - - addInstruction( - invokeSetListenerIndex + 1, - "invoke-virtual { v$buttonRegister }, Landroid/view/View;->performClick()Z" - ) - } - - thirdLoginScreenLoginOnClickFingerprint.method.apply { - // Use placeholder credentials to pass the login screen. - val loginActionIndex = indexOfFirstInstructionOrThrow(Opcode.RETURN_VOID) - 1 - val loginActionInstruction = getInstruction(loginActionIndex) - - addInstructions( - loginActionIndex, - """ - const-string v${loginActionInstruction.registerD}, "placeholder" - const-string v${loginActionInstruction.registerE}, "placeholder" - """ - ) + mapOf( + "getClientVersion" to clientVersion!!, + "getSystemVersion" to systemVersion!!, + "getHardwareMachine" to hardwareMachine!! + ).forEach { (methodName, value) -> + extensionFixConstantsFingerprint.classDef.methods.single { it.name == methodName }.returnEarly(value) } // endregion