diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-22 11:00:50 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-22 11:00:50 +0200 |
| commit | e31f3a60ac17d4be1724493304bc1084dcf3a7a2 (patch) | |
| tree | 2075b7b64838ee0eb00adb79989a8c3688a95e39 | |
| parent | ef9937073deceb9fee8fabe64702c2a4d5629056 (diff) | |
| download | mygram-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.md | 2616 |
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`. |
