feat: Microsoft Account authentication

This commit is contained in:
yuhuihuang
2020-12-10 21:47:53 +08:00
parent 4715a95a54
commit c191186023
26 changed files with 835 additions and 21 deletions

View File

@@ -25,7 +25,52 @@ import java.util.Map;
*/
public abstract class AccountFactory<T extends Account> {
public enum AccountLoginType {
/**
* Either username or password should not be provided.
* AccountFactory will take its own way to check credentials.
*/
NONE(false, false),
/**
* AccountFactory only needs username.
*/
USERNAME(true, false),
/**
* AccountFactory needs both username and password for credential verification.
*/
USERNAME_PASSWORD(true, true);
public final boolean requiresUsername, requiresPassword;
AccountLoginType(boolean requiresUsername, boolean requiresPassword) {
this.requiresUsername = requiresUsername;
this.requiresPassword = requiresPassword;
}
}
/**
* Informs how this account factory verifies user's credential.
* @see AccountLoginType
*/
public abstract AccountLoginType getLoginType();
/**
* Create a new(to be verified via network) account, and log in.
* @param selector for character selection if multiple characters belong to single account. Pick out which character to act as.
* @param username username of the account if needed.
* @param password password of the account if needed.
* @param additionalData extra data for specific account factory.
* @return logged-in account.
* @throws AuthenticationException if an error occurs when logging in.
*/
public abstract T create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException;
/**
* Create a existing(stored in local files) account.
* @param storage serialized account data.
* @return account stored in local storage. Credentials may expired, and you should refresh account state later.
*/
public abstract T fromStorage(Map<Object, Object> storage);
}

View File

@@ -44,6 +44,11 @@ public class AuthlibInjectorAccountFactory extends AccountFactory<AuthlibInjecto
this.serverLookup = serverLookup;
}
@Override
public AccountLoginType getLoginType() {
return AccountLoginType.USERNAME_PASSWORD;
}
@Override
public AuthlibInjectorAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
Objects.requireNonNull(selector);

View File

@@ -0,0 +1,117 @@
package org.jackhuang.hmcl.auth.microsoft;
import org.jackhuang.hmcl.auth.*;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static java.util.Objects.requireNonNull;
public class MicrosoftAccount extends Account {
protected final MicrosoftService service;
protected UUID characterUUID;
private boolean authenticated = false;
private MicrosoftSession session;
protected MicrosoftAccount(MicrosoftService service, MicrosoftSession session) {
this.service = requireNonNull(service);
this.session = requireNonNull(session);
this.characterUUID = requireNonNull(session.getProfile().getId());
}
protected MicrosoftAccount(MicrosoftService service, CharacterSelector characterSelector) throws AuthenticationException {
this.service = requireNonNull(service);
MicrosoftSession acquiredSession = service.authenticate();
if (acquiredSession.getProfile() == null) {
session = service.refresh(acquiredSession);
} else {
session = acquiredSession;
}
characterUUID = session.getProfile().getId();
authenticated = true;
}
@Override
public String getUsername() {
// TODO: email of Microsoft account is blocked by oauth.
return "";
}
@Override
public String getCharacter() {
return session.getProfile().getName();
}
@Override
public UUID getUUID() {
return session.getProfile().getId();
}
@Override
public AuthInfo logIn() throws AuthenticationException {
if (!authenticated) {
if (service.validate(session.getTokenType(), session.getAccessToken())) {
authenticated = true;
} else {
MicrosoftSession acquiredSession = service.authenticate();
if (acquiredSession.getProfile() == null) {
session = service.refresh(acquiredSession);
} else {
session = acquiredSession;
}
characterUUID = session.getProfile().getId();
authenticated = true;
}
}
return session.toAuthInfo();
}
@Override
public AuthInfo logInWithPassword(String password) throws AuthenticationException {
throw new UnsupportedOperationException();
}
@Override
public Optional<AuthInfo> playOffline() {
return Optional.of(session.toAuthInfo());
}
@Override
public Map<Object, Object> toStorage() {
return session.toStorage();
}
public MicrosoftService getService() {
return service;
}
@Override
public void clearCache() {
authenticated = false;
}
@Override
public String toString() {
return "MicrosoftAccount[uuid=" + characterUUID + ", name=" + getCharacter() + "]";
}
@Override
public int hashCode() {
return characterUUID.hashCode();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MicrosoftAccount that = (MicrosoftAccount) o;
return characterUUID.equals(that.characterUUID);
}
}

