diff options
| -rw-r--r-- | app/src/main/java/mygram/droid/Api.java | 180 | ||||
| -rw-r--r-- | app/src/main/java/mygram/droid/Likes.java | 48 | ||||
| -rw-r--r-- | app/src/main/java/mygram/droid/Playlist.java | 49 |
3 files changed, 275 insertions, 2 deletions
diff --git a/app/src/main/java/mygram/droid/Api.java b/app/src/main/java/mygram/droid/Api.java index edc4be1..7cce5b6 100644 --- a/app/src/main/java/mygram/droid/Api.java +++ b/app/src/main/java/mygram/droid/Api.java @@ -12,16 +12,84 @@ * 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.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; -/** Foldergram HTTP API. */ +/** 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; + 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 <T> void async(Callable<T> call, Consumer<T> ok, Consumer<Exception> err) { + async(IO, call, ok, err); + } + + static <T> void async(Executor on, Callable<T> call, Consumer<T> ok, Consumer<Exception> 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(); @@ -36,4 +104,112 @@ final class Api { 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<Folder> folders() throws IOException, JSONException { + JSONArray items = call("GET", "/folders").getJSONArray("items"); + List<Folder> out = new ArrayList<>(); + for (int i = 0; i < items.length(); i++) + out.add(new Folder(items.getJSONObject(i), Prefs.server())); + out.sort((a, b) -> Long.compare(b.latest, a.latest)); + return out; + } + + /** Every post of a folder, in server order. */ + static List<Post> folderPosts(String slug) throws IOException, JSONException { + List<Post> all = new ArrayList<>(); + for (int page = 1;; page++) { + JSONObject o = call("GET", "/folders/" + Uri.encode(slug) + "/images?limit=" + PAGE + "&page=" + page); + all.addAll(Post.list(o, Prefs.server())); + if (!o.optBoolean("hasMore")) + return all; + } + } + + /** 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<Post> 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<Post> 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<Post> randomFeed() throws IOException, JSONException { + return Post.list(call("GET", "/feed?mode=random&limit=" + PAGE), Prefs.server()); + } + + static JSONObject reels(int seed, int page) throws IOException, JSONException { + return call("GET", "/reels?mode=random&limit=12&seed=" + seed + "&page=" + page); + } + + /** Liked posts, newest like first. */ + static List<Post> 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<String, String> cookieHeaders(String url) throws IOException { + List<String> c = CookieHandler.getDefault().get(URI.create(url), Map.of()).get("Cookie"); + return c == null || c.isEmpty() ? Map.of() : Map.of("Cookie", String.join("; ", c)); + } } diff --git a/app/src/main/java/mygram/droid/Likes.java b/app/src/main/java/mygram/droid/Likes.java new file mode 100644 index 0000000..bbe2d51 --- /dev/null +++ b/app/src/main/java/mygram/droid/Likes.java @@ -0,0 +1,48 @@ +/* + * mygram-droid: Android TV client for Foldergram. + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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 org.json.JSONException; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** Liked post IDs. Feed items carry no liked flag, so this set drives every heart. */ +final class Likes { + private static final Set<Integer> ids = ConcurrentHashMap.newKeySet(); + + private Likes() { + } + + static void load() throws IOException, JSONException { + Set<Integer> fresh = ConcurrentHashMap.newKeySet(); + for (Post p : Api.likes()) + fresh.add(p.id); + ids.retainAll(fresh); + ids.addAll(fresh); + } + + static boolean has(int postId) { + return ids.contains(postId); + } + + static void set(int postId, boolean liked) { + if (liked) + ids.add(postId); + else + ids.remove(postId); + } +} diff --git a/app/src/main/java/mygram/droid/Playlist.java b/app/src/main/java/mygram/droid/Playlist.java new file mode 100644 index 0000000..98379fb --- /dev/null +++ b/app/src/main/java/mygram/droid/Playlist.java @@ -0,0 +1,49 @@ +/* + * mygram-droid: Android TV client for Foldergram. + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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.content.Context; +import android.content.Intent; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +/** Hands a post list to ViewerActivity; intents cannot carry hundreds of posts. */ +final class Playlist { + static List<Post> posts = new ArrayList<>(); + static int start; + /** Non-zero while the viewer plays endless Reels and pages for more. */ + static int reelsSeed; + static int reelsPage; + + private Playlist() { + } + + static void open(Context c, List<Post> list, int startIndex) { + posts = new ArrayList<>(list); + start = startIndex; + reelsSeed = 0; + c.startActivity(new Intent(c, ViewerActivity.class)); + } + + static void openReels(Context c) { + posts = new ArrayList<>(); + start = 0; + reelsSeed = 1 + ThreadLocalRandom.current().nextInt(1_000_000); + reelsPage = 0; + c.startActivity(new Intent(c, ViewerActivity.class)); + } +} |
