aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app/src/main/AndroidManifest.xml1
-rw-r--r--app/src/main/java/mygram/droid/App.java44
-rw-r--r--app/src/main/java/mygram/droid/FolderActivity.java12
-rw-r--r--app/src/main/java/mygram/droid/LockActivity.java63
-rw-r--r--app/src/main/java/mygram/droid/Prefs.java47
-rw-r--r--app/src/main/java/mygram/droid/SettingsActivity.java55
-rw-r--r--app/src/main/java/mygram/droid/Ui.java14
-rw-r--r--app/src/main/res/drawable/focus_ring.xml2
-rw-r--r--app/src/main/res/drawable/ic_lock_open.xml6
-rw-r--r--app/src/test/java/mygram/droid/LogicTest.java19
-rw-r--r--docs/superpowers/specs/2026-10-22-mygram-droid-design.md12
11 files changed, 256 insertions, 19 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 41db33b..39f2033 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,5 +29,6 @@
<activity android:name=".SettingsActivity" />
<activity android:name=".FolderActivity" />
<activity android:name=".ViewerActivity" />
+ <activity android:name=".LockActivity" />
</application>
</manifest>
diff --git a/app/src/main/java/mygram/droid/App.java b/app/src/main/java/mygram/droid/App.java
index ffbdd8d..1919343 100644
--- a/app/src/main/java/mygram/droid/App.java
+++ b/app/src/main/java/mygram/droid/App.java
@@ -14,19 +14,61 @@
package mygram.droid;
+import android.app.Activity;
import android.app.Application;
+import android.content.Intent;
+import android.os.Bundle;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
-public final class App extends Application {
+public final class App extends Application implements Application.ActivityLifecycleCallbacks {
+ /** Until the PIN is entered; set again whenever the app leaves the screen. */
+ static boolean locked = true;
+ private int started;
+
@Override
public void onCreate() {
super.onCreate();
Prefs.init(this);
+ registerActivityLifecycleCallbacks(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));
}
+
+ /** Before any screen draws, cover it with the PIN prompt if the app is locked. */
+ @Override
+ public void onActivityStarted(Activity a) {
+ started++;
+ if (locked && Prefs.hasPin() && !(a instanceof LockActivity))
+ a.startActivity(new Intent(a, LockActivity.class));
+ }
+
+ @Override
+ public void onActivityStopped(Activity a) {
+ if (--started == 0)
+ locked = true;
+ }
+
+ @Override
+ public void onActivityCreated(Activity a, Bundle state) {
+ }
+
+ @Override
+ public void onActivityResumed(Activity a) {
+ }
+
+ @Override
+ public void onActivityPaused(Activity a) {
+ }
+
+ @Override
+ public void onActivitySaveInstanceState(Activity a, Bundle state) {
+ }
+
+ @Override
+ public void onActivityDestroyed(Activity a) {
+ }
}
diff --git a/app/src/main/java/mygram/droid/FolderActivity.java b/app/src/main/java/mygram/droid/FolderActivity.java
index dd674c4..65ac596 100644
--- a/app/src/main/java/mygram/droid/FolderActivity.java
+++ b/app/src/main/java/mygram/droid/FolderActivity.java
@@ -19,11 +19,9 @@ import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
-import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
-import android.widget.TextView;
import android.widget.Toast;
import androidx.recyclerview.widget.GridLayoutManager;
@@ -52,15 +50,7 @@ public final class FolderActivity extends Activity {
super.onCreate(state);
LinearLayout column = new LinearLayout(this);
column.setOrientation(LinearLayout.VERTICAL);
- LinearLayout header = new LinearLayout(this);
- header.setGravity(Gravity.CENTER_VERTICAL);
- int p = Ui.dp(this, 16);
- header.setPadding(p * 2, p, p * 2, 0);
- header.addView(Ui.iconButton(this, R.drawable.ic_arrow_back, "Back", v -> finish()));
- TextView title = Ui.text(this, getIntent().getStringExtra("name"), 24);
- title.setPadding(p, 0, 0, 0);
- header.addView(title);
- column.addView(header);
+ column.addView(Ui.header(this, getIntent().getStringExtra("name")));
content = new FrameLayout(this);
column.addView(content, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(column);
diff --git a/app/src/main/java/mygram/droid/LockActivity.java b/app/src/main/java/mygram/droid/LockActivity.java
new file mode 100644
index 0000000..dd95915
--- /dev/null
+++ b/app/src/main/java/mygram/droid/LockActivity.java
@@ -0,0 +1,63 @@
+/*
+ * 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.app.Activity;
+import android.os.Bundle;
+import android.text.InputType;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.Toast;
+
+/** Splash with the logo and a PIN prompt; App shows it while the app is locked. */
+public final class LockActivity extends Activity {
+ @Override
+ protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ LinearLayout box = Ui.column(this);
+ ImageView logo = new ImageView(this);
+ logo.setImageResource(R.drawable.ic_launcher);
+ logo.setContentDescription("mygram");
+ int s = Ui.dp(this, 96);
+ LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(s, s);
+ lp.bottomMargin = Ui.dp(this, 24);
+ box.addView(logo, lp);
+ EditText pin = Ui.field(this, "PIN", InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
+ box.addView(pin);
+ View ok = Ui.iconButton(this, R.drawable.ic_check, "Unlock", v -> {
+ if (Prefs.checkPin(pin.getText().toString())) {
+ App.locked = false;
+ finish();
+ } else {
+ pin.setText("");
+ Toast.makeText(this, "Wrong PIN", Toast.LENGTH_SHORT).show();
+ }
+ });
+ box.addView(ok);
+ // The keyboard's done key unlocks; the D-pad cannot leave the keyboard.
+ pin.setOnEditorActionListener((v, action, event) -> ok.performClick());
+ setContentView(box);
+ pin.requestFocus();
+ }
+
+ /** Back leaves the app instead of revealing the screen underneath. */
+ @Override
+ @SuppressWarnings("deprecation") // no predictive back on this TV; onBackPressed still fires
+ public void onBackPressed() {
+ finishAffinity();
+ }
+}
diff --git a/app/src/main/java/mygram/droid/Prefs.java b/app/src/main/java/mygram/droid/Prefs.java
index 33ce878..0ff295d 100644
--- a/app/src/main/java/mygram/droid/Prefs.java
+++ b/app/src/main/java/mygram/droid/Prefs.java
@@ -17,6 +17,11 @@ package mygram.droid;
import android.content.Context;
import android.content.SharedPreferences;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+
/** Settings stored on the device. */
final class Prefs {
private static SharedPreferences prefs;
@@ -57,4 +62,46 @@ final class Prefs {
static void setTimes(int photo, int background) {
prefs.edit().putInt("photo", photo).putInt("background", background).apply();
}
+
+ static boolean validPin(String pin) {
+ return pin.matches("[0-9]{4,8}");
+ }
+
+ static boolean hasPin() {
+ return !prefs.getString("pin", "").isEmpty();
+ }
+
+ /** Stores "salt:sha256(salt + pin)", never the PIN; null removes it. */
+ static void setPin(String pin) {
+ if (pin == null) {
+ prefs.edit().remove("pin").apply();
+ return;
+ }
+ byte[] b = new byte[16];
+ new SecureRandom().nextBytes(b);
+ String salt = hex(b);
+ prefs.edit().putString("pin", salt + ":" + hash(salt, pin)).apply();
+ }
+
+ static boolean checkPin(String pin) {
+ String[] stored = prefs.getString("pin", "").split(":");
+ return stored.length == 2 && MessageDigest.isEqual(stored[1].getBytes(StandardCharsets.UTF_8),
+ hash(stored[0], pin).getBytes(StandardCharsets.UTF_8));
+ }
+
+ // ponytail: one SHA-256 round; a 4-8 digit PIN is a privacy screen, not a secret against someone with the device storage.
+ static String hash(String salt, String pin) {
+ try {
+ return hex(MessageDigest.getInstance("SHA-256").digest((salt + pin).getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e); // every Java platform ships SHA-256
+ }
+ }
+
+ private static String hex(byte[] b) {
+ StringBuilder s = new StringBuilder();
+ for (byte x : b)
+ s.append(String.format("%02x", x));
+ return s.toString();
+ }
}
diff --git a/app/src/main/java/mygram/droid/SettingsActivity.java b/app/src/main/java/mygram/droid/SettingsActivity.java
index a8839bb..3783058 100644
--- a/app/src/main/java/mygram/droid/SettingsActivity.java
+++ b/app/src/main/java/mygram/droid/SettingsActivity.java
@@ -18,12 +18,16 @@ import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.text.InputType;
+import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.LinearLayout;
+import android.widget.ScrollView;
import android.widget.Toast;
-/** Viewer password, photo and background durations, change instance. */
+/** Viewer password, durations, app PIN, change instance. */
public final class SettingsActivity extends Activity {
+ private static final int PIN_INPUT = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD;
+
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
@@ -41,23 +45,58 @@ public final class SettingsActivity extends Activity {
EditText bg = Ui.field(this, "15", InputType.TYPE_CLASS_NUMBER);
bg.setText(String.valueOf(Prefs.backgroundSeconds()));
box.addView(bg);
+ box.addView(Ui.text(this, Prefs.hasPin() ? "New app PIN, 4 to 8 digits (empty keeps it)"
+ : "App PIN, 4 to 8 digits (empty for none)", 14));
+ EditText pin = Ui.field(this, "PIN", PIN_INPUT);
+ box.addView(pin);
+ EditText again = Ui.field(this, "Repeat PIN", PIN_INPUT);
+ box.addView(again);
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())));
+ photo.getText().toString(), bg.getText().toString(), pin.getText().toString(),
+ again.getText().toString())));
+ if (Prefs.hasPin())
+ row.addView(Ui.iconButton(this, R.drawable.ic_lock_open, "Remove PIN", v -> {
+ Prefs.setPin(null);
+ Toast.makeText(this, "PIN removed", Toast.LENGTH_SHORT).show();
+ finish();
+ }));
row.addView(Ui.iconButton(this, R.drawable.ic_swap, "Change instance",
v -> startActivity(new Intent(this, SetupActivity.class))));
box.addView(row);
- setContentView(box);
+
+ // Taller than the screen with the PIN fields; the scroll view follows focus.
+ ScrollView scroll = new ScrollView(this);
+ scroll.addView(box);
+ LinearLayout column = new LinearLayout(this);
+ column.setOrientation(LinearLayout.VERTICAL);
+ column.addView(Ui.header(this, "Settings"));
+ column.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
+ setContentView(column);
row.getChildAt(0).requestFocus();
}
- private void save(String password, String photo, String background) {
+ private void save(String password, String photo, String background, String pin, String again) {
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();
+ toast("Durations must be 1 to 600 seconds");
return;
}
+ if (!pin.isEmpty() || !again.isEmpty()) {
+ if (!Prefs.validPin(pin)) {
+ toast("The PIN must be 4 to 8 digits");
+ return;
+ }
+ if (!pin.equals(again)) {
+ toast("The two PINs differ");
+ return;
+ }
+ }
Prefs.setTimes(p, b);
+ if (!pin.isEmpty()) {
+ Prefs.setPin(pin);
+ App.locked = false; // just typed it; ask from the next time the app leaves the screen
+ }
if (password.equals(Prefs.password())) {
finish();
return;
@@ -68,7 +107,11 @@ public final class SettingsActivity extends Activity {
}, ok -> {
Prefs.setPassword(password);
finish();
- }, e -> Toast.makeText(this, "Login failed: " + e.getMessage(), Toast.LENGTH_LONG).show());
+ }, e -> toast("Login failed: " + e.getMessage()));
+ }
+
+ private void toast(String s) {
+ Toast.makeText(this, s, Toast.LENGTH_LONG).show();
}
private static int seconds(String s) {
diff --git a/app/src/main/java/mygram/droid/Ui.java b/app/src/main/java/mygram/droid/Ui.java
index 234dad3..172fbff 100644
--- a/app/src/main/java/mygram/droid/Ui.java
+++ b/app/src/main/java/mygram/droid/Ui.java
@@ -14,6 +14,7 @@
package mygram.droid;
+import android.app.Activity;
import android.content.Context;
import android.graphics.Color;
import android.graphics.RenderEffect;
@@ -153,6 +154,19 @@ final class Ui {
button.getChildAt(1).setVisibility(current ? View.VISIBLE : View.INVISIBLE);
}
+ /** Back arrow and title, for screens outside the main view. */
+ static LinearLayout header(Activity a, String title) {
+ LinearLayout header = new LinearLayout(a);
+ header.setGravity(Gravity.CENTER_VERTICAL);
+ int p = dp(a, 16);
+ header.setPadding(p * 2, p, p * 2, 0);
+ header.addView(iconButton(a, R.drawable.ic_arrow_back, "Back", v -> a.finish()));
+ TextView t = text(a, title, 24);
+ t.setPadding(p, 0, 0, 0);
+ header.addView(t);
+ return header;
+ }
+
static void blur(ImageView v, float radius) {
v.setRenderEffect(RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP));
}
diff --git a/app/src/main/res/drawable/focus_ring.xml b/app/src/main/res/drawable/focus_ring.xml
index ed924f3..d054c1c 100644
--- a/app/src/main/res/drawable/focus_ring.xml
+++ b/app/src/main/res/drawable/focus_ring.xml
@@ -5,7 +5,7 @@
<item android:state_focused="true">
<shape>
<corners android:radius="8dp" />
- <stroke android:width="2dp" android:color="#B3FFFFFF" />
+ <stroke android:width="3dp" android:color="#FFEC4899" />
</shape>
</item>
<item>
diff --git a/app/src/main/res/drawable/ic_lock_open.xml b/app/src/main/res/drawable/ic_lock_open.xml
new file mode 100644
index 0000000..ddcdda3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lock_open.xml
@@ -0,0 +1,6 @@
+<!-- Material Icons "lock_open", 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="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1V6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2H6c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1 -0.9,-2 -2,-2zM18,20H6V10h12v10z" />
+</vector>
diff --git a/app/src/test/java/mygram/droid/LogicTest.java b/app/src/test/java/mygram/droid/LogicTest.java
index e807725..6fb45aa 100644
--- a/app/src/test/java/mygram/droid/LogicTest.java
+++ b/app/src/test/java/mygram/droid/LogicTest.java
@@ -107,4 +107,23 @@ public class LogicTest {
assertFalse(d.press(2000));
assertFalse("301 ms is too slow", d.press(2301));
}
+
+ @Test
+ public void validatesPins() {
+ assertTrue(Prefs.validPin("1234"));
+ assertTrue(Prefs.validPin("12345678"));
+ assertFalse(Prefs.validPin("123"));
+ assertFalse(Prefs.validPin("123456789"));
+ assertFalse(Prefs.validPin("12a4"));
+ assertFalse(Prefs.validPin(""));
+ }
+
+ @Test
+ public void hashesPinsWithSalt() {
+ String h = Prefs.hash("00ff", "1234");
+ assertEquals(64, h.length());
+ assertEquals(h, Prefs.hash("00ff", "1234"));
+ assertFalse(h.equals(Prefs.hash("00fe", "1234")));
+ assertFalse(h.equals(Prefs.hash("00ff", "1235")));
+ }
}
diff --git a/docs/superpowers/specs/2026-10-22-mygram-droid-design.md b/docs/superpowers/specs/2026-10-22-mygram-droid-design.md
index 39c7d19..4545884 100644
--- a/docs/superpowers/specs/2026-10-22-mygram-droid-design.md
+++ b/docs/superpowers/specs/2026-10-22-mygram-droid-design.md
@@ -139,6 +139,18 @@ videos only, endless, paged with the same seed.
- Viewer password.
- Photo duration (default 8 s).
- Background interval (default 15 s).
+- Optional app PIN, 4 to 8 digits, typed twice; Remove PIN when one is set.
+- Header with a back arrow, like the folder grid.
+
+### PIN lock
+
+- Only when a PIN is set: a splash with the logo and a PIN field covers
+ any screen at app start and whenever the app comes back after leaving
+ the screen (an Application lifecycle counter tracks started activities).
+- Back on the lock screen leaves the app.
+- Stored as salt plus SHA-256, never the PIN. It is a privacy screen, not
+ protection against someone with adb or root on the TV; clearing the
+ app data removes it.
## Architecture