/* * mygram-droid: Android TV client for Foldergram. * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 as published * by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * for more details. */ package mygram.droid; import android.net.Uri; import android.os.Handler; import android.os.Looper; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.CookieHandler; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Consumer; /** Foldergram HTTP API. Calls block; run them through async(). */ final class Api { static final ExecutorService IO = Executors.newFixedThreadPool(4); private static final int TIMEOUT_MS = 15000; private static final int PAGE = 60; /** Folder slug to picture URL, filled by folders(); the viewer reads it. */ // ponytail: empty until a folder list has loaded once, so the viewer then shows the name alone. static final Map AVATARS = new ConcurrentHashMap<>(); private Api() { } static final class HttpError extends IOException { final int code; HttpError(int code, String body) { super("HTTP " + code + (body.isEmpty() ? "" : ": " + body)); this.code = code; } } static final class Folder { final String slug, name, avatarUrl; final int count; final long latest; Folder(JSONObject o, String base) { slug = o.optString("slug"); name = o.optString("name"); avatarUrl = resolve(base, Post.str(o, "avatarUrl")); count = o.optInt("imageCount"); latest = o.optLong("latestImageMtimeMs"); } } /** Runs call on the IO pool, then ok or err on the main thread. */ static void async(Callable call, Consumer ok, Consumer err) { async(IO, call, ok, err); } static void async(Executor on, Callable call, Consumer ok, Consumer err) { Handler main = new Handler(Looper.getMainLooper()); on.execute(() -> { try { T value = call.call(); main.post(() -> ok.accept(value)); } catch (Exception e) { main.post(() -> err.accept(e)); } }); } /** "192.0.2.10:4141/" or "http://host:4141//" to "http://host:4141". */ static String normalize(String url) { String s = url.trim(); if (!s.contains("://")) s = "http://" + s; while (s.endsWith("/")) s = s.substring(0, s.length() - 1); return s; } /** Resolves a server path like "/thumbnails/..." against the base URL. */ static String resolve(String base, String path) { return path == null ? null : URI.create(base + "/").resolve(path).toString(); } /** One request to base + "/api" + path. Throws HttpError on 4xx/5xx. */ static String request(String base, String method, String path, String body, int timeoutMs) throws IOException { HttpURLConnection c = (HttpURLConnection) new URL(base + "/api" + path).openConnection(); try { c.setConnectTimeout(timeoutMs); c.setReadTimeout(timeoutMs); c.setRequestMethod(method); // Foldergram rejects mutations without it (requireTrustedMutationRequest). if (!"GET".equals(method)) c.setRequestProperty("x-foldergram-intent", "1"); if (body != null) { c.setDoOutput(true); c.setRequestProperty("Content-Type", "application/json"); try (OutputStream out = c.getOutputStream()) { out.write(body.getBytes(StandardCharsets.UTF_8)); } } int code = c.getResponseCode(); InputStream in = code < 400 ? c.getInputStream() : c.getErrorStream(); String text = in == null ? "" : new String(in.readAllBytes(), StandardCharsets.UTF_8); if (code >= 400) throw new HttpError(code, text); return text; } finally { c.disconnect(); } } static JSONObject authStatus(String base) throws IOException, JSONException { return new JSONObject(request(base, "GET", "/auth/status", null, 5000)); } /** Logs in; the session cookie lands in the default CookieManager. */ static void login(String base, String password) throws IOException { request(base, "POST", "/auth/login", "{\"password\":" + JSONObject.quote(password) + "}", TIMEOUT_MS); } /** A request to the saved server. On 401 logs in again once and retries. */ static JSONObject call(String method, String path) throws IOException, JSONException { String base = Prefs.server(); try { return new JSONObject(request(base, method, path, null, TIMEOUT_MS)); } catch (HttpError e) { if (e.code != 401 || Prefs.password().isEmpty()) throw e; login(base, Prefs.password()); return new JSONObject(request(base, method, path, null, TIMEOUT_MS)); } } /** All folders, most recently updated first. */ static List folders() throws IOException, JSONException { JSONArray items = call("GET", "/folders").getJSONArray("items"); List out = new ArrayList<>(); for (int i = 0; i < items.length(); i++) out.add(new Folder(items.getJSONObject(i), Prefs.server())); for (Folder f : out) if (f.avatarUrl != null) AVATARS.put(f.slug, f.avatarUrl); out.sort((a, b) -> Long.compare(b.latest, a.latest)); return out; } /** Pager over a folder: each call returns the next page, shuffled; empty once all pages are used. */ // ponytail: random page order plus in-page shuffle, not a full shuffle; neighbours within a page stay together; upgrade: fetch all ids once if that ever matters. static Callable> folderPages(String slug, int count) { List pages = new ArrayList<>(); for (int p = 1; p <= (count + PAGE - 1) / PAGE; p++) pages.add(p); Collections.shuffle(pages); String path = "/folders/" + Uri.encode(slug) + "/images?limit=" + PAGE + "&page="; int[] next = {0}; return () -> { synchronized (next) { while (next[0] < pages.size()) { // imageCount counts carousel slides, so later pages may be empty List posts = Post.list(call("GET", path + pages.get(next[0]++)), Prefs.server()); if (!posts.isEmpty()) { Collections.shuffle(posts); return posts; } } return new ArrayList<>(); } }; } /** Pager over random Reels with a fixed seed; empty once the server reports no more. */ static Callable> reelsPages(int seed) { int[] state = {0, 1}; // page, hasMore return () -> { synchronized (state) { if (state[1] == 0) return new ArrayList<>(); JSONObject o = call("GET", "/reels?mode=random&limit=12&seed=" + seed + "&page=" + ++state[0]); if (!o.optBoolean("hasMore")) state[1] = 0; return Post.list(o, Prefs.server()); } }; } /** Up to n random posts of a folder. */ // ponytail: samples one random page of 60, so a row never mixes pages; fetch more pages if rows feel repetitive. static List folderSample(Folder f, int n) throws IOException, JSONException { int pages = Math.max(1, (f.count + PAGE - 1) / PAGE); int page = 1 + ThreadLocalRandom.current().nextInt(pages); String path = "/folders/" + Uri.encode(f.slug) + "/images?limit=" + PAGE + "&page="; List posts = Post.list(call("GET", path + page), Prefs.server()); if (posts.isEmpty() && page > 1) // imageCount counts carousel slides, so pages can run out early posts = Post.list(call("GET", path + 1), Prefs.server()); Collections.shuffle(posts); return new ArrayList<>(posts.subList(0, Math.min(n, posts.size()))); } static List randomFeed() throws IOException, JSONException { return Post.list(call("GET", "/feed?mode=random&limit=" + PAGE), Prefs.server()); } /** Liked posts, newest like first. */ static List likes() throws IOException, JSONException { return Post.list(call("GET", "/likes"), Prefs.server()); } /** /images/:id/like: older instances lack /posts/:id/like, newer ones keep this alias. */ static void setLike(int postId, boolean on) throws IOException, JSONException { call(on ? "POST" : "DELETE", "/images/" + postId + "/like"); } /** The session cookie for url, for clients that bypass HttpURLConnection. */ static Map cookieHeaders(String url) throws IOException { List c = CookieHandler.getDefault().get(URI.create(url), Map.of()).get("Cookie"); return c == null || c.isEmpty() ? Map.of() : Map.of("Cookie", String.join("; ", c)); } }