aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-22 11:00:50 +0200
committerDanilo M. <danix@danix.xyz>2026-10-22 11:00:50 +0200
commite31f3a60ac17d4be1724493304bc1084dcf3a7a2 (patch)
tree2075b7b64838ee0eb00adb79989a8c3688a95e39
parentef9937073deceb9fee8fabe64702c2a4d5629056 (diff)
downloadmygram-droid-e31f3a60ac17d4be1724493304bc1084dcf3a7a2.tar.gz
mygram-droid-e31f3a60ac17d4be1724493304bc1084dcf3a7a2.zip
Add implementation plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-10-22-mygram-droid.md2616
1 files changed, 2616 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-22-mygram-droid.md b/docs/superpowers/plans/2026-10-22-mygram-droid.md
new file mode 100644
index 0000000..ebe1b2b
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-22-mygram-droid.md
@@ -0,0 +1,2616 @@
+# 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=<scratchpad>; B=<instance base url>
+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. <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.
+ */
+```
+
+- [ ] **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. <danix@danix.xyz>
+# 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. <danix@danix.xyz>
+// 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. <danix@danix.xyz>
+// 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. <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.
+
+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. <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.
+
+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
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ Licensed under the GNU General Public License version 2 only. -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+ <uses-feature android:name="android.software.leanback" android:required="true" />
+ <uses-feature android:name="android.hardware.touchscreen" android:required="false" />
+
+ <!-- Cleartext: Foldergram runs on the LAN over plain HTTP.
+ No backup: the viewer password lives in app preferences. -->
+ <application
+ android:name=".App"
+ android:allowBackup="false"
+ android:banner="@drawable/banner"
+ android:icon="@drawable/ic_folder"
+ android:label="mygram"
+ android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
+ android:usesCleartextTraffic="true">
+ <activity android:name=".MainActivity" android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ <activity android:name=".SetupActivity" />
+ <activity android:name=".SettingsActivity" />
+ <activity android:name=".FolderActivity" />
+ <activity android:name=".ViewerActivity" />
+ </application>
+</manifest>
+```
+
+- [ ] **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
+<!-- Material Icons "$1", Apache License 2.0 -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp" android:height="24dp"
+ android:viewportWidth="24" android:viewportHeight="24">
+ <path android:fillColor="#FFFFFFFF" android:pathData="$2" />
+</vector>
+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
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ Licensed under the GNU General Public License version 2 only. -->
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:state_focused="true">
+ <shape>
+ <corners android:radius="8dp" />
+ <stroke android:width="3dp" android:color="#FFFFFFFF" />
+ </shape>
+ </item>
+ <item>
+ <shape>
+ <corners android:radius="8dp" />
+ <solid android:color="#00000000" />
+ </shape>
+ </item>
+</selector>
+```
+
+`app/src/main/res/drawable/banner.xml`:
+
+```xml
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ Licensed under the GNU General Public License version 2 only. -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+ <item>
+ <shape>
+ <solid android:color="#FF4F46E5" />
+ <size android:width="320dp" android:height="180dp" />
+ </shape>
+ </item>
+ <item android:drawable="@drawable/ic_folder" android:gravity="center"
+ android:width="96dp" android:height="96dp" />
+</layer-list>
+```
+
+- [ ] **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<Post> 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("<html>router login</html>"));
+ }
+
+ @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> 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<Post> list(JSONObject page, String base) {
+ List<Post> 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 <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()));
+ 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());
+ }
+
+ 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<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));
+ }
+}
+```
+
+- [ ] **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<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);
+ }
+}
+```
+
+- [ ] **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<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));
+ }
+}
+```
+
+- [ ] **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<String> scan(String self) throws InterruptedException {
+ String prefix = self.substring(0, self.lastIndexOf('.') + 1);
+ ExecutorService pool = Executors.newFixedThreadPool(64);
+ List<String> 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<String> 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<TileAdapter.Tile> 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<TileAdapter.Tile> postTiles(Context c, List<Post> posts) {
+ List<TileAdapter.Tile> 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<String, String> 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<String, String> instances) {
+ reset(instances.isEmpty() ? "No Foldergram found on port " + Discovery.PORT : "Choose an instance");
+ for (Map.Entry<String, String> 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<TileAdapter.Holder> {
+ 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<Tile> tiles;
+ private final int widthDp, heightDp;
+
+ TileAdapter(List<Tile> 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<HomeRows.Row> {
+ 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<Api.Folder> folders;
+ private final Map<String, List<Post>> samples = new HashMap<>();
+
+ HomeRows(Activity activity, List<Api.Folder> 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<Post> 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<TileAdapter.Tile> 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<Post> 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<TileAdapter.Tile> 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<Drawable>() {
+ @Override
+ public boolean onLoadFailed(GlideException e, Object model, Target<Drawable> target, boolean first) {
+ return false;
+ }
+
+ @Override
+ public boolean onResourceReady(Drawable d, Object model, Target<Drawable> 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<Drawable>() {
+ @Override
+ public boolean onLoadFailed(GlideException e, Object model, Target<Drawable> target, boolean first) {
+ if (gen == generation)
+ fail();
+ return false;
+ }
+
+ @Override
+ public boolean onResourceReady(Drawable d, Object model, Target<Drawable> 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 <device>`.
+
+- [ ] **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`.