package com.miniabs; import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.Reader; import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.ProtocolException; import java.net.Socket; import java.net.URL; import java.net.URLEncoder; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLException; import javax.net.ssl.SSLSocket; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import android.content.Context; import android.os.Build; /** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */ final class Api { static final String VERSION = "1.9"; static class ApiException extends IOException { final int code; ApiException(int code, String msg) { super(msg); this.code = code; } } /** Sign-in is no longer valid; the user must log in again. */ static class AuthException extends IOException { AuthException(String msg) { super(msg); } } private static final Object REFRESH_LOCK = new Object(); static { // Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off. System.setProperty("http.keepAlive", "false"); } private Api() { } // ------------------------------------------------------------------ plumbing static HttpURLConnection open(Context c, String url, String method) throws IOException { Trust.install(c); HttpURLConnection conn = new NetConnection(new URL(url)); conn.setConnectTimeout(15000); conn.setReadTimeout(30000); conn.setUseCaches(false); conn.setInstanceFollowRedirects(true); setMethod(conn, method); conn.setRequestProperty("User-Agent", "Podlite/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")"); return conn; } private static void setMethod(HttpURLConnection conn, String method) throws ProtocolException { conn.setRequestMethod(method); } /** * PATCH: Audiobookshelf uses it for "mark as played" and for saving progress without a session. * (Android 2.x's own HttpURLConnection can't send it; the bundled client can.) */ private static Object rawPatch(Context c, String path, JSONObject body, boolean mayRefresh) throws IOException { Prefs p = Prefs.get(c); if (mayRefresh) ensureFresh(c, 120); String used = p.token(); HttpURLConnection conn = open(c, p.server() + path, "PATCH"); conn.setInstanceFollowRedirects(false); conn.setRequestProperty("Authorization", "Bearer " + used); conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Accept", "application/json"); conn.setDoOutput(true); int code; try { OutputStream os = conn.getOutputStream(); os.write((body == null ? "{}" : body.toString()).getBytes("UTF-8")); code = conn.getResponseCode(); InputStream in = code >= 400 ? conn.getErrorStream() : conn.getInputStream(); if (in != null) { byte[] rest = new byte[1024]; while (in.read(rest) > 0) { } // read the reply so the connection can be reused } } finally { conn.disconnect(); } if (code == 401) { if (mayRefresh && p.canRefresh()) { refreshIf(c, used, 0); return rawPatch(c, path, body, false); } throw new AuthException("A túa sesión caducou. Inicia sesión de novo."); } if (code >= 400) throw new ApiException(code, ""); if (code >= 300) throw new ApiException(code, "O servidor redirixiu a petición; comproba o enderezo do servidor."); return "OK"; } /** Old Android throws instead of returning 401 when the server sends no WWW-Authenticate header. */ static int responseCode(HttpURLConnection conn) throws IOException { try { return conn.getResponseCode(); } catch (IOException e) { String m = e.getMessage(); if (m != null && m.toLowerCase().contains("authentication challenge")) return 401; throw e; } } private static void writeBody(HttpURLConnection conn, JSONObject body) throws IOException { byte[] b = (body == null ? "{}" : body.toString()).getBytes("UTF-8"); conn.setDoOutput(true); conn.setRequestProperty("Content-Type", "application/json"); conn.setFixedLengthStreamingMode(b.length); OutputStream os = conn.getOutputStream(); try { os.write(b); } finally { os.close(); } } private static Object readBody(HttpURLConnection conn, int maxString, String[] deepSkip) throws IOException { InputStream is = new BufferedInputStream(conn.getInputStream(), 8192); try { String type = conn.getContentType(); Reader r = new InputStreamReader(is, "UTF-8"); if (type != null && type.contains("json")) return Json.parse(r, maxString, deepSkip); StringBuilder sb = new StringBuilder(); char[] b = new char[512]; int n; while ((n = r.read(b)) > 0 && sb.length() < 4000) sb.append(b, 0, n); return sb.toString(); } finally { is.close(); } } private static String errorText(HttpURLConnection conn) { try { InputStream es = conn.getErrorStream(); if (es == null) return ""; Reader r = new InputStreamReader(es, "UTF-8"); StringBuilder sb = new StringBuilder(); char[] b = new char[256]; int n; while ((n = r.read(b)) > 0 && sb.length() < 200) sb.append(b, 0, n); es.close(); String t = sb.toString().trim(); if (t.startsWith("<")) return ""; // an HTML error page is no use on a tiny screen return t.length() > 120 ? t.substring(0, 120) : t; } catch (Exception e) { return ""; } } static Object call(Context c, String method, String path, JSONObject body) throws IOException { return call(c, method, path, body, 2000, null, true); } static Object call(Context c, String method, String path, JSONObject body, int maxString, String[] deepSkip, boolean mayRefresh) throws IOException { Prefs p = Prefs.get(c); if (!p.loggedIn()) throw new AuthException("Inicia sesión."); if ("PATCH".equals(method)) return rawPatch(c, path, body, mayRefresh); if (mayRefresh) ensureFresh(c, 120); String used = p.token(); HttpURLConnection conn = open(c, p.server() + path, method); try { conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("Authorization", "Bearer " + used); if (body != null || "POST".equals(method)) writeBody(conn, body); int code = responseCode(conn); if (code == 401) { conn.disconnect(); if (mayRefresh && p.canRefresh()) { refreshIf(c, used, 0); return call(c, method, path, body, maxString, deepSkip, false); } throw new AuthException("A túa sesión caducou. Inicia sesión de novo."); } if (code >= 400) throw new ApiException(code, errorText(conn)); return readBody(conn, maxString, deepSkip); } finally { conn.disconnect(); } } static JSONObject obj(Object o) throws IOException { if (o instanceof JSONObject) return (JSONObject) o; throw new IOException("Resposta inesperada do servidor"); } // ------------------------------------------------------------------ sign-in /** Checks the address points at an Audiobookshelf server. Returns its version, or "" if unknown. */ static String status(Context c, String server) throws IOException { HttpURLConnection conn = open(c, server + "/status", "GET"); try { int code = responseCode(conn); if (code == 404) throw new IOException("Non hai ningún servidor de Audiobookshelf neste enderezo."); if (code >= 400) throw new ApiException(code, errorText(conn)); Object o = readBody(conn, 200, null); if (!(o instanceof JSONObject)) throw new IOException("Non hai ningún servidor de Audiobookshelf neste enderezo."); JSONObject s = (JSONObject) o; if (s.has("isInit") && !s.optBoolean("isInit", true)) { throw new IOException("Este servidor aínda non está configurado. Remata a configuración nun navegador."); } return Util.str(s, "serverVersion"); } finally { conn.disconnect(); } } /** Logs in with username and password. Returns the user's default library id (may be empty). */ static String login(Context c, String server, String user, String pass) throws IOException { HttpURLConnection conn = open(c, server + "/login", "POST"); try { conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("x-return-tokens", "true"); JSONObject body = new JSONObject(); try { body.put("username", user).put("password", pass); } catch (JSONException ignored) { } writeBody(conn, body); int code = responseCode(conn); if (code == 401) throw new ApiException(401, "Usuario ou contrasinal incorrectos."); if (code == 429) throw new ApiException(429, "Demasiados intentos. Agarda un minuto e téntao de novo."); if (code >= 400) throw new ApiException(code, errorText(conn)); JSONObject o = obj(readBody(conn, 500, null)); JSONObject u = o.optJSONObject("user"); if (u == null) throw new IOException("Resposta inesperada do servidor"); String access = Util.str(u, "accessToken"); String refresh = Util.str(u, "refreshToken"); String legacy = Util.str(u, "token"); Prefs p = Prefs.get(c); if (access.length() > 0) p.setAuth(user, Prefs.MODE_JWT, access, refresh); else if (legacy.length() > 0) p.setAuth(user, Prefs.MODE_LEGACY, legacy, ""); else throw new IOException("O servidor non devolveu ningún token de sesión."); return Util.str(o, "userDefaultLibraryId"); } finally { conn.disconnect(); } } /** Signs in with an API key by checking it against /api/me. Returns the username. */ static String loginWithKey(Context c, String key) throws IOException { Prefs p = Prefs.get(c); p.setAuth("", Prefs.MODE_KEY, key, ""); try { JSONObject me = obj(call(c, "GET", "/api/me", null, 100, null, false)); String name = Util.str(me, "username"); p.setAuth(name, Prefs.MODE_KEY, key, ""); return name; } catch (IOException e) { p.logout(); if (e instanceof AuthException) throw new ApiException(401, "Non se aceptou esa chave da API."); throw e; } } /** Refreshes the access token if it has less than minSeconds left. */ static void ensureFresh(Context c, long minSeconds) throws IOException { Prefs p = Prefs.get(c); if (!p.canRefresh()) return; if (p.tokenSecondsLeft() >= minSeconds) return; refreshIf(c, null, minSeconds); } /** Gets a new access token now (after a 401, or before reconnecting a stream). */ static void refresh(Context c, boolean force) throws IOException { refreshIf(c, force ? Prefs.get(c).token() : null, 120); } /** * stale: the token the server just rejected. If another thread has already replaced it, * there's nothing to do. With no stale token, refreshes only if fewer than minSeconds are left. */ private static void refreshIf(Context c, String stale, long minSeconds) throws IOException { synchronized (REFRESH_LOCK) { Prefs p = Prefs.get(c); if (!p.canRefresh()) throw new AuthException("A túa sesión caducou. Inicia sesión de novo."); if (stale != null) { if (!stale.equals(p.token())) return; } else if (p.tokenSecondsLeft() >= minSeconds) { return; } HttpURLConnection conn = open(c, p.server() + "/auth/refresh", "POST"); try { conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("x-refresh-token", p.refreshToken()); writeBody(conn, null); int code = responseCode(conn); if (code == 401 || code == 403) { throw new AuthException("A túa sesión caducou. Inicia sesión de novo."); } if (code >= 400) throw new ApiException(code, errorText(conn)); JSONObject u = obj(readBody(conn, 500, null)).optJSONObject("user"); String access = u == null ? "" : Util.str(u, "accessToken"); if (access.length() == 0) throw new AuthException("A túa sesión caducou. Inicia sesión de novo."); p.setTokens(access, Util.str(u, "refreshToken")); } finally { conn.disconnect(); } } } // ------------------------------------------------------------------ URLs static String enc(String s) { try { return URLEncoder.encode(s, "UTF-8"); } catch (Exception e) { return s; } } /** Appends the access token as a query parameter (MediaPlayer on Android 2.x can't send headers). */ static String withToken(Context c, String url) { return url + (url.indexOf('?') >= 0 ? "&" : "?") + "token=" + enc(Prefs.get(c).token()); } /** Turns a server-relative content URL into a full URL, handling servers hosted under a sub-path. */ static String absolute(Context c, String contentUrl) { if (contentUrl.startsWith("http://") || contentUrl.startsWith("https://")) return contentUrl; String server = Prefs.get(c).server(); try { URL u = new URL(server); String base = u.getPath(); if (base.length() > 1 && contentUrl.startsWith(base + "/")) { String origin = u.getProtocol() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : ""); return origin + contentUrl; } } catch (Exception ignored) { } return server + (contentUrl.startsWith("/") ? "" : "/") + contentUrl; } static String coverUrl(Context c, String itemId, int width) { return withToken(c, Prefs.get(c).server() + "/api/items/" + itemId + "/cover?format=jpeg&width=" + width); } static String[] downloadUrls(Context c, Episode e) { String base = Prefs.get(c).server() + "/api/items/" + e.itemId + "/file/" + e.ino; return new String[]{withToken(c, base + "/download"), withToken(c, base)}; } // ------------------------------------------------------------------ data static JSONArray libraries(Context c) throws IOException { Object o = call(c, "GET", "/api/libraries", null); if (o instanceof JSONArray) return (JSONArray) o; JSONArray a = obj(o).optJSONArray("libraries"); return a == null ? new JSONArray() : a; } static JSONObject libraryItems(Context c, String lib, int page, int limit) throws IOException { return obj(call(c, "GET", "/api/libraries/" + lib + "/items?minified=1&sort=media.metadata.title&limit=" + limit + "&page=" + page, null, 300, null, true)); } static JSONObject recentEpisodes(Context c, String lib, int page, int limit) throws IOException { return obj(call(c, "GET", "/api/libraries/" + lib + "/recent-episodes?limit=" + limit + "&page=" + page, null, 1200, new String[]{"episodes", "audioFiles", "tracks"}, true)); } static JSONObject itemsInProgress(Context c) throws IOException { return obj(call(c, "GET", "/api/me/items-in-progress?limit=15", null, 1200, new String[]{"episodes"}, true)); } /** A podcast with all its episodes. Show notes are cut to keep memory low. */ static JSONObject item(Context c, String itemId) throws IOException { return obj(call(c, "GET", "/api/items/" + itemId, null, 800, null, true)); } static JSONObject me(Context c) throws IOException { return obj(call(c, "GET", "/api/me", null, 200, null, true)); } private static JSONObject deviceInfo(Context c) throws JSONException { return new JSONObject() .put("deviceId", Prefs.get(c).deviceId()) .put("clientName", "Podlite") .put("clientVersion", VERSION) .put("manufacturer", Build.MANUFACTURER) .put("model", Build.MODEL) .put("sdkVersion", Build.VERSION.SDK_INT); } /** Opens a playback session. Asks for direct play: Android 2.x can't play HLS transcodes. */ static JSONObject startPlayback(Context c, Episode e) throws IOException { JSONObject body = new JSONObject(); try { JSONArray mimes = new JSONArray(); String[] ok = {"audio/mpeg", "audio/mp3", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/x-aac", "audio/ogg", "audio/wav", "audio/x-wav", "audio/3gpp", "audio/amr"}; for (String m : ok) mimes.put(m); body.put("deviceInfo", deviceInfo(c)) .put("forceDirectPlay", true) .put("forceTranscode", false) .put("supportedMimeTypes", mimes) .put("mediaPlayer", "Podlite"); } catch (JSONException ignored) { } return obj(call(c, "POST", "/api/items/" + e.itemId + "/play/" + e.id, body, 200, new String[]{"episodes"}, true)); } private static JSONObject syncBody(double cur, double listened, double dur) { JSONObject b = new JSONObject(); try { b.put("currentTime", cur).put("timeListened", listened); if (dur > 0) b.put("duration", dur); } catch (JSONException ignored) { } return b; } static void syncSession(Context c, String sid, double cur, double listened, double dur) throws IOException { call(c, "POST", "/api/session/" + sid + "/sync", syncBody(cur, listened, dur)); } static void closeSession(Context c, String sid, double cur, double listened, double dur) throws IOException { call(c, "POST", "/api/session/" + sid + "/close", syncBody(cur, listened, dur)); } /** Saves progress without a session (offline catch-up, mark played/unplayed). */ static void setProgress(Context c, String itemId, String epId, double cur, double dur, Boolean finished) throws IOException { JSONObject b = new JSONObject(); try { if (cur >= 0) { b.put("currentTime", cur); if (dur > 0) { b.put("duration", dur); b.put("progress", Math.min(1.0, cur / dur)); } } if (finished != null) b.put("isFinished", finished.booleanValue()); } catch (JSONException ignored) { } call(c, "PATCH", "/api/me/progress/" + itemId + "/" + epId, b); } }