diff options
Diffstat (limited to 'app/src/main/java')
| -rw-r--r-- | app/src/main/java/mygram/droid/Discovery.java | 66 | ||||
| -rw-r--r-- | app/src/main/java/mygram/droid/SetupActivity.java | 106 | ||||
| -rw-r--r-- | app/src/main/java/mygram/droid/TileAdapter.java | 126 | ||||
| -rw-r--r-- | app/src/main/java/mygram/droid/Ui.java | 155 |
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; + } +} |