View File

@@ -0,0 +1,36 @@
package org.jackhuang.hmcl.auth.microsoft;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.CharacterSelector;
import java.util.Map;
import java.util.Objects;
public class MicrosoftAccountFactory extends AccountFactory<MicrosoftAccount> {
private final MicrosoftService service;
public MicrosoftAccountFactory(MicrosoftService service) {
this.service = service;
}
@Override
public AccountLoginType getLoginType() {
return AccountLoginType.NONE;
}
@Override
public MicrosoftAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
Objects.requireNonNull(selector);
return new MicrosoftAccount(service, selector);
}
@Override
public MicrosoftAccount fromStorage(Map<Object, Object> storage) {
Objects.requireNonNull(storage);
MicrosoftSession session = MicrosoftSession.fromStorage(storage);
return new MicrosoftAccount(service, session);
}
}

View File

@@ -0,0 +1,280 @@
package org.jackhuang.hmcl.auth.microsoft;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.NoCharacterException;
import org.jackhuang.hmcl.auth.ServerDisconnectException;
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static java.util.Objects.requireNonNull;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
public class MicrosoftService {
private static final Pattern OAUTH_URL_PATTERN = Pattern.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
private final WebViewCallback callback;
public MicrosoftService(WebViewCallback callback) {
this.callback = callback;
}
public MicrosoftSession authenticate() throws AuthenticationException {
requireNonNull(callback);
try {
// Microsoft OAuth Flow
String code = callback.show(this, urlToBeTested -> OAUTH_URL_PATTERN.matcher(urlToBeTested).find(), "https://login.live.com/oauth20_authorize.srf" +
"?client_id=00000000402b5328" +
"&response_type=code" +
"&scope=service%3A%3Auser.auth.xboxlive.com%3A%3AMBI_SSL" +
"&redirect_uri=https%3A%2F%2Flogin.live.com%2Foauth20_desktop.srf")
.thenApply(url -> {
Matcher matcher = OAUTH_URL_PATTERN.matcher(url);
matcher.find();
return matcher.group(1);
})
.get();
// Authorization Code -> Token
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf").form(mapOf(
pair("client_id", "00000000402b5328"),
pair("code", code),
pair("grant_type", "authorization_code"),
pair("redirect_uri", "https://login.live.com/oauth20_desktop.srf"),
pair("scope", "service::user.auth.xboxlive.com::MBI_SSL"))).getString();
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText, LiveAuthorizationResponse.class);
// Authenticate with XBox Live
XBoxLiveAuthenticationResponse xboxResponse = HttpRequest.POST("https://user.auth.xboxlive.com/user/authenticate")
.json(mapOf(
pair("Properties", mapOf(
pair("AuthMethod", "RPS"),
pair("SiteName", "user.auth.xboxlive.com"),
pair("RpsTicket", response.accessToken)
)),
pair("RelyingParty", "http://auth.xboxlive.com"),
pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
// Authenticate with XSTS
XBoxLiveAuthenticationResponse xstsResponse = HttpRequest.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(mapOf(
pair("Properties", mapOf(
pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token))
)),
pair("RelyingParty", "rp://api.minecraftservices.com/"),
pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
// Authenticate with Minecraft
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + xstsResponse.token)))
.getJson(MinecraftLoginWithXBoxResponse.class);
// Checking Game Ownership
MinecraftStoreResponse storeResponse = HttpRequest.GET("https://api.minecraftservices.com/entitlements/mcstore")
.authorization(String.format("%s %s", minecraftResponse.tokenType, minecraftResponse.accessToken))
.getJson(MinecraftStoreResponse.class);
handleErrorResponse(storeResponse);
if (storeResponse.items.isEmpty()) {
throw new NoCharacterException();
}
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, new MicrosoftSession.User(minecraftResponse.username), null);
} catch (IOException | ExecutionException | InterruptedException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(e);
}
}
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
try {
// Get the profile
MinecraftProfileResponse profileResponse = HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(String.format("%s %s", oldSession.getTokenType(), oldSession.getAccessToken()))
.getJson(MinecraftProfileResponse.class);
handleErrorResponse(profileResponse);
return new MicrosoftSession(oldSession.getTokenType(), oldSession.getAccessToken(), oldSession.getUser(), new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(e);
}
}
public boolean validate(String tokenType, String accessToken) throws AuthenticationException {
requireNonNull(tokenType);
requireNonNull(accessToken);
try {
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(String.format("%s %s", tokenType, accessToken))
.filter((url, responseCode) -> {
if (responseCode / 100 == 4) {
throw new ResponseCodeException(url, responseCode);
}
})
.getString();
return true;
} catch (ResponseCodeException e) {
return false;
} catch (IOException e) {
throw new ServerDisconnectException(e);
}
}
private static void handleErrorResponse(MinecraftErrorResponse response) throws AuthenticationException {
if (response.error != null) {
throw new RemoteAuthenticationException(response.error, response.errorMessage, response.developerMessage);
}
}
private static class LiveAuthorizationResponse {
@SerializedName("token_type")
String tokenType;
@SerializedName("expires_in")
int expiresIn;
@SerializedName("scope")
String scope;
@SerializedName("access_token")
String accessToken;
@SerializedName("refresh_token")
String refreshToken;
@SerializedName("user_id")
String userId;
@SerializedName("foci")
String foci;
}
private static class XBoxLiveAuthenticationResponseDisplayClaims {
List<Map<Object, Object>> xui;
}
private static class XBoxLiveAuthenticationResponse {
@SerializedName("IssueInstant")
String issueInstant;
@SerializedName("NotAfter")
String notAfter;
@SerializedName("Token")
String token;
@SerializedName("DisplayClaims")
XBoxLiveAuthenticationResponseDisplayClaims displayClaims;
}
private static class MinecraftLoginWithXBoxResponse {
@SerializedName("username")
String username;
@SerializedName("roles")
List<String> roles;
@SerializedName("access_token")
String accessToken;
@SerializedName("token_type")
String tokenType;
@SerializedName("expires_in")
int expiresIn;
}
private static class MinecraftStoreResponseItem {
@SerializedName("name")
String name;
@SerializedName("signature")
String signature;
}
private static class MinecraftStoreResponse extends MinecraftErrorResponse {
@SerializedName("items")
List<MinecraftStoreResponseItem> items;
@SerializedName("signature")
String signature;
@SerializedName("keyId")
String keyId;
}
private static class MinecraftProfileResponseSkin implements Validation {
public String id;
public String state;
public String url;
public String variant;
public String alias;
@Override
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(id, "id cannot be null");
Validation.requireNonNull(state, "state cannot be null");
Validation.requireNonNull(url, "url cannot be null");
Validation.requireNonNull(variant, "variant cannot be null");
Validation.requireNonNull(alias, "alias cannot be null");
}
}
private static class MinecraftProfileResponseCape {
}
private static class MinecraftProfileResponse extends MinecraftErrorResponse implements Validation {
@SerializedName("id")
UUID id;
@SerializedName("name")
String name;
@SerializedName("skins")
List<MinecraftProfileResponseSkin> skins;
@SerializedName("capes")
List<MinecraftProfileResponseCape> capes;
@Override
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(id, "id cannot be null");
Validation.requireNonNull(name, "name cannot be null");
Validation.requireNonNull(skins, "skins cannot be null");
Validation.requireNonNull(capes, "capes cannot be null");
}
}
private static class MinecraftErrorResponse {
public String path;
public String errorType;
public String error;
public String errorMessage;
public String developerMessage;
}
public interface WebViewCallback {
CompletableFuture<String> show(MicrosoftService service, Predicate<String> urlTester, String initialURL);
}
}

