aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-22 11:11:41 +0200
committerDanilo M. <danix@danix.xyz>2026-10-22 11:11:41 +0200
commitc222cb8e784d65f17ac29433fe51cd4c56e1b261 (patch)
tree3bb8e0133dc6757bed1a42f65a0110a67592f9c4
parent324b600f5795735c74eae81a27beb4953f40653e (diff)
downloadmygram-droid-c222cb8e784d65f17ac29433fe51cd4c56e1b261.tar.gz
mygram-droid-c222cb8e784d65f17ac29433fe51cd4c56e1b261.zip
Add Foldergram API client, likes set and playlist hand-off
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--app/src/main/java/mygram/droid/Api.java180
-rw-r--r--app/src/main/java/mygram/droid/Likes.java48
-rw-r--r--app/src/main/java/mygram/droid/Playlist.java49
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));
+ }
+}