package com.gianlu.pretendyourexyzzy.api; import android.app.Activity; import android.content.Context; import android.net.Uri; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; import androidx.annotation.WorkerThread; import com.gianlu.commonutils.CommonUtils; import com.gianlu.commonutils.lifecycle.LifecycleAwareHandler; import com.gianlu.commonutils.lifecycle.LifecycleAwareRunnable; import com.gianlu.commonutils.preferences.Prefs; import com.gianlu.commonutils.preferences.json.JsonStoring; import com.gianlu.commonutils.ui.OfflineActivity; import com.gianlu.pretendyourexyzzy.LoadingActivity; import com.gianlu.pretendyourexyzzy.PK; import com.gianlu.pretendyourexyzzy.api.models.CahConfig; import com.gianlu.pretendyourexyzzy.api.models.FirstLoad; import com.gianlu.pretendyourexyzzy.api.models.FirstLoadAndConfig; import com.gianlu.pretendyourexyzzy.api.models.PollMessage; import com.gianlu.pretendyourexyzzy.api.models.metrics.GameHistory; import com.gianlu.pretendyourexyzzy.api.models.metrics.GameRound; import com.gianlu.pretendyourexyzzy.api.models.metrics.SessionHistory; import com.gianlu.pretendyourexyzzy.api.models.metrics.SessionStats; import com.gianlu.pretendyourexyzzy.api.models.metrics.UserHistory; import org.jetbrains.annotations.NotNull; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.Closeable; import java.io.IOException; import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLException; import okhttp3.FormBody; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; public class Pyx implements Closeable { protected final static int AJAX_TIMEOUT = 5; protected final static int POLLING_TIMEOUT = 30; public final Server server; protected final LifecycleAwareHandler handler; protected final OkHttpClient client; protected final ExecutorService executor = Executors.newFixedThreadPool(5); Pyx() throws NoServersException { this.handler = new LifecycleAwareHandler(new Handler(Looper.getMainLooper())); this.server = Server.lastServer(); this.client = new OkHttpClient.Builder().addInterceptor(new UserAgentInterceptor()).build(); } protected Pyx(Server server, LifecycleAwareHandler handler, OkHttpClient client) { this.server = server; this.handler = handler; this.client = client; } @NonNull static Pyx getStandard() throws NoServersException { return InstanceHolder.holder().instantiateStandard(); } @NonNull public static Pyx get() throws LevelMismatchException { return InstanceHolder.holder().get(InstanceHolder.Level.STANDARD); } static void raiseException(@NonNull JSONObject obj) throws PyxException { if (obj.optBoolean("e", false) || obj.has("ec")) throw new PyxException(obj); } public static void invalidate() { InstanceHolder.holder().invalidate(); } protected void prepareRequest(@NonNull Op operation, @NonNull Request.Builder request) { if (operation == Op.FIRST_LOAD) { String lastSessionId = Prefs.getString(PK.LAST_JSESSIONID, null); if (lastSessionId != null) request.addHeader("Cookie", "JSESSIONID=" + lastSessionId); } } @WorkerThread protected final PyxResponse request(@NonNull Op operation, PyxRequest.Param... params) throws IOException, JSONException, PyxException { return request(operation, false, params); } private static final String TAG = Pyx.class.getSimpleName(); @NonNull @WorkerThread private PyxResponse request(@NonNull Op operation, boolean retried, PyxRequest.Param... params) throws IOException, JSONException, PyxException { FormBody.Builder reqBody = new FormBody.Builder(StandardCharsets.UTF_8).add("o", operation.val); for (PyxRequest.Param pair : params) { if (pair.value() != null) reqBody.add(pair.key(), pair.value("")); } Request.Builder builder = new Request.Builder() .url(server.ajax()) .post(reqBody.build()); prepareRequest(operation, builder); try (Response resp = client.newBuilder() .connectTimeout(AJAX_TIMEOUT, TimeUnit.SECONDS) .readTimeout(AJAX_TIMEOUT, TimeUnit.SECONDS) .build().newCall(builder.build()).execute()) { ResponseBody respBody = resp.body(); if (respBody != null) { JSONObject obj = new JSONObject(respBody.string()); try { raiseException(obj); Log.v(TAG, operation + "; " + Arrays.toString(params)); } catch (PyxException ex) { Log.d(TAG, "op = " + operation + ", params = " + Arrays.toString(params) + ", code = " + ex.errorCode + ", retried = " + retried, ex); if (!retried && ex.shouldRetry()) return request(operation, true, params); throw ex; } return new PyxResponse(resp, obj); } else { throw new StatusCodeException(resp); } } catch (SocketTimeoutException ex) { if (!retried) return request(operation, true, params); else throw ex; } catch (RuntimeException ex) { if (ex.getCause() instanceof SSLException) throw (SSLException) ex.getCause(); else throw ex; } } public final void request(@NonNull PyxRequest request, @Nullable Activity activity, @NonNull OnSuccess listener) { executor.execute(new RequestRunner(request, activity, listener)); } @WorkerThread public final void requestSync(@NonNull PyxRequest request) throws JSONException, PyxException, IOException { request(request.op, request.params); } public final <E> void request(@NonNull PyxRequestWithResult<E> request, @Nullable Activity activity, @NonNull OnResult<E> listener) { executor.execute(new RequestWithResultRunner<>(request, activity, listener)); } @NonNull @WorkerThread public final <E> E requestSync(PyxRequestWithResult<E> request) throws JSONException, PyxException, IOException { PyxResponse resp = request(request.op, request.params); return request.processor.process(resp.resp, resp.obj); } @WorkerThread @NonNull private FirstLoadAndConfig firstLoadSync() throws PyxException, IOException, JSONException { FirstLoad fl = requestSync(PyxRequests.firstLoad()); CahConfig cahConfig; try (Response resp = client.newBuilder() .connectTimeout(AJAX_TIMEOUT, TimeUnit.SECONDS) .readTimeout(AJAX_TIMEOUT, TimeUnit.SECONDS) .build().newCall(new Request.Builder() .url(server.config()).get().build()).execute()) { ResponseBody respBody = resp.body(); if (respBody != null) cahConfig = new CahConfig(respBody.string()); else throw new StatusCodeException(resp); } try (Response resp = client.newBuilder() .connectTimeout(AJAX_TIMEOUT, TimeUnit.SECONDS) .readTimeout(AJAX_TIMEOUT, TimeUnit.SECONDS) .build().newCall(new Request.Builder() .url(server.stats()).get().build()).execute()) { ResponseBody respBody = resp.body(); if (respBody != null) cahConfig.appendStats(respBody.string()); else throw new StatusCodeException(resp); } return new FirstLoadAndConfig(fl, cahConfig); } @NonNull private String requestSync(@NonNull HttpUrl url) throws IOException { try (Response resp = client.newBuilder() .connectTimeout(AJAX_TIMEOUT, TimeUnit.SECONDS) .readTimeout(AJAX_TIMEOUT, TimeUnit.SECONDS) .build().newCall(new Request.Builder() .url(url).get().build()).execute()) { if (resp.code() < 200 || resp.code() >= 300) throw new StatusCodeException(resp); ResponseBody respBody = resp.body(); if (respBody != null) return respBody.string(); else throw new StatusCodeException(resp); } } public final void getUserHistory(@NonNull String userId, @Nullable Activity activity, @NonNull OnResult<UserHistory> listener) { final HttpUrl url = server.userHistory(userId); if (url == null) { listener.onException(new MetricsNotSupportedException(server)); return; } executor.execute(new LifecycleAwareRunnable(handler, activity == null ? listener : activity) { @Override public void run() { try { UserHistory history = new UserHistory(new JSONObject(requestSync(url))); post(() -> listener.onDone(history)); } catch (JSONException | IOException ex) { post(() -> listener.onException(ex)); } } }); } public final void getSessionHistory(@NonNull String sessionId, @Nullable Activity activity, @NonNull OnResult<SessionHistory> listener) { final HttpUrl url = server.sessionHistory(sessionId); if (url == null) { listener.onException(new MetricsNotSupportedException(server)); return; } executor.execute(new LifecycleAwareRunnable(handler, activity == null ? listener : activity) { @Override public void run() { try { SessionHistory history = new SessionHistory(new JSONObject(requestSync(url))); post(() -> listener.onDone(history)); } catch (JSONException | IOException ex) { post(() -> listener.onException(ex)); } } }); } public final void getSessionStats(@NonNull String sessionId, @Nullable Activity activity, @NonNull OnResult<SessionStats> listener) { final HttpUrl url = server.sessionStats(sessionId); if (url == null) { listener.onException(new MetricsNotSupportedException(server)); return; } executor.execute(new LifecycleAwareRunnable(handler, activity == null ? listener : activity) { @Override public void run() { try { SessionStats history = new SessionStats(new JSONObject(requestSync(url))); post(() -> listener.onDone(history)); } catch (JSONException | IOException ex) { post(() -> listener.onException(ex)); } } }); } public final void getGameHistory(@NonNull String gameId, @Nullable Activity activity, @NonNull OnResult<GameHistory> listener) { final HttpUrl url = server.gameHistory(gameId); if (url == null) { listener.onException(new MetricsNotSupportedException(server)); return; } executor.execute(new LifecycleAwareRunnable(handler, activity == null ? listener : activity) { @Override public void run() { try { GameHistory history = new GameHistory(new JSONArray(requestSync(url))); post(() -> listener.onDone(history)); } catch (JSONException | IOException ex) { post(() -> listener.onException(ex)); } } }); } public final void getGameRound(@NonNull String roundId, @Nullable Activity activity, @NonNull OnResult<GameRound> listener) { final HttpUrl url = server.gameRound(roundId); if (url == null) { listener.onException(new MetricsNotSupportedException(server)); return; } executor.execute(new LifecycleAwareRunnable(handler, activity == null ? listener : activity) { @Override public void run() { try { GameRound history = new GameRound(new JSONObject(requestSync(url))); post(() -> listener.onDone(history)); } catch (JSONException | IOException ex) { post(() -> listener.onException(ex)); } } }); } public final void firstLoad(@Nullable Activity activity, @NonNull OnResult<FirstLoadedPyx> listener) { final InstanceHolder holder = InstanceHolder.holder(); try { FirstLoadedPyx pyx = holder.get(InstanceHolder.Level.FIRST_LOADED); handler.post(activity == null ? listener : activity, () -> listener.onDone(pyx)); } catch (LevelMismatchException exx) { executor.execute(new LifecycleAwareRunnable(handler, activity == null ? listener : activity) { @Override public void run() { try { FirstLoadAndConfig result = firstLoadSync(); FirstLoadedPyx pyx = new FirstLoadedPyx(server, handler, client, result); holder.set(pyx); post(() -> listener.onDone(pyx)); } catch (PyxException | IOException | JSONException ex) { post(() -> listener.onException(ex)); } } }); } } @Override public void close() { client.dispatcher().executorService().shutdown(); } public boolean hasMetrics() { return server.metricsUrl != null; } public boolean isServerSecure() { return server.isHttps(); } public enum Op { REGISTER("r"), FIRST_LOAD("fl"), LOGOUT("lo"), GET_GAMES_LIST("ggl"), CHAT("c"), GET_NAMES_LIST("gn"), JOIN_GAME("jg"), SPECTATE_GAME("vg"), LEAVE_GAME("lg"), GET_GAME_INFO("ggi"), GET_GAME_CARDS("gc"), GAME_CHAT("GC"), PLAY_CARD("pc"), JUDGE_SELECT("js"), CREATE_GAME("cg"), START_GAME("sg"), CHANGE_GAME_OPTIONS("cgo"), LIST_CARDCAST_CARD_SETS("clc"), ADD_CARDCAST_CARD_SET("cac"), REMOVE_CARDCAST_CARD_SET("crc"), WHOIS("Wi"); private final String val; Op(@NonNull String val) { this.val = val; } } public interface Processor<E> { @NonNull @WorkerThread E process(@NonNull Response response, @NonNull JSONObject obj) throws JSONException; } @UiThread public interface OnSuccess { void onDone(); void onException(@NonNull Exception ex); } @UiThread public interface OnResult<E> { void onDone(@NonNull E result); void onException(@NonNull Exception ex); } @UiThread public interface OnEventListener { void onPollMessage(@NonNull PollMessage message) throws JSONException; void onStoppedPolling(); } public static class NoServersException extends Exception { public void solve(@NonNull Context context) { OfflineActivity.startActivity(context, LoadingActivity.class); } } public static class MetricsNotSupportedException extends Exception { MetricsNotSupportedException(Server server) { super("Metrics aren't supported on this server: " + server.name); } } protected static class PyxResponse { private final Response resp; private final JSONObject obj; PyxResponse(@NonNull Response resp, @NonNull JSONObject obj) { this.resp = resp; this.obj = obj; } } public static class Server { public final HttpUrl url; public final String name; private final boolean editable; private final HttpUrl metricsUrl; private final Params params; public transient volatile ServersChecker.CheckResult status = null; private transient HttpUrl ajaxUrl; private transient HttpUrl pollingUrl; private transient HttpUrl configUrl; private transient HttpUrl statsUrl; private static final String TAG = Server.class.getSimpleName(); public Server(@NonNull HttpUrl url, @Nullable HttpUrl metricsUrl, @NonNull String name, @NonNull Params params, boolean editable) { this.url = url; this.metricsUrl = metricsUrl; this.name = name; this.params = params; this.editable = editable; } Server(JSONObject obj) throws JSONException { this(parseUrlOrThrow(obj.getString("uri")), parseNullableUrl(obj.optString("metrics")), obj.getString("name"), obj.has("params") ? new Params(obj.getJSONObject("params")) : Params.defaultValues(), obj.optBoolean("editable", true)); } @Nullable private static HttpUrl parseNullableUrl(@Nullable String url) { if (url == null || url.isEmpty()) return null; else return HttpUrl.parse(url); } static void parseAndSave(@NonNull JSONArray array) throws JSONException { List<Server> servers = new ArrayList<>(array.length()); for (int i = 0; i < array.length(); i++) { JSONObject obj = array.getJSONObject(i); String name = CommonUtils.getStupidString(obj, "name"); HttpUrl url = new HttpUrl.Builder() .host(obj.getString("host")) .scheme(obj.getBoolean("secure") ? "https" : "http") .port(obj.getInt("port")) .encodedPath(obj.getString("path")) .build(); String metrics = CommonUtils.getStupidString(obj, "metrics"); servers.add(new Server(url, metrics == null ? null : HttpUrl.parse(metrics), name == null ? (url.host() + " server") : name, obj.has("params") ? new Params(obj.getJSONObject("params")) : Params.defaultValues(), false)); } JSONArray json = new JSONArray(); for (Server server : servers) json.put(server.toJson()); JsonStoring.intoPrefs().putJsonArray(PK.API_SERVERS, json); Prefs.putLong(PK.API_SERVERS_CACHE_AGE, System.currentTimeMillis()); } @Nullable public static Server fromUrl(Uri url) { List<Server> servers = loadAllServers(); for (Server server : servers) { if (server.url.host().equals(url.getHost()) && server.url.port() == url.getPort()) return server; } return null; } @NonNull private static HttpUrl parseUrlOrThrow(String str) throws JSONException { if (str == null) throw new JSONException("str is null"); try { return HttpUrl.get(str); } catch (IllegalArgumentException ex) { if (Build.VERSION.SDK_INT >= 27) throw new JSONException(ex); else throw new JSONException(ex.getMessage()); } } @Nullable public static HttpUrl parseUrl(String str) { if (str == null) return null; try { return HttpUrl.parse(str); } catch (IllegalStateException ex) { Log.w(TAG, "Failed parsing URL: " + str, ex); return null; } } @NonNull public static List<Server> loadAllServers() { List<Server> all = new ArrayList<>(10); all.addAll(loadServers(PK.USER_SERVERS)); all.addAll(loadServers(PK.API_SERVERS)); return all; } @NonNull private static List<Server> loadServers(Prefs.Key key) { List<Server> servers = new ArrayList<>(); JSONArray array; try { array = JsonStoring.intoPrefs().getJsonArray(key); if (array == null) array = new JSONArray(); } catch (JSONException ex) { Log.e(TAG, "Failed parsing JSON.", ex); return new ArrayList<>(); } for (int i = 0; i < array.length(); i++) { try { servers.add(new Server(array.getJSONObject(i))); } catch (JSONException ex) { Log.e(TAG, "Failed parsing JSON.", ex); } } return servers; } @Nullable private static Server getServer(@NonNull Prefs.Key key, @NonNull String name) throws JSONException { JSONArray array = JsonStoring.intoPrefs().getJsonArray(key); if (array == null) array = new JSONArray(); for (int i = 0; i < array.length(); i++) { JSONObject obj = array.getJSONObject(i); if (Objects.equals(obj.optString("name"), name)) return new Server(obj); } return null; } @NonNull public static Server lastServer() throws NoServersException { String name = Prefs.getString(PK.LAST_SERVER, null); List<Server> apiServers = loadServers(PK.API_SERVERS); if (name == null && !apiServers.isEmpty()) return apiServers.get(0); if (name == null) throw new IllegalStateException("Cannot load any server!"); Server server = null; try { server = getServer(PK.USER_SERVERS, name); } catch (JSONException ex) { Log.e(TAG, "Failed parsing JSON.", ex); } if (server == null) { try { server = getServer(PK.API_SERVERS, name); } catch (JSONException ex) { Log.e(TAG, "Failed parsing JSON.", ex); } } if (server == null && !apiServers.isEmpty()) server = apiServers.get(0); if (server == null) throw new NoServersException(); return server; } public static void addUserServer(Server server) throws JSONException { if (!server.isEditable()) return; JSONArray array = JsonStoring.intoPrefs().getJsonArray(PK.USER_SERVERS); if (array == null) array = new JSONArray(); for (int i = array.length() - 1; i >= 0; i--) { if (Objects.equals(array.getJSONObject(i).getString("name"), server.name)) array.remove(i); } array.put(server.toJson()); JsonStoring.intoPrefs().putJsonArray(PK.USER_SERVERS, array); } public static void removeUserServer(Server server) { if (!server.isEditable()) return; try { JSONArray array = JsonStoring.intoPrefs().getJsonArray(PK.USER_SERVERS); if (array == null) array = new JSONArray(); for (int i = 0; i < array.length(); i++) { JSONObject obj = array.getJSONObject(i); if (Objects.equals(obj.optString("name"), server.name)) { array.remove(i); break; } } JsonStoring.intoPrefs().putJsonArray(PK.USER_SERVERS, array); } catch (JSONException ex) { Log.e(TAG, "Failed parsing JSON.", ex); } } public static boolean hasServer(String name) { try { return getServer(PK.USER_SERVERS, name) != null || getServer(PK.API_SERVERS, name) != null; } catch (JSONException ex) { Log.e(TAG, "Failed parsing JSON.", ex); return true; } } @NonNull public Params params() { return params; } @Override public int hashCode() { int result = url.hashCode(); result = 31 * result + name.hashCode(); return result; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Server server = (Server) o; return url.equals(server.url) && name.equals(server.name); } @NonNull private JSONObject toJson() throws JSONException { return new JSONObject() .put("params", params == null ? null : params.toJson()) .put("name", name) .put("metrics", metricsUrl) .put("editable", editable) .put("uri", url.toString()); } @Nullable HttpUrl gameHistory(String id) { if (metricsUrl == null) return null; return metricsUrl.newBuilder().addPathSegments("game/" + id).build(); } @Nullable HttpUrl gameRound(String id) { if (metricsUrl == null) return null; return metricsUrl.newBuilder().addPathSegments("round/" + id).build(); } @Nullable HttpUrl sessionHistory(String id) { if (metricsUrl == null) return null; return metricsUrl.newBuilder().addPathSegments("session/" + id).build(); } @Nullable HttpUrl sessionStats(String id) { if (metricsUrl == null) return null; return metricsUrl.newBuilder().addPathSegments("session/" + id + "/stats").build(); } @Nullable HttpUrl userHistory(String id) { if (metricsUrl == null) return null; return metricsUrl.newBuilder().addPathSegments("user/" + id).build(); } public boolean isEditable() { return editable; } @NonNull HttpUrl stats() { if (statsUrl == null) statsUrl = url.newBuilder().addPathSegment("stats.jsp").build(); return statsUrl; } @NonNull HttpUrl ajax() { if (ajaxUrl == null) ajaxUrl = url.newBuilder().addPathSegment("AjaxServlet").build(); return ajaxUrl; } @NonNull HttpUrl config() { if (configUrl == null) configUrl = url.newBuilder().addPathSegments("js/cah.config.js").build(); return configUrl; } @NonNull public HttpUrl polling() { if (pollingUrl == null) pollingUrl = url.newBuilder().addPathSegment("LongPollServlet").build(); return pollingUrl; } public boolean hasMetrics() { return metricsUrl != null; } public boolean isHttps() { return url.isHttps(); } public static class Params { public final int blankCardsMin; public final int blankCardsMax; public final int playersMin; public final int playersMax; public final int spectatorsMin; public final int spectatorsMax; public final int scoreMin; public final int scoreMax; private Params(int blankCardsMin, int blankCardsMax, int playersMin, int playersMax, int spectatorsMin, int spectatorsMax, int scoreMin, int scoreMax) { this.blankCardsMin = blankCardsMin; this.blankCardsMax = blankCardsMax; this.playersMin = playersMin; this.playersMax = playersMax; this.spectatorsMin = spectatorsMin; this.spectatorsMax = spectatorsMax; this.scoreMin = scoreMin; this.scoreMax = scoreMax; } private Params(@NonNull JSONObject obj) throws JSONException { blankCardsMin = obj.getInt("bl-min"); blankCardsMax = obj.getInt("bl-max"); scoreMin = obj.getInt("sl-min"); scoreMax = obj.getInt("sl-max"); spectatorsMin = obj.getInt("vL-min"); spectatorsMax = obj.getInt("vL-max"); playersMin = obj.getInt("pL-min"); playersMax = obj.getInt("pL-max"); } @NonNull public static Params defaultValues() { return new Params(0, 30, 3, 20, 0, 20, 4, 69); } @NotNull JSONObject toJson() throws JSONException { JSONObject obj = new JSONObject(); obj.put("bl-min", blankCardsMin); obj.put("bl-max", blankCardsMax); obj.put("sl-min", scoreMin); obj.put("sl-max", scoreMax); obj.put("vL-min", spectatorsMin); obj.put("vL-max", spectatorsMax); obj.put("pL-min", playersMin); obj.put("pL-max", playersMax); return obj; } } } private class RequestRunner extends LifecycleAwareRunnable { private final PyxRequest request; private final OnSuccess listener; RequestRunner(PyxRequest request, @Nullable Activity activity, @NonNull OnSuccess listener) { super(handler, activity == null ? listener : activity); this.request = request; this.listener = listener; } @Override public void run() { try { request(request.op, request.params); post(listener::onDone); } catch (IOException | JSONException | PyxException ex) { post(() -> listener.onException(ex)); } } } private class RequestWithResultRunner<E> extends LifecycleAwareRunnable { private final PyxRequestWithResult<E> request; private final OnResult<E> listener; RequestWithResultRunner(PyxRequestWithResult<E> request, @Nullable Activity activity, @NonNull OnResult<E> listener) { super(handler, activity == null ? listener : activity); this.request = request; this.listener = listener; } @Override public void run() { try { PyxResponse resp = request(request.op, request.params); E result = request.processor.process(resp.resp, resp.obj); post(() -> listener.onDone(result)); } catch (IOException | JSONException | PyxException ex) { post(() -> listener.onException(ex)); } } } }