1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
|
/*
* 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.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<String, String> 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 <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();
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<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()));
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<List<Post>> folderPages(String slug, int count) {
List<Integer> 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<Post> 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<List<Post>> 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<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());
}
/** 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));
}
}
|