View File

@@ -0,0 +1,100 @@
package org.jackhuang.hmcl.auth.microsoft;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import java.util.Map;
import java.util.UUID;
import static java.util.Objects.requireNonNull;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.Pair.pair;
public class MicrosoftSession {
private final String tokenType;
private final String accessToken;
private final User user;
private final GameProfile profile;
public MicrosoftSession(String tokenType, String accessToken, User user, GameProfile profile) {
this.tokenType = tokenType;
this.accessToken = accessToken;
this.user = user;
this.profile = profile;
}
public String getTokenType() {
return tokenType;
}
public String getAccessToken() {
return accessToken;
}
public User getUser() {
return user;
}
public GameProfile getProfile() {
return profile;
}
public static MicrosoftSession fromStorage(Map<?, ?> storage) {
UUID uuid = tryCast(storage.get("uuid"), String.class).map(UUIDTypeAdapter::fromString).orElseThrow(() -> new IllegalArgumentException("uuid is missing"));
String name = tryCast(storage.get("displayName"), String.class).orElseThrow(() -> new IllegalArgumentException("displayName is missing"));
String tokenType = tryCast(storage.get("tokenType"), String.class).orElseThrow(() -> new IllegalArgumentException("tokenType is missing"));
String accessToken = tryCast(storage.get("accessToken"), String.class).orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
String userId = tryCast(storage.get("userid"), String.class).orElseThrow(() -> new IllegalArgumentException("userid is missing"));
return new MicrosoftSession(tokenType, accessToken, new User(userId), new GameProfile(uuid, name));
}
public Map<Object, Object> toStorage() {
requireNonNull(profile);
requireNonNull(user);
return mapOf(
pair("tokenType", tokenType),
pair("accessToken", accessToken),
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())),
pair("displayName", profile.getName()),
pair("userid", user.id)
);
}
public AuthInfo toAuthInfo() {
requireNonNull(profile);
return new AuthInfo(profile.getName(), profile.getId(), accessToken, "{}");
}
public static class User {
private final String id;
public User(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
public static class GameProfile {
private final UUID id;
private final String name;
public GameProfile(UUID id, String name) {
this.id = id;
this.name = name;
}
public UUID getId() {
return id;
}
public String getName() {
return name;
}
}
}

View File

@@ -37,6 +37,11 @@ public class OfflineAccountFactory extends AccountFactory<OfflineAccount> {
private OfflineAccountFactory() {
}
@Override
public AccountLoginType getLoginType() {
return AccountLoginType.USERNAME;
}
public OfflineAccount create(String username, UUID uuid) {
return new OfflineAccount(username, uuid);
}

View File

@@ -17,9 +17,8 @@
*/
package org.jackhuang.hmcl.auth.yggdrasil;
import static java.util.Objects.requireNonNull;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.jackhuang.hmcl.util.Immutable;
@@ -38,7 +37,7 @@ public class CompleteGameProfile extends GameProfile {
public CompleteGameProfile(UUID id, String name, Map<String, String> properties) {
super(id, name);
this.properties = requireNonNull(properties);
this.properties = Objects.requireNonNull(properties);
}
public CompleteGameProfile(GameProfile profile, Map<String, String> properties) {

View File

@@ -17,8 +17,7 @@
*/
package org.jackhuang.hmcl.auth.yggdrasil;
import static java.util.Objects.requireNonNull;
import java.util.Objects;
import java.util.UUID;
import org.jackhuang.hmcl.util.Immutable;
@@ -40,8 +39,8 @@ public class GameProfile implements Validation {
private final String name;
public GameProfile(UUID id, String name) {
this.id = requireNonNull(id);
this.name = requireNonNull(name);
this.id = Objects.requireNonNull(id);
this.name = Objects.requireNonNull(name);
}
public UUID getId() {
@@ -54,9 +53,7 @@ public class GameProfile implements Validation {
@Override
public void validate() throws JsonParseException {
if (id == null)
throw new JsonParseException("Game profile id cannot be null");
if (name == null)
throw new JsonParseException("Game profile name cannot be null");
Validation.requireNonNull(id, "Game profile id cannot be null");
Validation.requireNonNull(name, "Game profile name cannot be null");
}
}

View File

@@ -209,6 +209,7 @@ public class YggdrasilAccount extends Account {
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || obj.getClass() != YggdrasilAccount.class)
return false;
YggdrasilAccount another = (YggdrasilAccount) obj;

View File

@@ -36,12 +36,17 @@ public class YggdrasilAccountFactory extends AccountFactory<YggdrasilAccount> {
public static final YggdrasilAccountFactory MOJANG = new YggdrasilAccountFactory(YggdrasilService.MOJANG);
private YggdrasilService service;
private final YggdrasilService service;
public YggdrasilAccountFactory(YggdrasilService service) {
this.service = service;
}
@Override
public AccountLoginType getLoginType() {
return AccountLoginType.USERNAME_PASSWORD;
}
@Override
public YggdrasilAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
Objects.requireNonNull(selector);

View File

@@ -38,4 +38,9 @@ public interface Validation {
* @throws TolerableValidationException if we want to replace this object with null (i.e. the object does not fulfill the constraints).
*/
void validate() throws JsonParseException, TolerableValidationException;
static void requireNonNull(Object object, String message) throws JsonParseException {
if (object == null)
throw new JsonParseException(message);
}
}

View File

@@ -0,0 +1,133 @@
package org.jackhuang.hmcl.util.io;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.jackhuang.hmcl.util.gson.JsonUtils.GSON;
import static org.jackhuang.hmcl.util.io.NetworkUtils.createHttpConnection;
import static org.jackhuang.hmcl.util.io.NetworkUtils.resolveConnection;
public abstract class HttpRequest {
protected final URL url;
protected final String method;
protected final Map<String, String> headers = new HashMap<>();
protected ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester;
private HttpRequest(URL url, String method) {
this.url = url;
this.method = method;
}
public HttpRequest accept(String contentType) {
return header("Accept", contentType);
}
public HttpRequest authorization(String token) {
return header("Authorization", token);
}
public HttpRequest contentType(String contentType) {
return header("Content-Type", contentType);
}
public HttpRequest header(String key, String value) {
headers.put(key, value);
return this;
}
public abstract String getString() throws IOException;
public <T> T getJson(Class<T> typeOfT) throws IOException, JsonParseException {
return JsonUtils.fromNonNullJson(getString(), typeOfT);
}
public HttpRequest filter(ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester) {
this.responseCodeTester = responseCodeTester;
return this;
}
protected HttpURLConnection createConnection() throws IOException {
HttpURLConnection con = createHttpConnection(url);
con.setRequestMethod(method);
for (Map.Entry<String, String> entry : headers.entrySet()) {
con.setRequestProperty(entry.getKey(), entry.getValue());
}
return con;
}
public static class HttpGetRequest extends HttpRequest {
public HttpGetRequest(URL url) {
super(url, "GET");
}
public String getString() throws IOException {
HttpURLConnection con = createConnection();
con = resolveConnection(con);
return IOUtils.readFullyAsString(con.getInputStream());
}
}
public static class HttpPostRequest extends HttpRequest {
private byte[] bytes;
public HttpPostRequest(URL url) {
super(url, "POST");
}
public <T> HttpPostRequest json(Object payload) throws JsonParseException {
return string(payload instanceof String ? (String) payload : GSON.toJson(payload),
"application/json");
}
public HttpPostRequest form(Map<String, String> params) {
return string(NetworkUtils.withQuery("", params), "application/x-www-form-urlencoded");
}
public HttpPostRequest string(String payload, String contentType) {
bytes = payload.getBytes(UTF_8);
header("Content-Length", "" + bytes.length);
contentType(contentType + "; charset=utf-8");
return this;
}
public String getString() throws IOException {
HttpURLConnection con = createConnection();
con.setDoOutput(true);
if (responseCodeTester != null) {
responseCodeTester.accept(url, con.getResponseCode());
}
try (OutputStream os = con.getOutputStream()) {
os.write(bytes);
}
return NetworkUtils.readData(con);
}
}
public static HttpGetRequest GET(String url) throws MalformedURLException {
return GET(new URL(url));
}
public static HttpGetRequest GET(URL url) {
return new HttpGetRequest(url);
}
public static HttpPostRequest POST(String url) throws MalformedURLException {
return POST(new URL(url));
}
public static HttpPostRequest POST(URL url) {
return new HttpPostRequest(url);
}
}

View File

@@ -43,7 +43,9 @@ public final class NetworkUtils {
if (param.getValue() == null)
continue;
if (first) {
sb.append('?');
if (!baseUrl.isEmpty()) {
sb.append('?');
}
first = false;
} else {
sb.append('&');