# mygram-droid Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the Android TV Foldergram client described in `docs/superpowers/specs/2026-10-22-mygram-droid-design.md`. **Architecture:** One single-module Gradle project in `app/`, plain Java, UI built in code (no XML layouts). Static helper classes (`Api`, `Prefs`, `Likes`, `Playlist`) hold shared state; five Activities (Setup, Main, Folder, Viewer, Settings). One JVM-wide `CookieManager` carries the Foldergram session for `Api`, Glide and ExoPlayer. **Tech Stack:** Java 17, Android SDK 34 (minSdk 34), AGP 8.5.2, Gradle 8.7 in Docker, RecyclerView, Glide 4.16.0, Media3 ExoPlayer 1.4.1, JUnit 4 + org.json for JVM tests. **Deviations from the spec (deliberate, smaller):** - Tabs are plain views swapped inside `MainActivity`, not Fragments (no `androidx.fragment` dependency). - Icons are Material Icons path data (Apache 2.0), same family as Material Symbols. - Subnet scan runs 64 probes in parallel (800 ms timeout) to stay near 4 s. - `usesCleartextTraffic="true"` instead of a network security config file. **Conventions for every task:** - Placeholders only: `http://192.0.2.10:4141`, never the real instance address. - Every `.java` file starts with the license header from Task 1, Step 2. It is omitted from the listings below to keep them readable; do not forget it. - Java indentation is tabs. - Never run `bash *.SlackBuild`-style host installs; builds run only through `./build.sh` (Docker). - Commits are GPG-signed (the default; never disable it) and end with the attribution line given in the session. --- ## File structure ``` .gitignore build outputs and Docker Gradle home build.sh Docker build: APK, or unit tests with "test" deploy.sh connect to the TV over adb, build, install, launch app/Dockerfile JDK 17 + SDK 34 + Gradle 8.7 build image app/settings.gradle app/gradle.properties app/build.gradle app/src/main/AndroidManifest.xml app/src/main/res/drawable/ ic_*.xml icons, focus_ring.xml, banner.xml app/src/main/java/mygram/droid/ App.java Application: Prefs init, shared CookieManager Prefs.java SharedPreferences: server, password, durations Api.java HTTP + JSON, endpoints, async helper, URL helpers Post.java feed item model (single, video, carousel) Likes.java in-memory liked post IDs Playlist.java static hand-off of a post list to the viewer DoubleOk.java double-press detection Discovery.java /24 subnet scan for Foldergram Ui.java view helpers: dp, text, icon buttons, focus, grids TileAdapter.java thumbnail tiles for rows and grids HomeRows.java Home: one row per folder SetupActivity.java instance picker and password prompt MainActivity.java top bar, background, Home / Liked / Folders FolderActivity.java shuffled grid of one folder ViewerActivity.java fullscreen player SettingsActivity.java password, durations, change instance app/src/test/java/mygram/droid/LogicTest.java ``` --- ### Task 0: Probe the live instance The instance now runs in viewer-password mode. Field names in the code below come from upstream `docs/api.md` and `server/src/types/models.ts`; confirm them against the real instance before writing code. **Files:** none in the repo. Scratch files go in the session scratchpad. - [ ] **Step 1: Get the password without putting it in the repo or the transcript** Ask the user to run (they type it, it never lands in git): ``` ! mkdir -p ~/.config/mygram-droid && read -rsp 'viewer password: ' p && printf %s "$p" > ~/.config/mygram-droid/viewer.pw && chmod 600 ~/.config/mygram-droid/viewer.pw ``` Ask the user for the instance base URL too (it is in agent memory as well). Below it is `$B`. - [ ] **Step 2: Log in and save the cookie** ```bash S=; B= python3 -I -c 'import json,sys; print(json.dumps({"password": open(sys.argv[1]).read()}))' ~/.config/mygram-droid/viewer.pw | curl -s -c "$S/jar" -H 'x-foldergram-intent: 1' -H 'Content-Type: application/json' --data-binary @- "$B/api/auth/login" ``` Expected: JSON with `"ok":true` and `"role":"viewer"`. - [ ] **Step 3: Check the fields the app reads** ```bash for p in '/api/feed?limit=60' '/api/likes' '/api/reels?mode=random&limit=6&seed=1' '/api/folders'; do echo "== $p"; curl -s -b "$S/jar" "$B$p" | python3 -I -c ' import json,sys d=json.load(sys.stdin); it=d["items"] print("hasMore" in d, len(it)) if it: print(sorted(it[0].keys())) c=[i for i in it if i.get("postType")=="carousel"] if c: print(sorted(c[0]["mediaItems"][0].keys()))' done curl -s -b "$S/jar" -o /dev/null -w '%{http_code} %{content_type}\n' "$B/icon-192.png" ``` Expected: - feed, likes and reels items contain `id`, `folderSlug`, `folderName`, `mediaType`, `width`, `height`, `durationMs`, `thumbnailUrl`, `previewUrl`, `postType`. - carousel `mediaItems` entries contain `mediaType`, `width`, `height`, `durationMs`, `thumbnailUrl`, `previewUrl`. - folders items contain `slug`, `name`, `avatarUrl`, `imageCount`, `latestImageMtimeMs`. - `/likes` has no `hasMore` (single list) — fine, the app does not page it. - `/icon-192.png` returns `200 image/png`. If any name differs, fix it in `Post.java` / `Api.Folder` when you write them, and note it in the spec. Do not print or save item contents anywhere (they are personal). --- ### Task 1: Project skeleton that builds an APK **Files:** - Create: `.gitignore`, `build.sh`, `deploy.sh`, `app/Dockerfile`, `app/settings.gradle`, `app/gradle.properties`, `app/build.gradle`, `app/src/main/AndroidManifest.xml`, `app/src/main/java/mygram/droid/App.java`, `app/src/main/java/mygram/droid/Prefs.java`, `app/src/main/java/mygram/droid/MainActivity.java` (stub), plus stubs `SetupActivity`, `SettingsActivity`, `FolderActivity`, `ViewerActivity` so the manifest resolves. - [ ] **Step 1: `.gitignore`** ``` app/build/ app/.gradle/ app/.home/ ``` - [ ] **Step 2: License header used by every `.java` file** ```java /* * 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. */ ``` - [ ] **Step 3: `app/Dockerfile`** (same image recipe as the tv-debloat launcher) ```dockerfile # Build image for mygram-droid: JDK 17, Android SDK 34, Gradle. # Used by build.sh; nothing is installed on the host. # # Copyright (C) 2026 Danilo M. # Licensed under the GNU General Public License version 2 only. FROM eclipse-temurin:17-jdk-jammy ENV ANDROID_HOME=/opt/android-sdk \ GRADLE_VERSION=8.7 ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:/opt/gradle-$GRADLE_VERSION/bin RUN apt-get update && apt-get install -y --no-install-recommends unzip curl \ && rm -rf /var/lib/apt/lists/* \ && curl -fsSL -o /tmp/tools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \ && mkdir -p $ANDROID_HOME/cmdline-tools \ && unzip -q /tmp/tools.zip -d $ANDROID_HOME/cmdline-tools \ && mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \ && curl -fsSL -o /tmp/gradle.zip https://services.gradle.org/distributions/gradle-$GRADLE_VERSION-bin.zip \ && unzip -q /tmp/gradle.zip -d /opt \ && rm /tmp/*.zip \ && yes | sdkmanager --licenses >/dev/null \ && sdkmanager "platforms;android-34" "build-tools;34.0.0" "platform-tools" >/dev/null ``` - [ ] **Step 4: Gradle files** `app/settings.gradle`: ```groovy // Copyright (C) 2026 Danilo M. // Licensed under the GNU General Public License version 2 only. pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositories { google() mavenCentral() } } rootProject.name = 'mygram-droid' ``` `app/gradle.properties`: ``` android.useAndroidX=true org.gradle.jvmargs=-Xmx2g ``` `app/build.gradle`: ```groovy // Copyright (C) 2026 Danilo M. // Licensed under the GNU General Public License version 2 only. plugins { id 'com.android.application' version '8.5.2' } android { namespace 'mygram.droid' compileSdk 34 defaultConfig { applicationId 'mygram.droid' minSdk 34 targetSdk 34 versionCode 1 versionName '0.1' } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } testOptions { unitTests.returnDefaultValues = true } } dependencies { implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'com.github.bumptech.glide:glide:4.16.0' implementation 'androidx.media3:media3-exoplayer:1.4.1' implementation 'androidx.media3:media3-ui:1.4.1' testImplementation 'junit:junit:4.13.2' // Android's org.json is a stub on the JVM; tests need the real one. testImplementation 'org.json:json:20240303' } ``` - [ ] **Step 5: `build.sh`** ```bash #!/bin/bash # build.sh: build the debug APK in a throwaway Docker container, or run the # JVM unit tests with "./build.sh test". Nothing is installed on the host. # Output: app/build/outputs/apk/debug/mygram-droid-debug.apk # # 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. set -euo pipefail cd "$(dirname "$0")" task=assembleDebug [ "${1:-}" = test ] && task=testDebugUnitTest docker image inspect mygram-droid-build >/dev/null 2>&1 || docker build -t mygram-droid-build app # Gradle and Android caches live in app/.home so files stay user-owned. mkdir -p app/.home docker run --rm --user "$(id -u):$(id -g)" -e HOME=/src/.home -e JAVA_TOOL_OPTIONS=-Duser.home=/src/.home \ -v "$PWD/app:/src" -w /src mygram-droid-build \ gradle --no-daemon -q "$task" if [ "$task" = assembleDebug ]; then ls -l app/build/outputs/apk/debug/*.apk fi ``` - [ ] **Step 6: `deploy.sh`** ```bash #!/bin/bash # deploy.sh: build the APK, install it on the TV over wireless adb and # launch it. Finds the TV through its _adb-tls-connect mDNS record when no # device is connected; wireless debugging must be on (it turns off on reboot). # # 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. set -euo pipefail cd "$(dirname "$0")" pkg=mygram.droid apk=app/build/outputs/apk/debug/mygram-droid-debug.apk if ! adb get-state >/dev/null 2>&1; then addr=$(avahi-browse -rtp _adb-tls-connect._tcp 2>/dev/null | awk -F';' '/^=/ && $3 == "IPv4" {print $8 ":" $9; exit}') [ -n "$addr" ] || { echo "no device: set ANDROID_SERIAL or enable wireless debugging" >&2; exit 1; } adb connect "$addr" >/dev/null export ANDROID_SERIAL=$addr fi ./build.sh adb install -r "$apk" adb shell -n am start -n "$pkg/.MainActivity" >/dev/null echo "deployed to ${ANDROID_SERIAL:-$(adb get-serialno)}" ``` Run: `chmod +x build.sh deploy.sh` - [ ] **Step 7: Manifest** `app/src/main/AndroidManifest.xml`: ```xml ``` - [ ] **Step 8: Icons, focus ring, banner** Material Icons path data, Apache License 2.0. Run once from the repo root: ```bash d=app/src/main/res/drawable; mkdir -p "$d" icon() { cat > "$d/ic_$1.xml" < EOF } icon home 'M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z' icon reels 'M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6zM20,2H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z' icon favorite 'M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z' icon folder 'M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z' icon settings 'M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z' icon pause 'M6,19h4V5H6v14zM14,5v14h4V5h-4z' icon refresh 'M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z' icon edit 'M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z' icon check 'M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z' icon swap 'M6.99,11L3,15l3.99,4v-3H14v-2H6.99v-3zM21,9l-3.99,-4v3H10v2h7.01v3L21,9z' icon arrow_forward 'M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z' ``` `app/src/main/res/drawable/focus_ring.xml`: ```xml ``` `app/src/main/res/drawable/banner.xml`: ```xml ``` - [ ] **Step 9: `App.java` and `Prefs.java`** `app/src/main/java/mygram/droid/App.java`: ```java package mygram.droid; import android.app.Application; import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; public final class App extends Application { @Override public void onCreate() { super.onCreate(); Prefs.init(this); // One cookie jar for the Foldergram session: Api, Glide and ExoPlayer // all use HttpURLConnection, which consults the default handler. CookieHandler.setDefault(new CookieManager(null, CookiePolicy.ACCEPT_ALL)); } } ``` `app/src/main/java/mygram/droid/Prefs.java`: ```java package mygram.droid; import android.content.Context; import android.content.SharedPreferences; /** Settings stored on the device. */ final class Prefs { private static SharedPreferences prefs; private Prefs() { } static void init(Context c) { prefs = c.getSharedPreferences("mygram", Context.MODE_PRIVATE); } /** Base URL like "http://192.0.2.10:4141", or null before setup. */ static String server() { return prefs.getString("server", null); } /** Viewer password; empty for a public instance. */ static String password() { return prefs.getString("password", ""); } static int photoSeconds() { return prefs.getInt("photo", 8); } static int backgroundSeconds() { return prefs.getInt("background", 15); } static void setServer(String base, String password) { prefs.edit().putString("server", base).putString("password", password).apply(); } static void setPassword(String password) { prefs.edit().putString("password", password).apply(); } static void setTimes(int photo, int background) { prefs.edit().putInt("photo", photo).putInt("background", background).apply(); } } ``` - [ ] **Step 10: Activity stubs so the manifest resolves** Create each of `MainActivity.java`, `SetupActivity.java`, `SettingsActivity.java`, `FolderActivity.java`, `ViewerActivity.java` in `app/src/main/java/mygram/droid/` with this body, changing the class name (later tasks replace them whole): ```java package mygram.droid; import android.app.Activity; public final class MainActivity extends Activity { } ``` - [ ] **Step 11: Build** Run: `./build.sh` Expected: first run builds the Docker image (several minutes), then prints `app/build/outputs/apk/debug/mygram-droid-debug.apk` with a size. - [ ] **Step 12: Commit** ```bash git add .gitignore build.sh deploy.sh app git commit -m "Add Android project skeleton and Docker build" ``` --- ### Task 2: Pure logic with tests (Post, URL helpers, health check, double-OK) **Files:** - Create: `app/src/test/java/mygram/droid/LogicTest.java` - Create: `app/src/main/java/mygram/droid/Post.java`, `Api.java` (URL helpers only, completed in Task 3), `Discovery.java` (health check only, completed in Task 4), `DoubleOk.java` - [ ] **Step 1: Write the failing test** `app/src/test/java/mygram/droid/LogicTest.java` (with the license header): ```java package mygram.droid; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import org.json.JSONObject; import org.junit.Test; import java.util.List; public class LogicTest { private static final String BASE = "http://192.0.2.10:4141"; @Test public void parsesSinglePhoto() throws Exception { Post p = new Post(new JSONObject("{\"id\":7,\"folderSlug\":\"trips\",\"folderName\":\"Trips\"," + "\"mediaType\":\"image\",\"width\":1080,\"height\":1920,\"durationMs\":null," + "\"thumbnailUrl\":\"/thumbnails/ab/x.webp?v=1\",\"previewUrl\":\"/previews/ab/x.webp?v=1\"," + "\"postType\":\"single\",\"itemCount\":1}"), BASE); assertEquals(7, p.id); assertEquals("trips", p.folderSlug); assertEquals(1, p.media.size()); Post.Media m = p.media.get(0); assertFalse(m.video); assertEquals(0, m.durationMs); assertEquals(1080, m.width); assertEquals(BASE + "/thumbnails/ab/x.webp?v=1", m.thumbnailUrl); assertEquals(BASE + "/previews/ab/x.webp?v=1", m.previewUrl); } @Test public void parsesVideo() throws Exception { Post p = new Post(new JSONObject("{\"id\":8,\"mediaType\":\"video\",\"durationMs\":41633," + "\"thumbnailUrl\":\"/thumbnails/cd/y.webp\",\"previewUrl\":\"/previews/cd/y.mp4\"}"), BASE); Post.Media m = p.media.get(0); assertTrue(m.video); assertEquals(41633, m.durationMs); assertEquals(BASE + "/previews/cd/y.mp4", m.previewUrl); } @Test public void parsesCarouselSlidesInOrder() throws Exception { Post p = new Post(new JSONObject("{\"id\":9,\"mediaType\":\"image\",\"postType\":\"carousel\"," + "\"thumbnailUrl\":\"/thumbnails/a.webp\",\"previewUrl\":\"/previews/a.webp\",\"mediaItems\":[" + "{\"imageId\":1,\"mediaType\":\"image\",\"thumbnailUrl\":\"/thumbnails/a.webp\",\"previewUrl\":\"/previews/a.webp\"}," + "{\"imageId\":2,\"mediaType\":\"video\",\"durationMs\":5000,\"thumbnailUrl\":\"/thumbnails/b.webp\",\"previewUrl\":\"/previews/b.mp4\"}]}"), BASE); assertEquals(2, p.media.size()); assertFalse(p.media.get(0).video); assertTrue(p.media.get(1).video); assertEquals(BASE + "/previews/b.mp4", p.media.get(1).previewUrl); } @Test public void parsesPageAndNullUrl() throws Exception { List posts = Post.list(new JSONObject("{\"items\":[{\"id\":1,\"mediaType\":\"image\"," + "\"thumbnailUrl\":null,\"previewUrl\":\"/previews/z.webp\"}],\"hasMore\":false}"), BASE); assertEquals(1, posts.size()); assertNull(posts.get(0).media.get(0).thumbnailUrl); assertEquals(0, Post.list(new JSONObject("{}"), BASE).size()); } @Test public void normalizesServerUrls() { assertEquals("http://192.0.2.10:4141", Api.normalize(" 192.0.2.10:4141/ ")); assertEquals("http://foldergram.example.org:4141", Api.normalize("http://foldergram.example.org:4141//")); assertEquals("https://foldergram.example.org", Api.normalize("https://foldergram.example.org")); } @Test public void resolvesMediaPaths() { assertEquals(BASE + "/icon-192.png", Api.resolve(BASE, "/icon-192.png")); assertNull(Api.resolve(BASE, null)); } @Test public void detectsFoldergramHealth() { assertTrue(Discovery.isFoldergram("{\"ok\":true,\"timestamp\":\"t\",\"storage\":{\"available\":true}}")); assertFalse(Discovery.isFoldergram("{\"ok\":true}")); assertFalse(Discovery.isFoldergram("{\"ok\":false,\"storage\":{}}")); assertFalse(Discovery.isFoldergram("router login")); } @Test public void tellsDoublePressFromSingle() { DoubleOk d = new DoubleOk(); assertFalse(d.press(1000)); assertTrue(d.press(1250)); assertFalse("a double press resets", d.press(1400)); assertFalse(d.press(2000)); assertFalse("301 ms is too slow", d.press(2301)); } } ``` - [ ] **Step 2: Run it to see it fail** Run: `./build.sh test` Expected: FAIL, compilation errors `cannot find symbol` for `Post`, `Api`, `Discovery`, `DoubleOk`. - [ ] **Step 3: `Post.java`** ```java package mygram.droid; import org.json.JSONArray; import org.json.JSONObject; import java.util.ArrayList; import java.util.List; /** One Foldergram post: a photo, a video, or a carousel of slides. */ final class Post { /** One slide. A single post has exactly one. */ static final class Media { final boolean video; final int width, height; final long durationMs; final String thumbnailUrl, previewUrl; Media(JSONObject o, String base) { video = "video".equals(o.optString("mediaType")); width = o.optInt("width"); height = o.optInt("height"); durationMs = o.optLong("durationMs"); thumbnailUrl = Api.resolve(base, str(o, "thumbnailUrl")); previewUrl = Api.resolve(base, str(o, "previewUrl")); } } final int id; final String folderSlug, folderName; final List media = new ArrayList<>(); Post(JSONObject o, String base) { id = o.optInt("id"); folderSlug = o.optString("folderSlug"); folderName = o.optString("folderName"); JSONArray items = o.optJSONArray("mediaItems"); if (items != null) for (int i = 0; i < items.length(); i++) media.add(new Media(items.optJSONObject(i), base)); if (media.isEmpty()) media.add(new Media(o, base)); } /** The posts in a paged response's "items". */ static List list(JSONObject page, String base) { List out = new ArrayList<>(); JSONArray items = page.optJSONArray("items"); if (items != null) for (int i = 0; i < items.length(); i++) out.add(new Post(items.optJSONObject(i), base)); return out; } /** optString turns JSON null into "null"; this returns null. */ static String str(JSONObject o, String key) { return o.isNull(key) ? null : o.optString(key); } } ``` - [ ] **Step 4: `Api.java` (URL helpers; the rest arrives in Task 3)** ```java package mygram.droid; import java.net.URI; /** Foldergram HTTP API. */ final class Api { private Api() { } /** "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(); } } ``` - [ ] **Step 5: `Discovery.java` (health check; the scan arrives in Task 4)** ```java package mygram.droid; import org.json.JSONException; import org.json.JSONObject; /** Finds Foldergram instances on the LAN. */ final class Discovery { private Discovery() { } /** True for a Foldergram /api/health reply: {"ok":true,...,"storage":{...}}. */ static boolean isFoldergram(String body) { try { JSONObject o = new JSONObject(body); return o.optBoolean("ok") && o.optJSONObject("storage") != null; } catch (JSONException e) { return false; } } } ``` - [ ] **Step 6: `DoubleOk.java`** ```java package mygram.droid; /** Tells a single OK press from the second press of a double press. */ final class DoubleOk { static final long WINDOW_MS = 300; private long last = Long.MIN_VALUE / 2; /** Call on every press with its time; true if it completes a double press. */ boolean press(long nowMs) { if (nowMs - last <= WINDOW_MS) { last = Long.MIN_VALUE / 2; return true; } last = nowMs; return false; } } ``` - [ ] **Step 7: Run the tests** Run: `./build.sh test` Expected: exits 0, no output (Gradle `-q`). On failure Gradle prints the failing test and a report path under `app/build/reports/tests/`. - [ ] **Step 8: Commit** ```bash git add app/src git commit -m "Add post parsing, URL helpers, health check and double-OK logic with tests" ``` --- ### Task 3: API client, likes and playlist hand-off **Files:** - Modify: `app/src/main/java/mygram/droid/Api.java` (replace whole file) - Create: `app/src/main/java/mygram/droid/Likes.java`, `app/src/main/java/mygram/droid/Playlist.java` - [ ] **Step 1: Replace `Api.java`** ```java 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. 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 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())); out.sort((a, b) -> Long.compare(b.latest, a.latest)); return out; } /** Every post of a folder, in server order. */ static List folderPosts(String slug) throws IOException, JSONException { List 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 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()); } 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 likes() throws IOException, JSONException { return Post.list(call("GET", "/likes"), Prefs.server()); } static void setLike(int postId, boolean on) throws IOException, JSONException { call(on ? "POST" : "DELETE", "/posts/" + 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)); } } ``` - [ ] **Step 2: `Likes.java`** ```java 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 ids = ConcurrentHashMap.newKeySet(); private Likes() { } static void load() throws IOException, JSONException { Set 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); } } ``` - [ ] **Step 3: `Playlist.java`** ```java 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 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 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)); } } ``` - [ ] **Step 4: Tests and build still pass** Run: `./build.sh test && ./build.sh` Expected: tests exit 0; APK listed. - [ ] **Step 5: Commit** ```bash git add app/src git commit -m "Add Foldergram API client, likes set and playlist hand-off" ``` --- ### Task 4: Subnet discovery and instance picker **Files:** - Modify: `app/src/main/java/mygram/droid/Discovery.java` (replace whole file) - Create: `app/src/main/java/mygram/droid/Ui.java` - Modify: `app/src/main/java/mygram/droid/SetupActivity.java` (replace stub) - [ ] **Step 1: Replace `Discovery.java`** ```java package mygram.droid; import android.content.Context; import android.net.ConnectivityManager; import android.net.LinkAddress; import android.net.LinkProperties; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.net.Inet4Address; import java.net.InetAddress; import java.net.URI; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; /** Finds Foldergram instances on the LAN. */ // ponytail: probes only the TV's /24 on the default port; manual URL entry covers proxies and other ports. final class Discovery { static final int PORT = 4141; private Discovery() { } /** True for a Foldergram /api/health reply: {"ok":true,...,"storage":{...}}. */ static boolean isFoldergram(String body) { try { JSONObject o = new JSONObject(body); return o.optBoolean("ok") && o.optJSONObject("storage") != null; } catch (JSONException e) { return false; } } /** The TV's own IPv4 address, or null when offline. */ static String localIpv4(Context c) { ConnectivityManager cm = c.getSystemService(ConnectivityManager.class); LinkProperties lp = cm.getLinkProperties(cm.getActiveNetwork()); if (lp != null) for (LinkAddress a : lp.getLinkAddresses()) if (a.getAddress() instanceof Inet4Address) return a.getAddress().getHostAddress(); return null; } /** Base URLs of instances answering on the /24 around self. Blocks a few seconds. */ static List scan(String self) throws InterruptedException { String prefix = self.substring(0, self.lastIndexOf('.') + 1); ExecutorService pool = Executors.newFixedThreadPool(64); List found = Collections.synchronizedList(new ArrayList<>()); for (int i = 1; i < 255; i++) { String base = "http://" + prefix + i + ":" + PORT; pool.execute(() -> { try { if (isFoldergram(Api.request(base, "GET", "/health", null, 800))) found.add(base); } catch (IOException e) { // nothing listening there } }); } pool.shutdown(); pool.awaitTermination(20, TimeUnit.SECONDS); List out = new ArrayList<>(found); Collections.sort(out); return out; } /** Reverse-DNS name plus IP when there is a name, else the IP. */ static String label(String base) { String ip = URI.create(base).getHost(); try { String name = InetAddress.getByName(ip).getCanonicalHostName(); return name.equals(ip) ? ip : name + " " + ip; } catch (UnknownHostException e) { return ip; } } } ``` - [ ] **Step 2: `Ui.java`** ```java package mygram.droid; import android.content.Context; import android.graphics.Color; import android.graphics.RenderEffect; import android.graphics.Shader; import android.text.InputType; import android.view.Gravity; import android.view.View; import android.widget.EditText; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.List; /** View helpers. The UI is built in code, there are no layout files. */ final class Ui { static final int RED = 0xFFFF3B5C; /** Post tiles: portrait, most media is 9:16. */ static final int POST_W = 128, POST_H = 228; private Ui() { } static int dp(Context c, float v) { return Math.round(v * c.getResources().getDisplayMetrics().density); } static TextView text(Context c, String s, int sp) { TextView t = new TextView(c); t.setText(s); t.setTextSize(sp); t.setTextColor(Color.WHITE); return t; } static LinearLayout column(Context c) { LinearLayout box = new LinearLayout(c); box.setOrientation(LinearLayout.VERTICAL); box.setGravity(Gravity.CENTER); int p = dp(c, 32); box.setPadding(p, p, p, p); return box; } static LinearLayout row(Context c) { LinearLayout row = new LinearLayout(c); row.setGravity(Gravity.CENTER); return row; } static EditText field(Context c, String hint, int inputType) { EditText e = new EditText(c); e.setHint(hint); e.setInputType(inputType); e.setTextColor(Color.WHITE); e.setHintTextColor(Color.GRAY); e.setMinWidth(dp(c, 400)); e.setSingleLine(true); return e; } static int password() { return InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD; } /** Focus ring and a small zoom: the only focus feedback on a TV. */ static void focusable(View v) { v.setFocusable(true); v.setForeground(v.getContext().getDrawable(R.drawable.focus_ring)); v.setOnFocusChangeListener((x, has) -> zoom(x, has)); } private static void zoom(View v, boolean in) { v.animate().scaleX(in ? 1.08f : 1f).scaleY(in ? 1.08f : 1f).setDuration(120).start(); } /** Icon-only button; its label shows under it while it has focus. Child 0 is the ImageView. */ static LinearLayout iconButton(Context c, int icon, String label, View.OnClickListener click) { LinearLayout box = new LinearLayout(c); box.setOrientation(LinearLayout.VERTICAL); box.setGravity(Gravity.CENTER_HORIZONTAL); int p = dp(c, 10); box.setPadding(p, p, p, p / 2); ImageView image = new ImageView(c); image.setImageResource(icon); image.setContentDescription(label); int s = dp(c, 36); box.addView(image, new LinearLayout.LayoutParams(s, s)); TextView t = text(c, label, 12); t.setVisibility(View.INVISIBLE); box.addView(t); box.setFocusable(true); box.setForeground(c.getDrawable(R.drawable.focus_ring)); box.setOnFocusChangeListener((v, has) -> { t.setVisibility(has ? View.VISIBLE : View.INVISIBLE); zoom(v, has); }); box.setOnClickListener(click); return box; } static void blur(ImageView v, float radius) { v.setRenderEffect(RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)); } static RecyclerView list(Context c, int orientation) { RecyclerView rv = new RecyclerView(c); rv.setLayoutManager(new LinearLayoutManager(c, orientation, false)); int p = dp(c, 24); rv.setPadding(p, 0, p, 0); rv.setClipToPadding(false); return rv; } static RecyclerView grid(Context c, List tiles, int w, int h, int span) { RecyclerView rv = new RecyclerView(c); rv.setLayoutManager(new GridLayoutManager(c, span)); rv.setAdapter(new TileAdapter(tiles, w, h)); int p = dp(c, 24); rv.setPadding(p, p, p, p); rv.setClipToPadding(false); return rv; } /** Tiles that open the viewer on posts, starting at the chosen one. */ static List postTiles(Context c, List posts) { List tiles = new ArrayList<>(); for (int i = 0; i < posts.size(); i++) { int start = i; tiles.add(TileAdapter.post(posts.get(i), () -> Playlist.open(c, posts, start))); } return tiles; } } ``` Note: `Ui` references `TileAdapter`, created in Task 5. To keep this task compiling, create `TileAdapter.java` now with the full content from Task 5, Step 1, and commit it with this task. - [ ] **Step 3: Replace `SetupActivity.java`** ```java package mygram.droid; import android.app.Activity; import android.content.Intent; import android.os.Bundle; import android.text.InputType; import android.widget.EditText; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; import java.util.LinkedHashMap; import java.util.Map; /** Instance picker: LAN scan, manual URL, then the password when the instance asks for one. */ public final class SetupActivity extends Activity { private LinearLayout box; @Override protected void onCreate(Bundle state) { super.onCreate(state); box = Ui.column(this); setContentView(box); scan(); } private void scan() { reset("Searching the network for Foldergram…"); Api.async(() -> { Map found = new LinkedHashMap<>(); String self = Discovery.localIpv4(this); if (self != null) for (String base : Discovery.scan(self)) found.put(base, Discovery.label(base)); return found; }, this::found, e -> { reset("Scan failed: " + e.getMessage()); actions(); }); } private void found(Map instances) { reset(instances.isEmpty() ? "No Foldergram found on port " + Discovery.PORT : "Choose an instance"); for (Map.Entry e : instances.entrySet()) { TextView t = Ui.text(this, e.getValue(), 20); int p = Ui.dp(this, 12); t.setPadding(p * 2, p, p * 2, p); Ui.focusable(t); t.setOnClickListener(v -> choose(e.getKey())); box.addView(t); } actions(); box.getChildAt(1).requestFocus(); } private void actions() { LinearLayout row = Ui.row(this); row.addView(Ui.iconButton(this, R.drawable.ic_refresh, "Rescan", v -> scan())); row.addView(Ui.iconButton(this, R.drawable.ic_edit, "Enter URL", v -> manual())); box.addView(row); } private void manual() { reset("Instance URL"); EditText url = Ui.field(this, "http://host:" + Discovery.PORT, InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); box.addView(url); box.addView(Ui.iconButton(this, R.drawable.ic_check, "Connect", v -> choose(Api.normalize(url.getText().toString())))); url.requestFocus(); } private void choose(String base) { reset("Connecting to " + base + "…"); Api.async(() -> Api.authStatus(base), status -> { if ("public".equals(status.optString("accessMode"))) save(base, ""); else password(base); }, e -> { reset("Cannot reach " + base + ": " + e.getMessage()); actions(); }); } private void password(String base) { reset("Viewer password for " + base); EditText pw = Ui.field(this, "Password", Ui.password()); box.addView(pw); box.addView(Ui.iconButton(this, R.drawable.ic_check, "Log in", v -> { String s = pw.getText().toString(); Api.async(() -> { Api.login(base, s); return true; }, ok -> save(base, s), e -> Toast.makeText(this, e instanceof Api.HttpError && ((Api.HttpError) e).code == 401 ? "Wrong password" : e.getMessage(), Toast.LENGTH_LONG).show()); })); pw.requestFocus(); } private void save(String base, String password) { Prefs.setServer(base, password); startActivity(new Intent(this, MainActivity.class) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)); } private void reset(String title) { box.removeAllViews(); box.addView(Ui.text(this, title, 24)); } } ``` - [ ] **Step 4: Build** Run: `./build.sh test && ./build.sh` Expected: tests exit 0; APK listed. - [ ] **Step 5: Commit** ```bash git add app/src git commit -m "Add LAN discovery and instance picker" ``` --- ### Task 5: Tiles, Home rows, main screen, folder grid **Files:** - Create: `app/src/main/java/mygram/droid/TileAdapter.java` (if not created in Task 4), `HomeRows.java` - Modify: `MainActivity.java`, `FolderActivity.java` (replace stubs) - [ ] **Step 1: `TileAdapter.java`** ```java package mygram.droid; import android.content.Context; import android.graphics.Color; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import java.util.List; /** Thumbnail tiles for rows and grids: posts, folders and the Continue tile. */ final class TileAdapter extends RecyclerView.Adapter { static final class Tile { final String image, label; final int icon, postId; final Runnable click; /** image or icon is shown; postId is -1 for tiles that are not posts. */ Tile(String image, String label, int icon, int postId, Runnable click) { this.image = image; this.label = label; this.icon = icon; this.postId = postId; this.click = click; } } static Tile post(Post p, Runnable click) { return new Tile(p.media.get(0).thumbnailUrl, null, 0, p.id, click); } static final class Holder extends RecyclerView.ViewHolder { final ImageView image, heart; final TextView label; Holder(View v, ImageView image, TextView label, ImageView heart) { super(v); this.image = image; this.label = label; this.heart = heart; } } private final List tiles; private final int widthDp, heightDp; TileAdapter(List tiles, int widthDp, int heightDp) { this.tiles = tiles; this.widthDp = widthDp; this.heightDp = heightDp; } @Override public Holder onCreateViewHolder(ViewGroup parent, int type) { Context c = parent.getContext(); FrameLayout frame = new FrameLayout(c); RecyclerView.LayoutParams lp = new RecyclerView.LayoutParams(Ui.dp(c, widthDp), Ui.dp(c, heightDp)); int m = Ui.dp(c, 12); lp.setMargins(m, m, m, m); frame.setLayoutParams(lp); frame.setBackgroundColor(0xFF222222); ImageView image = new ImageView(c); frame.addView(image, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); TextView label = Ui.text(c, "", 14); label.setShadowLayer(6, 0, 0, Color.BLACK); int p = Ui.dp(c, 8); label.setPadding(p, p, p, p); frame.addView(label, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM)); ImageView heart = new ImageView(c); heart.setImageResource(R.drawable.ic_favorite); heart.setColorFilter(Ui.RED); heart.setContentDescription("Liked"); int s = Ui.dp(c, 22); FrameLayout.LayoutParams hp = new FrameLayout.LayoutParams(s, s, Gravity.TOP | Gravity.END); hp.setMargins(p, p, p, p); frame.addView(heart, hp); Ui.focusable(frame); return new Holder(frame, image, label, heart); } @Override public void onBindViewHolder(Holder h, int position) { Tile t = tiles.get(position); if (t.image != null) { h.image.setScaleType(ImageView.ScaleType.CENTER_CROP); Glide.with(h.image).load(t.image).into(h.image); } else { Glide.with(h.image).clear(h.image); h.image.setScaleType(ImageView.ScaleType.CENTER); h.image.setImageResource(t.icon); } h.label.setText(t.label); h.label.setVisibility(t.label == null ? View.GONE : View.VISIBLE); h.heart.setVisibility(t.postId >= 0 && Likes.has(t.postId) ? View.VISIBLE : View.GONE); h.itemView.setContentDescription(t.label != null ? t.label : "Post"); h.itemView.setOnClickListener(v -> t.click.run()); } @Override public int getItemCount() { return tiles.size(); } } ``` - [ ] **Step 2: `HomeRows.java`** ```java package mygram.droid; import android.app.Activity; import android.content.Context; import android.view.ViewGroup; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; /** Home: one row per folder, a random sample of its posts, then a Continue tile. */ final class HomeRows extends RecyclerView.Adapter { private static final int SAMPLE = 10; static final class Row extends RecyclerView.ViewHolder { final TextView title; final RecyclerView tiles; Row(LinearLayout v, TextView title, RecyclerView tiles) { super(v); this.title = title; this.tiles = tiles; } } private final Activity activity; private final List folders; private final Map> samples = new HashMap<>(); HomeRows(Activity activity, List folders) { this.activity = activity; this.folders = folders; } @Override public Row onCreateViewHolder(ViewGroup parent, int type) { Context c = parent.getContext(); LinearLayout v = new LinearLayout(c); v.setOrientation(LinearLayout.VERTICAL); v.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); TextView title = Ui.text(c, "", 18); title.setPadding(Ui.dp(c, 36), Ui.dp(c, 16), 0, 0); v.addView(title); RecyclerView tiles = Ui.list(c, RecyclerView.HORIZONTAL); v.addView(tiles, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(c, Ui.POST_H + 24))); return new Row(v, title, tiles); } @Override public void onBindViewHolder(Row row, int position) { Api.Folder f = folders.get(position); row.title.setText(f.name); List posts = samples.get(f.slug); if (posts == null) { posts = List.of(); samples.put(f.slug, posts); // marks it loading, so it is fetched once Api.async(() -> Api.folderSample(f, SAMPLE), p -> { samples.put(f.slug, p); notifyItemChanged(folders.indexOf(f)); }, e -> { // the row keeps only its Continue tile }); } List tiles = new ArrayList<>(); for (Post p : posts) tiles.add(TileAdapter.post(p, () -> open(f, p))); tiles.add(new TileAdapter.Tile(null, "Continue", R.drawable.ic_arrow_forward, -1, () -> FolderActivity.open(activity, f))); row.tiles.setAdapter(new TileAdapter(tiles, Ui.POST_W, Ui.POST_H)); } @Override public int getItemCount() { return folders.size(); } /** Viewer at p, then the rest of the folder, shuffled. */ private void open(Api.Folder f, Post p) { Api.async(() -> Api.folderPosts(f.slug), all -> { Collections.shuffle(all); all.removeIf(x -> x.id == p.id); all.add(0, p); Playlist.open(activity, all, 0); }, e -> Toast.makeText(activity, "Cannot load folder: " + e.getMessage(), Toast.LENGTH_LONG).show()); } /** Rebinds visible tiles so hearts follow likes changed in the viewer. */ void refreshHearts(RecyclerView list) { for (int i = 0; i < list.getChildCount(); i++) { RecyclerView.Adapter a = ((Row) list.getChildViewHolder(list.getChildAt(i))).tiles.getAdapter(); if (a != null) a.notifyItemRangeChanged(0, a.getItemCount()); } } } ``` - [ ] **Step 3: Replace `FolderActivity.java`** ```java package mygram.droid; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.TextView; import androidx.recyclerview.widget.RecyclerView; import java.util.Collections; /** One folder as a grid, shuffled once per open. */ public final class FolderActivity extends Activity { private FrameLayout content; private RecyclerView grid; static void open(Context c, Api.Folder f) { c.startActivity(new Intent(c, FolderActivity.class).putExtra("slug", f.slug).putExtra("name", f.name)); } @Override protected void onCreate(Bundle state) { super.onCreate(state); LinearLayout column = new LinearLayout(this); column.setOrientation(LinearLayout.VERTICAL); TextView title = Ui.text(this, getIntent().getStringExtra("name"), 24); title.setPadding(Ui.dp(this, 36), Ui.dp(this, 24), 0, 0); column.addView(title); content = new FrameLayout(this); column.addView(content, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); setContentView(column); show(Ui.text(this, "Loading…", 18)); Api.async(() -> Api.folderPosts(getIntent().getStringExtra("slug")), posts -> { Collections.shuffle(posts); grid = Ui.grid(this, Ui.postTiles(this, posts), Ui.POST_W, Ui.POST_H, 6); show(grid); grid.requestFocus(); }, e -> show(Ui.text(this, "Cannot load folder: " + e.getMessage(), 18))); } @Override protected void onResume() { super.onResume(); if (grid != null && grid.getAdapter() != null) grid.getAdapter().notifyItemRangeChanged(0, grid.getAdapter().getItemCount()); } private void show(android.view.View v) { content.removeAllViews(); content.addView(v); } } ``` - [ ] **Step 4: Replace `MainActivity.java`** ```java package mygram.droid; import android.app.Activity; import android.content.Intent; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.Space; import android.widget.TextView; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ThreadLocalRandom; /** Top bar, rotating blurred background, and the Home, Liked and Folders tabs. */ public final class MainActivity extends Activity { private static final int HOME = 0, LIKED = 1, FOLDERS = 2; private static final ViewGroup.LayoutParams FILL = new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); private final Handler handler = new Handler(Looper.getMainLooper()); private final Runnable rotate = this::rotateBackground; private FrameLayout content; /** Two layers that swap, so a new background fades in over the old one. */ private final ImageView[] layers = new ImageView[2]; private int front; private List backgrounds = new ArrayList<>(); private RecyclerView homeList; private HomeRows homeRows; private int tab = HOME; private boolean ready; @Override protected void onCreate(Bundle state) { super.onCreate(state); if (Prefs.server() == null) { startActivity(new Intent(this, SetupActivity.class)); finish(); return; } FrameLayout root = new FrameLayout(this); root.setBackgroundColor(Color.BLACK); FrameLayout layerBox = new FrameLayout(this); // bringToFront() stays inside it for (int i = 0; i < 2; i++) { layers[i] = new ImageView(this); layers[i].setScaleType(ImageView.ScaleType.CENTER_CROP); Ui.blur(layers[i], 60); layerBox.addView(layers[i], FILL); } root.addView(layerBox, FILL); View dim = new View(this); dim.setBackgroundColor(0xAA000000); root.addView(dim, FILL); LinearLayout column = new LinearLayout(this); column.setOrientation(LinearLayout.VERTICAL); column.addView(topBar()); content = new FrameLayout(this); column.addView(content, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); root.addView(column, FILL); setContentView(root); connect(); } /** [logo] [Reels] [Liked] [Folders] [Settings] */ private View topBar() { LinearLayout bar = new LinearLayout(this); bar.setGravity(Gravity.CENTER_VERTICAL); int p = Ui.dp(this, 16); bar.setPadding(p * 2, p, p * 2, 0); LinearLayout logo = Ui.iconButton(this, R.drawable.ic_home, "Home", v -> show(HOME)); // Foldergram's logo is AGPLv3: loaded from the instance, never bundled. Glide.with(this).load(Api.resolve(Prefs.server(), "/icon-192.png")).error(R.drawable.ic_home) .into((ImageView) logo.getChildAt(0)); bar.addView(logo); bar.addView(new Space(this), new LinearLayout.LayoutParams(0, 1, 1)); bar.addView(Ui.iconButton(this, R.drawable.ic_reels, "Reels", v -> Playlist.openReels(this))); bar.addView(Ui.iconButton(this, R.drawable.ic_favorite, "Liked", v -> show(LIKED))); bar.addView(Ui.iconButton(this, R.drawable.ic_folder, "Folders", v -> show(FOLDERS))); bar.addView(new Space(this), new LinearLayout.LayoutParams(0, 1, 1)); bar.addView(Ui.iconButton(this, R.drawable.ic_settings, "Settings", v -> startActivity(new Intent(this, SettingsActivity.class)))); return bar; } private void connect() { message("Connecting…"); Api.async(() -> { if (!Prefs.password().isEmpty()) Api.login(Prefs.server(), Prefs.password()); Likes.load(); return Api.randomFeed(); }, posts -> { ready = true; backgrounds = posts; handler.removeCallbacks(rotate); rotateBackground(); show(tab); }, this::failed); } private void failed(Exception e) { LinearLayout box = Ui.column(this); box.addView(Ui.text(this, "Cannot reach the instance: " + e.getMessage(), 18)); LinearLayout row = Ui.row(this); row.addView(Ui.iconButton(this, R.drawable.ic_refresh, "Retry", v -> connect())); row.addView(Ui.iconButton(this, R.drawable.ic_swap, "Change instance", v -> startActivity(new Intent(this, SetupActivity.class)))); box.addView(row); setContent(box); row.getChildAt(0).requestFocus(); } private void show(int t) { if (!ready) return; tab = t; if (t == HOME) { if (homeList != null) { setContent(homeList); return; } message("Loading…"); Api.async(Api::folders, folders -> { homeRows = new HomeRows(this, folders); homeList = Ui.list(this, RecyclerView.VERTICAL); homeList.setAdapter(homeRows); if (tab == HOME) setContent(homeList); }, this::failed); } else if (t == LIKED) { message("Loading…"); Api.async(Api::likes, posts -> { if (tab == LIKED) setContent(posts.isEmpty() ? centered("No liked posts yet") : Ui.grid(this, Ui.postTiles(this, posts), Ui.POST_W, Ui.POST_H, 6)); }, this::failed); } else { message("Loading…"); Api.async(Api::folders, folders -> { if (tab != FOLDERS) return; List tiles = new ArrayList<>(); for (Api.Folder f : folders) tiles.add(new TileAdapter.Tile(f.avatarUrl, f.name, R.drawable.ic_folder, -1, () -> FolderActivity.open(this, f))); setContent(Ui.grid(this, tiles, 160, 160, 5)); }, this::failed); } } @Override protected void onResume() { super.onResume(); if (!ready) return; handler.removeCallbacks(rotate); handler.postDelayed(rotate, Prefs.backgroundSeconds() * 1000L); if (tab == LIKED) show(LIKED); // unliked posts drop out else if (homeRows != null) homeRows.refreshHearts(homeList); } @Override protected void onPause() { super.onPause(); handler.removeCallbacks(rotate); } private void rotateBackground() { if (backgrounds.isEmpty()) return; Post p = backgrounds.get(ThreadLocalRandom.current().nextInt(backgrounds.size())); // Never pass the old drawable as a Glide placeholder: Glide may recycle its bitmap. ImageView back = layers[1 - front], top = layers[front]; back.setAlpha(0f); back.bringToFront(); Glide.with(this).load(p.media.get(0).thumbnailUrl).listener(new RequestListener() { @Override public boolean onLoadFailed(GlideException e, Object model, Target target, boolean first) { return false; } @Override public boolean onResourceReady(Drawable d, Object model, Target target, DataSource source, boolean first) { back.animate().alpha(1f).setDuration(1500).withEndAction(() -> Glide.with(top).clear(top)).start(); return false; } }).into(back); front = 1 - front; handler.postDelayed(rotate, Prefs.backgroundSeconds() * 1000L); } private void message(String s) { setContent(centered(s)); } private TextView centered(String s) { TextView t = Ui.text(this, s, 18); t.setGravity(Gravity.CENTER); return t; } private void setContent(View v) { content.removeAllViews(); content.addView(v, FILL); } } ``` - [ ] **Step 5: Build** Run: `./build.sh test && ./build.sh` Expected: tests exit 0; APK listed. - [ ] **Step 6: Commit** ```bash git add app/src git commit -m "Add main screen with top bar, Home rows, Liked and Folders tabs" ``` --- ### Task 6: Fullscreen viewer **Files:** - Modify: `app/src/main/java/mygram/droid/ViewerActivity.java` (replace stub) - [ ] **Step 1: Replace `ViewerActivity.java`** ```java package mygram.droid; import android.app.Activity; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.media.MediaMetadataRetriever; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.view.Gravity; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.OptIn; import androidx.media3.common.MediaItem; import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; import androidx.media3.common.util.UnstableApi; import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.ui.PlayerView; import com.bumptech.glide.Glide; import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; import java.util.HashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; /** * Fullscreen player over a blurred backdrop. Up/Down: post. Left/Right: slide, * or seek on a single video. OK: pause. Double OK: like. */ public final class ViewerActivity extends Activity { private static final long SEEK_MS = 10_000, TICK_MS = 100; /** Frame grabs block on the network; keep them off the API pool. */ private static final ExecutorService FRAMES = Executors.newSingleThreadExecutor(); private static final ViewGroup.LayoutParams FILL = new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); private final Handler handler = new Handler(Looper.getMainLooper()); private final Runnable ticker = this::tick; private final DoubleOk doubleOk = new DoubleOk(); private ExoPlayer player; private PlayerView video; private ImageView backdrop, photo, pauseIcon, heart, bigHeart; private ProgressBar progress; private TextView message; private int index, slide; /** Bumped on every slide change; late callbacks for older slides are dropped. */ private volatile int generation; private boolean paused, failed, loadingMore; private long photoElapsed; @Override @OptIn(markerClass = UnstableApi.class) protected void onCreate(Bundle state) { super.onCreate(state); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); FrameLayout root = new FrameLayout(this); root.setBackgroundColor(Color.BLACK); backdrop = new ImageView(this); backdrop.setScaleType(ImageView.ScaleType.CENTER_CROP); Ui.blur(backdrop, 40); root.addView(backdrop, FILL); View dim = new View(this); dim.setBackgroundColor(0x66000000); root.addView(dim, FILL); photo = new ImageView(this); photo.setScaleType(ImageView.ScaleType.FIT_CENTER); root.addView(photo, FILL); player = new ExoPlayer.Builder(this).build(); player.addListener(new Player.Listener() { @Override public void onPlaybackStateChanged(int s) { if (s == Player.STATE_ENDED) advance(); } @Override public void onPlayerError(PlaybackException e) { fail(); } }); video = new PlayerView(this); video.setUseController(false); video.setShutterBackgroundColor(Color.TRANSPARENT); video.setPlayer(player); root.addView(video, FILL); message = Ui.text(this, "", 20); message.setGravity(Gravity.CENTER); root.addView(message, FILL); progress = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal); progress.setMax(1000); root.addView(progress, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 4), Gravity.BOTTOM)); pauseIcon = overlay(root, R.drawable.ic_pause, "Paused", 32, Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL); heart = overlay(root, R.drawable.ic_favorite, "Liked", 32, Gravity.BOTTOM | Gravity.END); bigHeart = overlay(root, R.drawable.ic_favorite, "Liked", 160, Gravity.CENTER); heart.setColorFilter(Ui.RED); bigHeart.setColorFilter(Ui.RED); setContentView(root); if (!Playlist.posts.isEmpty()) { show(Playlist.start); } else if (Playlist.reelsSeed != 0) { message.setText("Loading…"); maybeLoadMore(); } else { finish(); // process restarted; the list is gone } } private ImageView overlay(FrameLayout root, int icon, String description, int sizeDp, int gravity) { ImageView v = new ImageView(this); v.setImageResource(icon); v.setContentDescription(description); v.setVisibility(View.GONE); int s = Ui.dp(this, sizeDp), m = Ui.dp(this, 24); FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(s, s, gravity); lp.setMargins(m, m, m, m); root.addView(v, lp); return v; } private Post post() { return Playlist.posts.get(index); } private Post.Media media() { return post().media.get(slide); } private void show(int i) { index = i; slide = 0; maybeLoadMore(); showSlide(); } private void showSlide() { Post.Media m = media(); int gen = ++generation; paused = false; failed = false; photoElapsed = 0; message.setText(""); pauseIcon.setVisibility(View.GONE); heart.setVisibility(Likes.has(post().id) ? View.VISIBLE : View.GONE); Glide.with(this).load(m.video ? m.thumbnailUrl : m.previewUrl).override(200).into(backdrop); if (m.video) { Glide.with(this).clear(photo); video.setVisibility(View.VISIBLE); player.setMediaItem(MediaItem.fromUri(m.previewUrl)); player.prepare(); player.play(); randomFrame(m, gen); } else { player.stop(); video.setVisibility(View.GONE); Glide.with(this).load(m.previewUrl).listener(new RequestListener() { @Override public boolean onLoadFailed(GlideException e, Object model, Target target, boolean first) { if (gen == generation) fail(); return false; } @Override public boolean onResourceReady(Drawable d, Object model, Target target, DataSource source, boolean first) { return false; } }).into(photo); } tick(); } /** Swaps the video backdrop for a frame from 20-80 % of the clip; ends are often black. */ private void randomFrame(Post.Media m, int gen) { if (m.durationMs <= 0) return; long us = (long) (m.durationMs * 1000 * (0.2 + 0.6 * ThreadLocalRandom.current().nextDouble())); Api.async(FRAMES, () -> { if (gen != generation) return null; try (MediaMetadataRetriever r = new MediaMetadataRetriever()) { r.setDataSource(m.previewUrl, new HashMap<>(Api.cookieHeaders(m.previewUrl))); return r.getScaledFrameAtTime(us, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, 320, 180); } }, frame -> { if (frame != null && gen == generation) { Glide.with(this).clear(backdrop); backdrop.setImageBitmap(frame); } }, e -> { // keep the blurred server thumbnail }); } /** Drives the progress bar and the photo timer. */ private void tick() { handler.removeCallbacks(ticker); if (media().video && !failed) { long d = player.getDuration(); progress.setProgress(d > 0 ? (int) (1000 * player.getCurrentPosition() / d) : 0); } else { if (!paused) photoElapsed += TICK_MS; long total = Prefs.photoSeconds() * 1000L; progress.setProgress((int) (1000 * Math.min(photoElapsed, total) / total)); if (photoElapsed >= total) { advance(); return; } } handler.postDelayed(ticker, TICK_MS); } private void advance() { if (slide < post().media.size() - 1) { slide++; showSlide(); } else if (index < Playlist.posts.size() - 1) { show(index + 1); } else { setPaused(true); // end of the list } } private void fail() { failed = true; photoElapsed = 0; message.setText("Cannot load this post"); if (media().video) player.stop(); } private void setPaused(boolean p) { paused = p; pauseIcon.setVisibility(p ? View.VISIBLE : View.GONE); if (media().video && !failed) { if (p) player.pause(); else player.play(); } } private void step(int dir) { if (post().media.size() > 1) { int s = slide + dir; if (s >= 0 && s < post().media.size()) { slide = s; showSlide(); } } else if (media().video && !failed) { player.seekTo(Math.max(0, player.getCurrentPosition() + dir * SEEK_MS)); } } private void toggleLike() { Post p = post(); boolean on = !Likes.has(p.id); Likes.set(p.id, on); heart.setVisibility(on ? View.VISIBLE : View.GONE); if (on) { bigHeart.setAlpha(1f); bigHeart.setVisibility(View.VISIBLE); bigHeart.animate().alpha(0f).setDuration(700).withEndAction(() -> bigHeart.setVisibility(View.GONE)).start(); } Api.async(() -> { Api.setLike(p.id, on); return true; }, ok -> { }, e -> { Likes.set(p.id, !on); if (!Playlist.posts.isEmpty() && post() == p) heart.setVisibility(on ? View.GONE : View.VISIBLE); Toast.makeText(this, "Like failed: " + e.getMessage(), Toast.LENGTH_SHORT).show(); }); } /** Reels only: fetches the next page when three posts remain. */ private void maybeLoadMore() { if (Playlist.reelsSeed == 0 || loadingMore || index < Playlist.posts.size() - 3) return; loadingMore = true; int page = Playlist.reelsPage + 1; Api.async(() -> Api.reels(Playlist.reelsSeed, page), o -> { loadingMore = false; Playlist.reelsPage = page; boolean first = Playlist.posts.isEmpty(); Playlist.posts.addAll(Post.list(o, Prefs.server())); if (!o.optBoolean("hasMore")) Playlist.reelsSeed = 0; if (first && !Playlist.posts.isEmpty()) show(0); }, e -> { loadingMore = false; if (Playlist.posts.isEmpty()) message.setText("Cannot load reels. Press Down to retry."); else Toast.makeText(this, "Cannot load more: " + e.getMessage(), Toast.LENGTH_SHORT).show(); }); } @Override public boolean onKeyDown(int code, KeyEvent e) { if (Playlist.posts.isEmpty()) { if (code == KeyEvent.KEYCODE_DPAD_DOWN) maybeLoadMore(); return super.onKeyDown(code, e); } switch (code) { case KeyEvent.KEYCODE_DPAD_UP: if (index > 0) show(index - 1); return true; case KeyEvent.KEYCODE_DPAD_DOWN: if (index < Playlist.posts.size() - 1) show(index + 1); else maybeLoadMore(); // retry a failed page return true; case KeyEvent.KEYCODE_DPAD_LEFT: step(-1); return true; case KeyEvent.KEYCODE_DPAD_RIGHT: step(1); return true; case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_ENTER: if (e.getRepeatCount() > 0) return true; // Pause at once; a second press undoes it and likes instead. setPaused(!paused); if (doubleOk.press(e.getEventTime())) toggleLike(); return true; default: return super.onKeyDown(code, e); } } @Override protected void onPause() { super.onPause(); if (!Playlist.posts.isEmpty() && !paused) setPaused(true); } @Override protected void onDestroy() { super.onDestroy(); handler.removeCallbacksAndMessages(null); generation++; player.release(); } } ``` - [ ] **Step 2: Build** Run: `./build.sh test && ./build.sh` Expected: tests exit 0; APK listed. - [ ] **Step 3: Commit** ```bash git add app/src git commit -m "Add fullscreen viewer with blurred backdrop, auto-advance and double-OK likes" ``` --- ### Task 7: Settings **Files:** - Modify: `app/src/main/java/mygram/droid/SettingsActivity.java` (replace stub) - [ ] **Step 1: Replace `SettingsActivity.java`** ```java package mygram.droid; import android.app.Activity; import android.content.Intent; import android.os.Bundle; import android.text.InputType; import android.widget.EditText; import android.widget.LinearLayout; import android.widget.Toast; /** Viewer password, photo and background durations, change instance. */ public final class SettingsActivity extends Activity { @Override protected void onCreate(Bundle state) { super.onCreate(state); LinearLayout box = Ui.column(this); box.addView(Ui.text(this, Prefs.server(), 18)); box.addView(Ui.text(this, "Viewer password", 14)); EditText pw = Ui.field(this, "Password", Ui.password()); pw.setText(Prefs.password()); box.addView(pw); box.addView(Ui.text(this, "Photo duration, seconds", 14)); EditText photo = Ui.field(this, "8", InputType.TYPE_CLASS_NUMBER); photo.setText(String.valueOf(Prefs.photoSeconds())); box.addView(photo); box.addView(Ui.text(this, "Background change, seconds", 14)); EditText bg = Ui.field(this, "15", InputType.TYPE_CLASS_NUMBER); bg.setText(String.valueOf(Prefs.backgroundSeconds())); box.addView(bg); LinearLayout row = Ui.row(this); row.addView(Ui.iconButton(this, R.drawable.ic_check, "Save", v -> save(pw.getText().toString(), photo.getText().toString(), bg.getText().toString()))); row.addView(Ui.iconButton(this, R.drawable.ic_swap, "Change instance", v -> startActivity(new Intent(this, SetupActivity.class)))); box.addView(row); setContentView(box); row.getChildAt(0).requestFocus(); } private void save(String password, String photo, String background) { int p = seconds(photo), b = seconds(background); if (p < 1 || b < 1) { Toast.makeText(this, "Durations must be 1 to 600 seconds", Toast.LENGTH_LONG).show(); return; } Prefs.setTimes(p, b); if (password.equals(Prefs.password())) { finish(); return; } Api.async(() -> { Api.login(Prefs.server(), password); return true; }, ok -> { Prefs.setPassword(password); finish(); }, e -> Toast.makeText(this, "Login failed: " + e.getMessage(), Toast.LENGTH_LONG).show()); } private static int seconds(String s) { try { int v = Integer.parseInt(s.trim()); return v <= 600 ? v : -1; } catch (NumberFormatException e) { return -1; } } } ``` - [ ] **Step 2: Build** Run: `./build.sh test && ./build.sh` Expected: tests exit 0; APK listed. - [ ] **Step 3: Commit** ```bash git add app/src git commit -m "Add settings screen" ``` --- ### Task 8: Docs **Files:** - Modify: `README.md`, `AGENTS.md` - [ ] **Step 1: README** Replace the `## Status` section and add a build section before `## License`: ```markdown ## Status Early development. Builds and runs on a Chromecast with Google TV HD. ## Build and install Requires Docker and adb on the host; nothing else is installed. ./build.sh # debug APK in app/build/outputs/apk/debug/ ./build.sh test # JVM unit tests ./deploy.sh # build, install on the TV over wireless adb, launch On first launch the app scans the local network for Foldergram on port 4141, or takes a URL, then asks for the viewer password if the instance has one. ## Credits Icons: [Material Icons](https://fonts.google.com/icons), Apache License 2.0. ``` - [ ] **Step 2: AGENTS.md** Replace the `## Testing` section with: ```markdown ## Building and testing - `./build.sh` builds the debug APK in Docker (image `mygram-droid-build`), `./build.sh test` runs the JVM unit tests (`LogicTest`), `./deploy.sh` installs on the TV and launches. - Package `mygram.droid`. UI is built in code, no layout XML. - Verify every UI change on the TV (see Target device). - For API probes use a cookie jar in a scratch directory and the viewer password from `~/.config/mygram-droid/viewer.pw` (outside the repo). ``` - [ ] **Step 3: Commit** ```bash git add README.md AGENTS.md git commit -m "Document build, deploy and test commands" ``` --- ### Task 9: On-device verification Device changes beyond install and launch are out of scope (see AGENTS.md). Ask the user to enable wireless debugging if `deploy.sh` reports no device. - [ ] **Step 1: Deploy** Run: `./deploy.sh` Expected: `Success` from `adb install`, then `deployed to `. - [ ] **Step 2: Setup flow** ```bash adb shell input keyevent KEYCODE_WAKEUP adb shell pm clear mygram.droid && adb shell am start -n mygram.droid/.MainActivity sleep 8; adb exec-out screencap -p > "$S/setup.png" ``` Expected (look at the screenshot): "Choose an instance" with one entry for the instance's IP, plus Rescan and Enter URL icons. Then OK on the entry; expect the password prompt. Type it without echoing: ```bash adb shell input keyevent KEYCODE_DPAD_CENTER; sleep 2 adb shell input text "$(cat ~/.config/mygram-droid/viewer.pw)" adb shell input keyevent KEYCODE_DPAD_DOWN KEYCODE_DPAD_CENTER sleep 6; adb exec-out screencap -p > "$S/home.png" ``` (Passwords with spaces or shell metacharacters need `%s` for spaces; ask the user to type it on the remote instead if `input text` mangles it.) Expected: Home with top bar (logo left, three icons centered, settings right), folder rows with thumbnails and a Continue tile, blurred background. - [ ] **Step 3: Viewer** Navigate: `KEYCODE_DPAD_DOWN` into the first row, `KEYCODE_DPAD_CENTER` on a tile. Screenshot after 3 s. Expected: item centered over a blurred backdrop, progress bar at the bottom moving. Check in turn, with a screenshot each: - `KEYCODE_DPAD_CENTER` once: pause icon at bottom center, bar frozen. - `KEYCODE_DPAD_CENTER KEYCODE_DPAD_CENTER` in one `input keyevent` call: big heart flashes, small heart bottom right, playback continues. - `KEYCODE_DPAD_DOWN`: next post; `KEYCODE_DPAD_UP`: back. - Wait photo duration + 1 s on a photo: auto-advance. - `KEYCODE_BACK`: back to Home; the liked tile shows a heart. - [ ] **Step 4: Tabs** - Reels icon: viewer opens on a video, Down pages on past 12 items. - Liked icon: grid contains the post liked in Step 3. Unlike it in the viewer (double OK), Back: it is gone. - Folders icon: avatar grid; OK opens a shuffled folder grid. - Settings icon: fields show 8 and 15; change photo to 4, Save, check a photo advances after 4 s. - [ ] **Step 5: Report** Tell the user what passed and what did not, with the screenshots' paths. Fix failures in the relevant task's file, rebuild with `./deploy.sh`, re-check, and commit each fix separately. --- ## Self-review notes - Spec coverage: discovery and password (Task 4), top bar with runtime logo (Task 5), Home rows, background, Continue (Task 5), Folders and folder grid (Task 5), Liked (Task 5), Reels (Tasks 3 and 6), viewer remote mapping, auto-advance, progress bar, pause icon, hearts, double OK, random-frame backdrop (Task 6), Settings (Task 7), errors (Tasks 4 to 7), unit test (Task 2), on-device checks (Task 9). - Type names used across tasks: `Api.Folder`, `Api.HttpError`, `Api.async`, `Api.call`, `Post.Media`, `Post.list`, `Post.str`, `TileAdapter.Tile`, `TileAdapter.post`, `Ui.POST_W/POST_H`, `Ui.postTiles`, `Ui.grid`, `Ui.list`, `Ui.iconButton`, `Playlist.open/openReels/reelsSeed/reelsPage`, `HomeRows.refreshHearts`, `FolderActivity.open`.