aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-22 11:13:26 +0200
committerDanilo M. <danix@danix.xyz>2026-10-22 11:13:26 +0200
commit9afba17e880840b8d138d1f5906d65afeaf8d5a6 (patch)
tree35696b7ab68a2ec55c4749ecfcf1fed549836446
parentc222cb8e784d65f17ac29433fe51cd4c56e1b261 (diff)
downloadmygram-droid-9afba17e880840b8d138d1f5906d65afeaf8d5a6.tar.gz
mygram-droid-9afba17e880840b8d138d1f5906d65afeaf8d5a6.zip
Add LAN discovery and instance picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--app/src/main/java/mygram/droid/Discovery.java66
-rw-r--r--app/src/main/java/mygram/droid/SetupActivity.java106
-rw-r--r--app/src/main/java/mygram/droid/TileAdapter.java126
-rw-r--r--app/src/main/java/mygram/droid/Ui.java155
4 files changed, 452 insertions, 1 deletions
diff --git a/app/src/main/java/mygram/droid/Discovery.java b/app/src/main/java/mygram/droid/Discovery.java
index 1d3e0be..56777f3 100644
--- a/app/src/main/java/mygram/droid/Discovery.java
+++ b/app/src/main/java/mygram/droid/Discovery.java
@@ -12,14 +12,33 @@
* for more details.
*/
-
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() {
}
@@ -32,4 +51,49 @@ final class Discovery {
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;
+ }
+ }
}
diff --git a/app/src/main/java/mygram/droid/SetupActivity.java b/app/src/main/java/mygram/droid/SetupActivity.java
index ef1f68c..106ec9c 100644
--- a/app/src/main/java/mygram/droid/SetupActivity.java
+++ b/app/src/main/java/mygram/droid/SetupActivity.java
@@ -15,6 +15,112 @@
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));
+ }
}
diff --git a/app/src/main/java/mygram/droid/TileAdapter.java b/app/src/main/java/mygram/droid/TileAdapter.java
new file mode 100644
index 0000000..ee954b6
--- /dev/null
+++ b/app/src/main/java/mygram/droid/TileAdapter.java
@@ -0,0 +1,126 @@
+/*
+ * mygram-droid: Android TV client for Foldergram.
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+
+package mygram.droid;
+
+import android.content.Context;
+import android.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();
+ }
+}
diff --git a/app/src/main/java/mygram/droid/Ui.java b/app/src/main/java/mygram/droid/Ui.java
new file mode 100644
index 0000000..852fe54
--- /dev/null
+++ b/app/src/main/java/mygram/droid/Ui.java
@@ -0,0 +1,155 @@
+/*
+ * mygram-droid: Android TV client for Foldergram.
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+
+package mygram.droid;
+
+import android.content.Context;
+import android.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;
+ }
+}