mirror of
https://github.com/ReVanced/revanced-patches.git
synced 2026-01-23 02:31:03 +00:00
feat(Spotify - Spoof client): Fix issues like songs skipping by spoofing to iOS (#5388)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com> Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
This commit is contained in:
@@ -7,7 +7,6 @@ dependencies {
|
|||||||
compileOnly(project(":extensions:spotify:stub"))
|
compileOnly(project(":extensions:spotify:stub"))
|
||||||
compileOnly(libs.annotation)
|
compileOnly(libs.annotation)
|
||||||
|
|
||||||
implementation(project(":extensions:spotify:utils"))
|
|
||||||
implementation(libs.nanohttpd)
|
implementation(libs.nanohttpd)
|
||||||
implementation(libs.protobuf.javalite)
|
implementation(libs.protobuf.javalite)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 +
|
|
||||||
')';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
package app.revanced.extension.spotify.misc.fix;
|
package app.revanced.extension.spotify.misc.fix;
|
||||||
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import app.revanced.extension.shared.Logger;
|
import app.revanced.extension.shared.Logger;
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
public class SpoofClientPatch {
|
public class SpoofClientPatch {
|
||||||
private static LoginRequestListener listener;
|
private static RequestListener listener;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injection point.
|
* Injection point. Launch requests listener server.
|
||||||
* <br>
|
|
||||||
* Launch login server.
|
|
||||||
*/
|
*/
|
||||||
public static void launchListener(int port) {
|
public synchronized 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;
|
||||||
@@ -21,34 +17,9 @@ public class SpoofClientPatch {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Logger.printInfo(() -> "Launching listener on port " + port);
|
Logger.printInfo(() -> "Launching listener on port " + port);
|
||||||
listener = new LoginRequestListener(port);
|
listener = new RequestListener(port);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "launchListener failure", ex);
|
Logger.printException(() -> "launchListener failure", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Injection point.
|
|
||||||
* <br>
|
|
||||||
* 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.
|
|
||||||
* <br>
|
|
||||||
* Set handler to call the native login after the webview login.
|
|
||||||
*/
|
|
||||||
public static void setNativeLoginHandler(View startLoginButton) {
|
|
||||||
WebApp.nativeLoginHandler = (() -> {
|
|
||||||
startLoginButton.setSoundEffectsEnabled(false);
|
|
||||||
startLoginButton.performClick();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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]+
|
|
||||||
;
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,37 +7,12 @@ import com.android.tools.smali.dexlib2.AccessFlags
|
|||||||
import com.android.tools.smali.dexlib2.Opcode
|
import com.android.tools.smali.dexlib2.Opcode
|
||||||
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
|
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
|
||||||
|
|
||||||
internal val getPackageInfoFingerprint = fingerprint {
|
|
||||||
strings(
|
|
||||||
"Failed to get the application signatures"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal val loadOrbitLibraryFingerprint = fingerprint {
|
internal val loadOrbitLibraryFingerprint = fingerprint {
|
||||||
strings("/liborbit-jni-spotify.so")
|
strings("/liborbit-jni-spotify.so")
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val startupPageLayoutInflateFingerprint = fingerprint {
|
internal val extensionFixConstantsFingerprint = fingerprint {
|
||||||
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
|
custom { _, classDef -> classDef.type == "Lapp/revanced/extension/spotify/misc/fix/Constants;" }
|
||||||
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 runIntegrityVerificationFingerprint = fingerprint {
|
internal val runIntegrityVerificationFingerprint = fingerprint {
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
package app.revanced.patches.spotify.misc.fix
|
package app.revanced.patches.spotify.misc.fix
|
||||||
|
|
||||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
|
|
||||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
|
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.bytecodePatch
|
||||||
import app.revanced.patcher.patch.intOption
|
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.HexPatchBuilder
|
||||||
import app.revanced.patches.shared.misc.hex.hexPatch
|
import app.revanced.patches.shared.misc.hex.hexPatch
|
||||||
import app.revanced.patches.spotify.misc.extension.sharedExtensionPatch
|
import app.revanced.patches.spotify.misc.extension.sharedExtensionPatch
|
||||||
import app.revanced.util.*
|
import app.revanced.util.returnEarly
|
||||||
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
|
|
||||||
|
|
||||||
internal const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/spotify/misc/fix/SpoofClientPatch;"
|
internal const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/spotify/misc/fix/SpoofClientPatch;"
|
||||||
|
|
||||||
@@ -25,16 +19,40 @@ val spoofClientPatch = bytecodePatch(
|
|||||||
val requestListenerPort by intOption(
|
val requestListenerPort by intOption(
|
||||||
key = "requestListenerPort",
|
key = "requestListenerPort",
|
||||||
default = 4345,
|
default = 4345,
|
||||||
title = " Login request listener port",
|
title = "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 spoofed requests. " +
|
||||||
"Port must be between 0 and 65535.",
|
"Port must be between 0 and 65535. " +
|
||||||
required = true,
|
"Do not change this option, if you do not know what you are doing.",
|
||||||
validator = {
|
validator = {
|
||||||
it!!
|
it!!
|
||||||
!(it < 0 || it > 65535)
|
!(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(
|
dependsOn(
|
||||||
sharedExtensionPatch,
|
sharedExtensionPatch,
|
||||||
hexPatch(ignoreMissingTargetFiles = true, block = fun HexPatchBuilder.() {
|
hexPatch(ignoreMissingTargetFiles = true, block = fun HexPatchBuilder.() {
|
||||||
@@ -44,10 +62,8 @@ 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:$requestListenerPort/v3/login" inFile
|
"https://clienttoken.spotify.com/v1/clienttoken" to
|
||||||
"lib/$architecture/liborbit-jni-spotify.so"
|
"http://127.0.0.1:$requestListenerPort/v1/clienttoken" 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"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -56,51 +72,6 @@ val spoofClientPatch = bytecodePatch(
|
|||||||
compatibleWith("com.spotify.music")
|
compatibleWith("com.spotify.music")
|
||||||
|
|
||||||
execute {
|
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<OneRegisterInstruction>(concatSignaturesIndex).registerA
|
|
||||||
val expectedSignature = "d6a6dced4a85f24204bf9505ccc1fce114cadb32"
|
|
||||||
|
|
||||||
replaceInstruction(concatSignaturesIndex, "const-string v$signatureRegister, \"$expectedSignature\"")
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region Spoof installer name.
|
|
||||||
|
|
||||||
val expectedInstallerName = "com.android.vending"
|
|
||||||
|
|
||||||
findInstructionIndicesReversedOrThrow {
|
|
||||||
val reference = getReference<MethodReference>()
|
|
||||||
reference?.name == "getInstallerPackageName" || reference?.name == "getInstallingPackageName"
|
|
||||||
}.forEach { index ->
|
|
||||||
val returnObjectIndex = index + 1
|
|
||||||
|
|
||||||
val installerPackageNameRegister = getInstruction<OneRegisterInstruction>(
|
|
||||||
returnObjectIndex
|
|
||||||
).registerA
|
|
||||||
|
|
||||||
addInstruction(
|
|
||||||
returnObjectIndex + 1,
|
|
||||||
"const-string v$installerPackageNameRegister, \"$expectedInstallerName\""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region Spoof client.
|
// region Spoof client.
|
||||||
|
|
||||||
loadOrbitLibraryFingerprint.method.addInstructions(
|
loadOrbitLibraryFingerprint.method.addInstructions(
|
||||||
@@ -111,72 +82,12 @@ val spoofClientPatch = bytecodePatch(
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
startupPageLayoutInflateFingerprint.method.apply {
|
mapOf(
|
||||||
val openLoginWebViewDescriptor =
|
"getClientVersion" to clientVersion!!,
|
||||||
"$EXTENSION_CLASS_DESCRIPTOR->launchLogin(Landroid/view/LayoutInflater;)V"
|
"getSystemVersion" to systemVersion!!,
|
||||||
|
"getHardwareMachine" to hardwareMachine!!
|
||||||
addInstructions(
|
).forEach { (methodName, value) ->
|
||||||
0,
|
extensionFixConstantsFingerprint.classDef.methods.single { it.name == methodName }.returnEarly(value)
|
||||||
"invoke-static/range { p1 .. p1 }, $openLoginWebViewDescriptor"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderStartLoginScreenFingerprint.method.apply {
|
|
||||||
val onEventIndex = indexOfFirstInstructionOrThrow {
|
|
||||||
opcode == Opcode.INVOKE_INTERFACE && getReference<MethodReference>()?.name == "getView"
|
|
||||||
}
|
|
||||||
|
|
||||||
val buttonRegister = getInstruction<OneRegisterInstruction>(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<MethodReference>()?.name == "getView"
|
|
||||||
}
|
|
||||||
|
|
||||||
val buttonRegister = getInstruction<OneRegisterInstruction>(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<MethodReference>()
|
|
||||||
reference?.definingClass == "Landroid/view/View;" && reference.name == "setOnClickListener"
|
|
||||||
}
|
|
||||||
|
|
||||||
val buttonRegister = getInstruction<FiveRegisterInstruction>(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<FiveRegisterInstruction>(loginActionIndex)
|
|
||||||
|
|
||||||
addInstructions(
|
|
||||||
loginActionIndex,
|
|
||||||
"""
|
|
||||||
const-string v${loginActionInstruction.registerD}, "placeholder"
|
|
||||||
const-string v${loginActionInstruction.registerE}, "placeholder"
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|||||||
Reference in New Issue
Block a user