- Translate the interface to Galician (tools/translate_gl.py), including Galician month abbreviations for dates, which Android 2.3 lacks - Resize for the Galaxy Mini's 240x320 screen: larger text, rows, tabs and buttons; all sizes now live in res/values/dimens.xml - Redraw play/pause/refresh icons for every density; slightly brighter secondary text colours Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
12 KiB
Java
216 lines
12 KiB
Java
package com.miniabs;
|
|
|
|
import java.io.InputStreamReader;
|
|
import java.io.StringReader;
|
|
import java.net.HttpURLConnection;
|
|
import java.net.URL;
|
|
|
|
import org.json.JSONArray;
|
|
import org.json.JSONObject;
|
|
|
|
import android.content.Context;
|
|
|
|
/** Runs MiniABS's real API, JSON and progress code against mock_abs.py on the desktop JVM. */
|
|
public class ApiTest {
|
|
static int pass, fail;
|
|
|
|
static void check(boolean ok, String what) {
|
|
if (ok) pass++;
|
|
else fail++;
|
|
System.out.println((ok ? " PASS " : " FAIL ") + what);
|
|
}
|
|
|
|
static Object server(String base, String path) throws Exception {
|
|
HttpURLConnection c = (HttpURLConnection) new URL(base + path).openConnection();
|
|
Object o = Json.parse(new InputStreamReader(c.getInputStream(), "UTF-8"), 100000, null);
|
|
c.disconnect();
|
|
return o;
|
|
}
|
|
|
|
public static void main(String[] a) throws Exception {
|
|
String base = "http://127.0.0.1:" + a[0];
|
|
boolean legacy = a.length > 1 && a[1].equals("legacy");
|
|
Context c = new Context();
|
|
Prefs p = Prefs.get(c);
|
|
|
|
System.out.println("JSON parser");
|
|
Object j = Json.parse(new StringReader(
|
|
"{\"a\":[1,-2.5e3,true,false,null,\"caf\\u00e9 \\ud83c\\udfa7 \\\"q\\\" \\\\ /\"],\"big\":12345678901,"
|
|
+ "\"chapters\":[{\"x\":[1,2,{\"y\":\"}]\"}]}],\"n\":{}}"), 50, null);
|
|
JSONObject jo = (JSONObject) j;
|
|
JSONArray arr = jo.getJSONArray("a");
|
|
check(arr.getInt(0) == 1 && arr.getDouble(1) == -2500.0 && arr.getBoolean(2) && !arr.getBoolean(3)
|
|
&& arr.isNull(4), "numbers, booleans and null");
|
|
check(arr.getString(5).equals("café 🎧 \"q\" \\ /"), "string escapes incl. emoji surrogates");
|
|
check(jo.getLong("big") == 12345678901L, "long numbers");
|
|
check(!jo.has("chapters") && jo.has("n"), "bulky keys skipped, parsing resumes correctly after them");
|
|
JSONObject cut = (JSONObject) Json.parse(new StringReader("{\"d\":\"0123456789abcdef\"}"), 10, null);
|
|
check(cut.getString("d").equals("0123456789…"), "long strings truncated with an ellipsis");
|
|
|
|
System.out.println("Sign-in");
|
|
p.setServer(LoginActivity_normalize(base + "/login/"));
|
|
check(p.server().equals(base), "address normalised (" + p.server() + ")");
|
|
check(Api.status(c, p.server()).equals("2.30.0"), "status check finds the server");
|
|
try {
|
|
Api.login(c, p.server(), "demo", "wrong");
|
|
check(false, "wrong password rejected");
|
|
} catch (Api.ApiException e) {
|
|
check(e.code == 401 && e.getMessage().contains("contrasinal incorrectos"), "wrong password rejected with a clear message");
|
|
}
|
|
String lib = Api.login(c, p.server(), "demo", "pw");
|
|
check(lib.equals("lib-pod"), "login returns default library");
|
|
if (legacy) {
|
|
check(Prefs.MODE_LEGACY.equals(p.mode()) && !p.canRefresh(), "old server: legacy token mode");
|
|
check(p.tokenSecondsLeft() > 3500, "legacy token lifetime read");
|
|
} else {
|
|
check(Prefs.MODE_JWT.equals(p.mode()) && p.canRefresh(), "new server: access + refresh token mode");
|
|
long left = p.tokenSecondsLeft();
|
|
check(left > 3590 && left <= 3600, "token lifetime read from JWT (" + left + "s)");
|
|
}
|
|
|
|
System.out.println("Browsing");
|
|
JSONArray libs = Api.libraries(c);
|
|
check(libs.length() == 2, "libraries listed");
|
|
JSONObject recent = Api.recentEpisodes(c, "lib-pod", 0, 20);
|
|
JSONArray eps = recent.getJSONArray("episodes");
|
|
Episode e0 = Episode.fromApi(eps.getJSONObject(0), null, null, null);
|
|
check(eps.length() == 20 && e0 != null, "recent episodes parsed");
|
|
check(e0.show.equals("Mini Show") && e0.author.equals("Ana Álvarez") && e0.itemId.equals("pod0"),
|
|
"show name/author taken from nested podcast");
|
|
check(!eps.getJSONObject(0).getJSONObject("podcast").has("episodes"),
|
|
"nested podcast episode lists dropped while reading (memory)");
|
|
check(e0.ino.equals("ino100") && e0.ext.equals(".mp3") && e0.duration > 1900 && e0.size > 0,
|
|
"audio file details parsed");
|
|
check(Util.date(e0.published).length() > 0 && Util.duration(e0.duration).equals("32 min"),
|
|
"date and duration formatted (" + Util.date(e0.published) + ", " + Util.duration(e0.duration) + ")");
|
|
|
|
App.loadProgress(c);
|
|
double[] pr7 = App.progress("ep7"), pr8 = App.progress("ep8");
|
|
check(pr7 != null && pr7[0] == 600.0 && pr7[2] == 0, "in-progress episode position loaded");
|
|
check(pr8 != null && pr8[2] == 1, "finished episode flagged");
|
|
|
|
JSONArray ip = Api.itemsInProgress(c).getJSONArray("libraryItems");
|
|
JSONObject it0 = ip.getJSONObject(0);
|
|
Episode cont = Episode.fromApi(it0.optJSONObject("recentEpisode"), it0.getString("id"), "Mini Show", "Ana");
|
|
check(cont != null && cont.id.equals("ep7"), "continue-listening episode parsed");
|
|
check(Episode.fromApi(ip.getJSONObject(1).optJSONObject("recentEpisode"), "book9", "", "") == null,
|
|
"books without an episode skipped");
|
|
|
|
long t0 = System.currentTimeMillis();
|
|
JSONObject item = Api.item(c, "pod1");
|
|
JSONArray all = item.getJSONObject("media").getJSONArray("episodes");
|
|
int maxDesc = 0;
|
|
for (int i = 0; i < all.length(); i++) maxDesc = Math.max(maxDesc, all.getJSONObject(i).getString("description").length());
|
|
check(all.length() == 300, "300-episode podcast loaded in " + (System.currentTimeMillis() - t0) + " ms");
|
|
check(maxDesc <= 801, "show notes capped at 800 chars (max " + maxDesc + ")");
|
|
check(!item.has("libraryFiles") && !all.getJSONObject(0).has("chapters"), "file lists and chapters dropped");
|
|
Episode e7 = Episode.fromApi(all.getJSONObject(7), "pod1", "Mini Show", "Ana");
|
|
check(Util.plainText(e7.desc).startsWith("Show notes café"), "show notes become plain text");
|
|
|
|
System.out.println("Playback session");
|
|
JSONObject sess = Api.startPlayback(c, e7);
|
|
check(Util.str(sess, "id").equals("sess1") && sess.getDouble("currentTime") == 600.0, "session opened at saved position");
|
|
String cu = sess.getJSONArray("audioTracks").getJSONObject(0).getString("contentUrl");
|
|
String url = Api.withToken(c, Api.absolute(c, cu));
|
|
check(url.startsWith(base + "/api/items/pod1/file/ino7?token=") && url.endsWith(Api.enc(p.token())),
|
|
"stream URL carries the token");
|
|
HttpURLConnection sc = (HttpURLConnection) new URL(url).openConnection();
|
|
check(sc.getResponseCode() != 401, "server accepts the tokenised stream URL");
|
|
sc.disconnect();
|
|
Api.syncSession(c, "sess1", 615.5, 15.2, 1807.5);
|
|
Api.closeSession(c, "sess1", 620, 4.5, 1807.5);
|
|
Api.setProgress(c, "pod1", "ep7", 1807.5, 1807.5, Boolean.TRUE);
|
|
JSONArray log = (JSONArray) server(base, "/__log");
|
|
JSONObject sync = null, close = null, patch = null, play = null;
|
|
for (int i = 0; i < log.length(); i++) {
|
|
JSONObject r = log.getJSONObject(i);
|
|
String path = r.getString("p");
|
|
if (path.equals("/api/session/sess1/sync")) sync = r;
|
|
if (path.equals("/api/session/sess1/close")) close = r;
|
|
if (path.equals("/api/me/progress/pod1/ep7")) patch = r;
|
|
if (path.startsWith("/api/items/pod1/play/")) play = r;
|
|
}
|
|
check(play != null && play.getJSONObject("b").getBoolean("forceDirectPlay")
|
|
&& play.getJSONObject("b").getJSONObject("deviceInfo").getString("clientName").equals("MiniABS"),
|
|
"play request asks for direct play and names the client");
|
|
check(sync != null && sync.getJSONObject("b").getDouble("currentTime") == 615.5
|
|
&& sync.getJSONObject("b").getDouble("timeListened") == 15.2, "progress sync sent");
|
|
check(close != null, "session closed");
|
|
check(patch != null && patch.getString("m").equals("PATCH")
|
|
&& patch.getJSONObject("b").getBoolean("isFinished"), "mark-as-played sent as PATCH");
|
|
|
|
System.out.println("Offline catch-up");
|
|
Episode e9 = Episode.fromApi(all.getJSONObject(9), "pod1", "Mini Show", "Ana");
|
|
App.saveLocal(c, e9, 321.0, 1809.5, true);
|
|
check(App.localPending(c, "ep9") && App.localPosition(c, "ep9") == 321.0, "position saved on the phone");
|
|
App.loadProgress(c);
|
|
check(App.progress("ep9")[0] == 321.0, "unsent local position wins over server's");
|
|
int sent = App.flushPending(c);
|
|
check(sent == 1 && !App.localPending(c, "ep9"), "pending position sent when back online");
|
|
|
|
if (!legacy) {
|
|
System.out.println("Token refresh");
|
|
String before = p.token();
|
|
server(base, "/__expire");
|
|
JSONArray again = Api.libraries(c);
|
|
check(again.length() == 2 && !p.token().equals(before), "expired token refreshed transparently after 401");
|
|
check(p.refreshToken().equals("refresh-" + (p.token().contains("\"n\"") ? "" : "")) || p.refreshToken().startsWith("refresh-"),
|
|
"rotated refresh token stored");
|
|
server(base, "/__expire");
|
|
int r0 = ((JSONObject) server(base, "/__refreshes")).getInt("n");
|
|
final Context fc = c;
|
|
final Exception[] errs = new Exception[3];
|
|
Thread[] ts = new Thread[3];
|
|
for (int i = 0; i < 3; i++) {
|
|
final int k = i;
|
|
ts[i] = new Thread() {
|
|
public void run() {
|
|
try {
|
|
Api.libraries(fc);
|
|
} catch (Exception ex) {
|
|
errs[k] = ex;
|
|
}
|
|
}
|
|
};
|
|
ts[i].start();
|
|
}
|
|
for (Thread t : ts) t.join();
|
|
int r1 = ((JSONObject) server(base, "/__refreshes")).getInt("n");
|
|
check(errs[0] == null && errs[1] == null && errs[2] == null && r1 - r0 == 1,
|
|
"3 requests failing at once trigger exactly one refresh (" + (r1 - r0) + ")");
|
|
// Simulate the token nearing expiry on the phone's clock: refreshed before the request.
|
|
p.sp.edit().putLong("token_obtained", System.currentTimeMillis() - 3550 * 1000L).commit();
|
|
String old = p.token();
|
|
Api.libraries(c);
|
|
check(!p.token().equals(old), "token refreshed early when about to expire");
|
|
server(base, "/__revoke");
|
|
try {
|
|
Api.libraries(c);
|
|
check(false, "revoked sign-in detected");
|
|
} catch (Api.AuthException ex) {
|
|
check(true, "revoked sign-in sends user back to login: \"" + ex.getMessage() + "\"");
|
|
}
|
|
}
|
|
|
|
System.out.println("Sub-path servers");
|
|
p.setServer("http://nas.local:8080/abs");
|
|
check(Api.absolute(c, "/abs/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"),
|
|
"content URL under a sub-path not doubled");
|
|
check(Api.absolute(c, "/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"),
|
|
"content URL without the sub-path prefixed");
|
|
|
|
System.out.println();
|
|
System.out.println(pass + " passed, " + fail + " failed");
|
|
System.exit(fail == 0 ? 0 : 1);
|
|
}
|
|
|
|
/** Same rules as LoginActivity.normalize (the Activity itself can't load off-device). */
|
|
static String LoginActivity_normalize(String s) {
|
|
s = s.trim();
|
|
while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
|
|
if (s.endsWith("/login")) s = s.substring(0, s.length() - 6);
|
|
if (s.length() > 0 && !s.startsWith("http://") && !s.startsWith("https://")) s = "http://" + s;
|
|
return s;
|
|
}
|
|
}
|