Skip to content

Commit b7b75bb

Browse files
oSumAtrIXNuckyzbrossshdrobotkLisoUseInAIKyrios
authored
fix(Spotify): Add Spoof client patch to fix various issues by using a web platform access token (#5173)
Co-authored-by: Nuckyz <[email protected]> Co-authored-by: brosssh <[email protected]> Co-authored-by: Dawid Krajcarz <[email protected]> Co-authored-by: LisoUseInAIKyrios <[email protected]>
1 parent 37d16d7 commit b7b75bb

File tree

31 files changed

+1140
-225
lines changed

31 files changed

+1140
-225
lines changed

build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
plugins {
2+
alias(libs.plugins.android.library) apply false
3+
}

extensions/boostforreddit/stub/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
plugins {
2-
id(libs.plugins.android.library.get().pluginId)
2+
alias(libs.plugins.android.library)
33
}
44

55
android {

extensions/nunl/stub/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
plugins {
2-
id(libs.plugins.android.library.get().pluginId)
2+
alias(libs.plugins.android.library)
33
}
44

55
android {

extensions/primevideo/stub/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
plugins {
2-
id(libs.plugins.android.library.get().pluginId)
2+
alias(libs.plugins.android.library)
33
}
44

55
android {

extensions/reddit/stub/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
plugins {
2-
id(libs.plugins.android.library.get().pluginId)
2+
alias(libs.plugins.android.library)
33
}
44

55
android {

extensions/shared/library/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
plugins {
2-
id("com.android.library")
2+
alias(libs.plugins.android.library)
33
}
44

55
android {

extensions/spotify/build.gradle.kts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
plugins {
2+
alias(libs.plugins.protobuf)
3+
}
4+
15
dependencies {
26
compileOnly(project(":extensions:shared:library"))
37
compileOnly(project(":extensions:spotify:stub"))
48
compileOnly(libs.annotation)
9+
10+
implementation(project(":extensions:spotify:utils"))
11+
implementation(libs.nanohttpd)
12+
implementation(libs.protobuf.javalite)
513
}
614

715
android {
@@ -14,3 +22,19 @@ android {
1422
targetCompatibility = JavaVersion.VERSION_1_8
1523
}
1624
}
25+
26+
protobuf {
27+
protoc {
28+
artifact = libs.protobuf.protoc.get().toString()
29+
}
30+
31+
generateProtoTasks {
32+
all().forEach { task ->
33+
task.builtins {
34+
create("java") {
35+
option("lite")
36+
}
37+
}
38+
}
39+
}
40+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package app.revanced.extension.spotify.misc.fix;
2+
3+
import androidx.annotation.NonNull;
4+
import androidx.annotation.Nullable;
5+
import app.revanced.extension.shared.Logger;
6+
import app.revanced.extension.spotify.login5.v4.proto.Login5.*;
7+
import com.google.protobuf.ByteString;
8+
import com.google.protobuf.MessageLite;
9+
import fi.iki.elonen.NanoHTTPD;
10+
11+
import java.io.ByteArrayInputStream;
12+
import java.io.FilterInputStream;
13+
import java.io.IOException;
14+
import java.io.InputStream;
15+
import java.util.Objects;
16+
17+
import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR;
18+
19+
class LoginRequestListener extends NanoHTTPD {
20+
LoginRequestListener(int port) {
21+
super(port);
22+
}
23+
24+
@NonNull
25+
@Override
26+
public Response serve(IHTTPSession request) {
27+
Logger.printInfo(() -> "Serving request for URI: " + request.getUri());
28+
29+
InputStream requestBodyInputStream = getRequestBodyInputStream(request);
30+
31+
LoginRequest loginRequest;
32+
try {
33+
loginRequest = LoginRequest.parseFrom(requestBodyInputStream);
34+
} catch (IOException e) {
35+
Logger.printException(() -> "Failed to parse LoginRequest", e);
36+
return newResponse(INTERNAL_ERROR);
37+
}
38+
39+
MessageLite loginResponse;
40+
41+
// A request may be made concurrently by Spotify,
42+
// however a webview can only handle one request at a time due to singleton cookie manager.
43+
// Therefore, synchronize to ensure that only one webview handles the request at a time.
44+
synchronized (this) {
45+
loginResponse = getLoginResponse(loginRequest);
46+
}
47+
48+
if (loginResponse != null) {
49+
return newResponse(Response.Status.OK, loginResponse);
50+
}
51+
52+
return newResponse(INTERNAL_ERROR);
53+
}
54+
55+
56+
@Nullable
57+
private static LoginResponse getLoginResponse(@NonNull LoginRequest loginRequest) {
58+
Session session;
59+
60+
boolean isInitialLogin = !loginRequest.hasStoredCredential();
61+
if (isInitialLogin) {
62+
Logger.printInfo(() -> "Received request for initial login");
63+
session = WebApp.currentSession; // Session obtained from WebApp.login.
64+
} else {
65+
Logger.printInfo(() -> "Received request to restore saved session");
66+
session = Session.read(loginRequest.getStoredCredential().getUsername());
67+
}
68+
69+
return toLoginResponse(session, isInitialLogin);
70+
}
71+
72+
73+
private static LoginResponse toLoginResponse(Session session, boolean isInitialLogin) {
74+
LoginResponse.Builder builder = LoginResponse.newBuilder();
75+
76+
if (session == null) {
77+
if (isInitialLogin) {
78+
Logger.printInfo(() -> "Session is null, returning try again later error for initial login");
79+
builder.setError(LoginError.TRY_AGAIN_LATER);
80+
} else {
81+
Logger.printInfo(() -> "Session is null, returning invalid credentials error for stored credential login");
82+
builder.setError(LoginError.INVALID_CREDENTIALS);
83+
}
84+
} else if (session.username == null) {
85+
Logger.printInfo(() -> "Session username is null, returning invalid credentials error");
86+
builder.setError(LoginError.INVALID_CREDENTIALS);
87+
} else if (session.accessTokenExpired()) {
88+
Logger.printInfo(() -> "Access token has expired, renewing session");
89+
WebApp.renewSession(session.cookies);
90+
return toLoginResponse(WebApp.currentSession, isInitialLogin);
91+
} else {
92+
session.save();
93+
Logger.printInfo(() -> "Returning session for username: " + session.username);
94+
builder.setOk(LoginOk.newBuilder()
95+
.setUsername(session.username)
96+
.setAccessToken(session.accessToken)
97+
.setStoredCredential(ByteString.fromHex("00")) // Placeholder, as it cannot be null or empty.
98+
.setAccessTokenExpiresIn(session.accessTokenExpiresInSeconds())
99+
.build());
100+
}
101+
102+
return builder.build();
103+
}
104+
105+
@NonNull
106+
private static InputStream limitedInputStream(InputStream inputStream, long contentLength) {
107+
return new FilterInputStream(inputStream) {
108+
private long remaining = contentLength;
109+
110+
@Override
111+
public int read() throws IOException {
112+
if (remaining <= 0) return -1;
113+
int result = super.read();
114+
if (result != -1) remaining--;
115+
return result;
116+
}
117+
118+
@Override
119+
public int read(byte[] b, int off, int len) throws IOException {
120+
if (remaining <= 0) return -1;
121+
len = (int) Math.min(len, remaining);
122+
int result = super.read(b, off, len);
123+
if (result != -1) remaining -= result;
124+
return result;
125+
}
126+
};
127+
}
128+
129+
@NonNull
130+
private static InputStream getRequestBodyInputStream(@NonNull IHTTPSession request) {
131+
long requestContentLength =
132+
Long.parseLong(Objects.requireNonNull(request.getHeaders().get("content-length")));
133+
return limitedInputStream(request.getInputStream(), requestContentLength);
134+
}
135+
136+
137+
@SuppressWarnings("SameParameterValue")
138+
@NonNull
139+
private static Response newResponse(Response.Status status) {
140+
return newResponse(status, null);
141+
}
142+
143+
@NonNull
144+
private static Response newResponse(Response.IStatus status, MessageLite messageLite) {
145+
if (messageLite == null) {
146+
return newFixedLengthResponse(status, "application/x-protobuf", null);
147+
}
148+
149+
byte[] messageBytes = messageLite.toByteArray();
150+
InputStream stream = new ByteArrayInputStream(messageBytes);
151+
return newFixedLengthResponse(status, "application/x-protobuf", stream, messageBytes.length);
152+
}
153+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package app.revanced.extension.spotify.misc.fix;
2+
3+
import android.content.SharedPreferences;
4+
import androidx.annotation.NonNull;
5+
import androidx.annotation.Nullable;
6+
import app.revanced.extension.shared.Logger;
7+
import app.revanced.extension.shared.Utils;
8+
import org.json.JSONException;
9+
import org.json.JSONObject;
10+
11+
import static android.content.Context.MODE_PRIVATE;
12+
13+
class Session {
14+
/**
15+
* Username of the account. Null if this session does not have an authenticated user.
16+
*/
17+
@Nullable
18+
final String username;
19+
/**
20+
* Access token for this session.
21+
*/
22+
final String accessToken;
23+
/**
24+
* Session expiration timestamp in milliseconds.
25+
*/
26+
final Long expirationTime;
27+
/**
28+
* Authentication cookies for this session.
29+
*/
30+
final String cookies;
31+
32+
/**
33+
* @param username Username of the account. Empty if this session does not have an authenticated user.
34+
* @param accessToken Access token for this session.
35+
* @param cookies Authentication cookies for this session.
36+
*/
37+
Session(@Nullable String username, String accessToken, String cookies) {
38+
this(username, accessToken, System.currentTimeMillis() + 60 * 60 * 1000, cookies);
39+
}
40+
41+
private Session(@Nullable String username, String accessToken, long expirationTime, String cookies) {
42+
this.username = username;
43+
this.accessToken = accessToken;
44+
this.expirationTime = expirationTime;
45+
this.cookies = cookies;
46+
}
47+
48+
/**
49+
* @return The number of milliseconds until the access token expires.
50+
*/
51+
long accessTokenExpiresInMillis() {
52+
long currentTime = System.currentTimeMillis();
53+
return expirationTime - currentTime;
54+
}
55+
56+
/**
57+
* @return The number of seconds until the access token expires.
58+
*/
59+
int accessTokenExpiresInSeconds() {
60+
return (int) accessTokenExpiresInMillis() / 1000;
61+
}
62+
63+
/**
64+
* @return True if the access token has expired, false otherwise.
65+
*/
66+
boolean accessTokenExpired() {
67+
return accessTokenExpiresInMillis() <= 0;
68+
}
69+
70+
void save() {
71+
Logger.printInfo(() -> "Saving session: " + this);
72+
73+
SharedPreferences.Editor editor = Utils.getContext().getSharedPreferences("revanced", MODE_PRIVATE).edit();
74+
75+
String json;
76+
try {
77+
json = new JSONObject()
78+
.put("accessToken", accessToken)
79+
.put("expirationTime", expirationTime)
80+
.put("cookies", cookies).toString();
81+
} catch (JSONException ex) {
82+
Logger.printException(() -> "Failed to convert session to stored credential", ex);
83+
return;
84+
}
85+
86+
editor.putString("session_" + username, json);
87+
editor.apply();
88+
}
89+
90+
@Nullable
91+
static Session read(String username) {
92+
Logger.printInfo(() -> "Reading saved session for username: " + username);
93+
94+
SharedPreferences sharedPreferences = Utils.getContext().getSharedPreferences("revanced", MODE_PRIVATE);
95+
String savedJson = sharedPreferences.getString("session_" + username, null);
96+
if (savedJson == null) {
97+
Logger.printInfo(() -> "No session found in shared preferences");
98+
return null;
99+
}
100+
101+
try {
102+
JSONObject json = new JSONObject(savedJson);
103+
String accessToken = json.getString("accessToken");
104+
long expirationTime = json.getLong("expirationTime");
105+
String cookies = json.getString("cookies");
106+
107+
return new Session(username, accessToken, expirationTime, cookies);
108+
} catch (JSONException ex) {
109+
Logger.printException(() -> "Failed to read session from shared preferences", ex);
110+
return null;
111+
}
112+
}
113+
114+
@NonNull
115+
@Override
116+
public String toString() {
117+
return "Session(" +
118+
"username=" + username +
119+
", accessToken=" + accessToken +
120+
", expirationTime=" + expirationTime +
121+
", cookies=" + cookies +
122+
')';
123+
}
124+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package app.revanced.extension.spotify.misc.fix;
2+
3+
import android.view.LayoutInflater;
4+
import app.revanced.extension.shared.Logger;
5+
6+
@SuppressWarnings("unused")
7+
public class SpoofClientPatch {
8+
private static LoginRequestListener listener;
9+
10+
/**
11+
* Injection point.
12+
* <br>
13+
* Start login server.
14+
*/
15+
public static void listen(int port) {
16+
if (listener != null) {
17+
Logger.printInfo(() -> "Listener already running on port " + port);
18+
return;
19+
}
20+
21+
try {
22+
listener = new LoginRequestListener(port);
23+
listener.start();
24+
Logger.printInfo(() -> "Listener running on port " + port);
25+
} catch (Exception ex) {
26+
Logger.printException(() -> "listen failure", ex);
27+
}
28+
}
29+
30+
/**
31+
* Injection point.
32+
* <br>
33+
* Launch login web view.
34+
*/
35+
public static void login(LayoutInflater inflater) {
36+
try {
37+
WebApp.login(inflater.getContext());
38+
} catch (Exception ex) {
39+
Logger.printException(() -> "login failure", ex);
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)