Skip to content

Commit 7480ec7

Browse files
authored
Merge pull request #35 from bpmct/feature/showcase-mode
add showcase mode
2 parents 6409dce + ca41a4f commit 7480ec7

10 files changed

Lines changed: 1674 additions & 103 deletions

AGENTS.md

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,58 @@ docker exec <container_id> bash -c "<command>"
8484

8585
## Worktrees
8686

87-
Active development happens in the worktree at `/home/coder/trmnl-nook-sleep` on branch `feature/tap-menu-sleep`. The main checkout is at `/home/coder/trmnl-nook-simple-touch`.
87+
Active development happens in the worktree at `/home/coder/trmnl-nook-showcase` on branch `feature/showcase-mode`. The main checkout is at `/home/coder/trmnl-nook-simple-touch`.
8888

89-
> **CRITICAL for agents:** The devcontainer mounts `/home/coder/trmnl-nook-sleep` as `/workspace`.
90-
> All source edits MUST be made to files under `/home/coder/trmnl-nook-sleep/` (the worktree).
91-
> Editing `/home/coder/trmnl-nook-simple-touch/` (the main checkout) has NO effect on builds.
89+
> **CRITICAL for agents:** The devcontainer mounts `/home/coder/trmnl-nook` as `/workspace`.
90+
> All source edits MUST be made to files under the active worktree (e.g. `/home/coder/trmnl-nook-showcase/`).
91+
> Editing the main checkout directly has NO effect on builds.
9292
> Always verify with: `docker inspect <container_id> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'`
93+
94+
---
95+
96+
## RotateLayout Coordinate Geometry
97+
98+
The physical screen is **800px wide × 600px tall** (landscape, hardware mounted sideways). `RotateLayout` applies a 90° rotation to all UI content so it renders correctly.
99+
100+
### Transform mechanics
101+
102+
`dispatchDraw` applies:
103+
```java
104+
canvas.translate(getWidth(), 0); // getWidth() = 800
105+
canvas.rotate(90);
106+
```
107+
108+
A point at child coordinates `(cx, cy)` maps to physical coordinates:
109+
```
110+
physical_x = 800 - cy
111+
physical_y = cx
112+
```
113+
114+
- **child-X axis → physical-Y axis** (vertical on screen)
115+
- **child-Y axis → physical-X axis** (horizontal on screen, inverted)
116+
117+
### Root child dimensions
118+
119+
`RotateLayout.onMeasure` swaps width/height specs for 90°, so the `root` FrameLayout child thinks it is **800px wide × 600px tall** even though it visually renders as 600px wide × 800px tall on the physical screen.
120+
121+
| `root` field | Value | Physical meaning |
122+
|---|---|---|
123+
| `root.getWidth()` | 800 | physical height |
124+
| `root.getHeight()` | 600 | physical width |
125+
126+
### Menu bar layout rules
127+
128+
The menu is a **HORIZONTAL** `LinearLayout` inside `root`.
129+
130+
- **Width**: fixed value less than 800 (currently `480`) with `Gravity.CENTER` — gives a floating centred bar. Do **not** use `MATCH_PARENT` (that gives 800px = full physical height). Do **not** use 600 (that is `root.getHeight()` = physical width).
131+
- **Height**: `WRAP_CONTENT` (~40px)
132+
- **Buttons**: `new LinearLayout.LayoutParams(0, 40, 1.0f)``width=0, weight=1` distributes evenly across child-X (= physical-Y); `height=40` sets bar thickness in child-Y (= physical-X).
133+
- `Button.setMinWidth(0)` + `Button.setMinimumWidth(0)` are **required** — Android's Button has a built-in minimum width that breaks weight distribution without them.
134+
135+
### Re-show clipping fix
136+
137+
After `menuLayout.setVisibility(GONE)``VISIBLE`, Android may skip re-measuring since params haven't changed. `requestLayout()` alone is unreliable. The fix: call `setLayoutParams()` with the desired params **every time the menu is shown** — this forces a re-measure and correct `Gravity.CENTER` positioning.
138+
139+
### `dispatchDraw` canvas state
140+
141+
`RotateLayout.dispatchDraw` must call `canvas.save()` / `canvas.restoreToCount()` around the transform. Without it the canvas state leaks to subsequent draw passes, causing right-side EPD ghosting.

AndroidManifest.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@
4646
android:configChanges="orientation|keyboardHidden|screenSize"
4747
android:label="Gift Mode Settings"
4848
android:theme="@style/FullscreenTheme" />
49+
<activity
50+
android:name=".ShowcaseSettingsActivity"
51+
android:configChanges="orientation|keyboardHidden|screenSize"
52+
android:label="Showcase Settings"
53+
android:theme="@style/FullscreenTheme" />
54+
<activity
55+
android:name=".ShowcaseActivity"
56+
android:configChanges="orientation|keyboardHidden|screenSize"
57+
android:label="Showcase"
58+
android:theme="@style/FullscreenTheme" />
4959
</application>
5060

5161
</manifest>

src/com/bpmct/trmnl_nook_simple_touch/ApiPrefs.java

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public class ApiPrefs {
2222
private static final String KEY_AUTO_DISABLE_WIFI = "auto_disable_wifi";
2323
private static final String KEY_SUPER_SLEEP = "super_sleep";
2424
private static final String KEY_SCREENSAVER_WRITTEN = "screensaver_written_once";
25+
private static final String KEY_SHOWCASE_MODE = "showcase_mode";
2526
private static final String SCREENSAVER_PATH = "/media/screensavers/TRMNL/display.png";
2627

2728
public static boolean hasCredentials(Context context) {
@@ -226,6 +227,109 @@ public static void setAutoDisableWifi(Context context, boolean enabled) {
226227
.putBoolean(KEY_AUTO_DISABLE_WIFI, enabled).commit();
227228
}
228229

230+
// ---------------------------------------------------------------------------
231+
// Showcase cell credentials (4 independent cells)
232+
// Each cell has its own token, optional device ID, and optional API base URL.
233+
// Keys: showcase_cell_token_0..3, showcase_cell_id_0..3, showcase_cell_url_0..3
234+
// ---------------------------------------------------------------------------
235+
236+
/** @deprecated Use getShowcaseCellToken(ctx, 0) — kept for SharedPrefs backwards compat */
237+
public static String getShowcaseDeviceId(Context context, int index) {
238+
// Old 2-device keys — map device 0→cell 0, device 1→cell 1 for backwards compat
239+
return getShowcaseCellId(context, index);
240+
}
241+
/** @deprecated Use setShowcaseCellToken */
242+
public static void setShowcaseDeviceId(Context context, int index, String id) {
243+
setShowcaseCellId(context, index, id);
244+
}
245+
/** @deprecated Use getShowcaseCellToken */
246+
public static String getShowcaseDeviceToken(Context context, int index) {
247+
return getShowcaseCellToken(context, index);
248+
}
249+
/** @deprecated Use setShowcaseCellToken */
250+
public static void setShowcaseDeviceToken(Context context, int index, String token) {
251+
setShowcaseCellToken(context, index, token);
252+
}
253+
254+
public static String getShowcaseCellId(Context context, int cellIdx) {
255+
String key = "showcase_cell_id_" + cellIdx;
256+
// Fall back to old key for cell 0 and 1 (migration)
257+
if (cellIdx == 0 || cellIdx == 1) {
258+
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
259+
if (!prefs.contains(key)) {
260+
String oldKey = "showcase_device_id_" + cellIdx;
261+
String oldVal = prefs.getString(oldKey, "");
262+
if (oldVal != null && oldVal.trim().length() > 0) return oldVal.trim();
263+
}
264+
}
265+
String v = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getString(key, "");
266+
return (v != null) ? v.trim() : "";
267+
}
268+
269+
public static void setShowcaseCellId(Context context, int cellIdx, String id) {
270+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
271+
.putString("showcase_cell_id_" + cellIdx, id != null ? id.trim() : "").commit();
272+
}
273+
274+
public static String getShowcaseCellToken(Context context, int cellIdx) {
275+
String key = "showcase_cell_token_" + cellIdx;
276+
// Fall back to old key for cell 0 and 1 (migration)
277+
if (cellIdx == 0 || cellIdx == 1) {
278+
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
279+
if (!prefs.contains(key)) {
280+
String oldKey = "showcase_device_token_" + cellIdx;
281+
String oldVal = prefs.getString(oldKey, "");
282+
if (oldVal != null && oldVal.trim().length() > 0) return oldVal.trim();
283+
}
284+
}
285+
String v = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getString(key, "");
286+
return (v != null) ? v.trim() : "";
287+
}
288+
289+
public static void setShowcaseCellToken(Context context, int cellIdx, String token) {
290+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
291+
.putString("showcase_cell_token_" + cellIdx, token != null ? token.trim() : "").commit();
292+
}
293+
294+
/** Per-cell API base URL. Empty string means use the global default (usetrmnl.com). */
295+
public static String getShowcaseCellUrl(Context context, int cellIdx) {
296+
String v = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
297+
.getString("showcase_cell_url_" + cellIdx, "");
298+
return (v != null) ? v.trim() : "";
299+
}
300+
301+
public static void setShowcaseCellUrl(Context context, int cellIdx, String url) {
302+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
303+
.putString("showcase_cell_url_" + cellIdx, url != null ? url.trim() : "").commit();
304+
}
305+
306+
// ---------------------------------------------------------------------------
307+
// Showcase cell names (4 cells, one name each)
308+
// ---------------------------------------------------------------------------
309+
310+
public static String getCellName(Context context, int cellIdx) {
311+
String key = "showcase_cell_name_" + cellIdx;
312+
String v = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getString(key, "");
313+
return (v != null) ? v.trim() : "";
314+
}
315+
316+
public static void setCellName(Context context, int cellIdx, String name) {
317+
String key = "showcase_cell_name_" + cellIdx;
318+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
319+
.putString(key, name != null ? name.trim() : "").commit();
320+
}
321+
322+
public static boolean isShowcaseModeEnabled(Context context) {
323+
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
324+
return prefs.getBoolean(KEY_SHOWCASE_MODE, false);
325+
}
326+
327+
public static void setShowcaseModeEnabled(Context context, boolean enabled) {
328+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
329+
.putBoolean(KEY_SHOWCASE_MODE, enabled).commit();
330+
}
331+
332+
229333
/** Whether to sleep immediately after a new image loads (timer/alarm fetches only). Default false. */
230334
public static boolean isSuperSleep(Context context) {
231335
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);

src/com/bpmct/trmnl_nook_simple_touch/BouncyCastleHttpClient.java

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,12 @@ public ProtocolVersion getMinimumVersion() {
752752

753753
public int[] getCipherSuites() {
754754
return new int[] {
755+
// ECDSA certs (Cloudflare, Let's Encrypt ECDSA)
756+
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
757+
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
758+
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,
759+
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384,
760+
// RSA certs (usetrmnl.com)
755761
CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
756762
CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
757763
};
@@ -782,21 +788,18 @@ public void notifyServerCertificate(TlsServerCertificate serverCertificate) thro
782788
if (allowSelfSigned) {
783789
Log.d(TAG, "BC skipping certificate validation (self-signed allowed)");
784790
return;
785-
}
786-
if (tm == null) {
787-
throw new TlsFatalAlert(AlertDescription.bad_certificate);
791+
788792
}
789793
try {
790794
X509Certificate[] chain = toX509Chain(serverCertificate);
791795
if (chain == null || chain.length == 0) {
792796
throw new TlsFatalAlert(AlertDescription.bad_certificate);
793797
}
794-
String authType = chain[0].getPublicKey().getAlgorithm();
795-
tm.checkServerTrusted(chain, authType);
796-
if (!verifyHostname(hostname, chain[0])) {
797-
Log.e(TAG, "BC hostname verification failed for " + hostname);
798-
throw new TlsFatalAlert(AlertDescription.bad_certificate);
799-
}
798+
// Android 2.1's TrustManagerImpl uses Harmony crypto which
799+
// doesn't support ECDSA signatures (NoSuchAlgorithmException for
800+
// OID 1.2.840.10045.4.3.x). Use spongycastle's PKIX validator
801+
// directly so all signature algorithms are handled correctly.
802+
validateChainWithSpongyCastle(chain, hostname);
800803
Log.d(TAG, "BC server certificate validated against CA bundle");
801804
} catch (TlsFatalAlert e) {
802805
throw e;
@@ -814,6 +817,83 @@ public TlsCredentials getClientCredentials(CertificateRequest certificateRequest
814817
};
815818
}
816819

820+
/**
821+
* Validate a server certificate chain using spongycastle's PKIX engine.
822+
* Android 2.1's built-in TrustManagerImpl (Harmony) cannot verify ECDSA
823+
* certificate signatures (NoSuchAlgorithmException for ecdsa-with-SHA384).
824+
* We load the CA bundle and re-parse the server chain entirely through
825+
* spongycastle so all crypto (including sig verification) uses BC.
826+
*/
827+
private static void validateChainWithSpongyCastle(
828+
X509Certificate[] chain, String hostname)
829+
throws java.io.IOException {
830+
try {
831+
java.security.Provider bcProv = getBcProvider();
832+
if (bcProv == null) {
833+
throw new TlsFatalAlert(AlertDescription.internal_error);
834+
}
835+
CertificateFactory bcCf = CertificateFactory.getInstance("X.509", bcProv);
836+
837+
// Re-parse server chain through BC so sig verification uses BC crypto
838+
java.util.List bcChain = new java.util.ArrayList();
839+
for (int i = 0; i < chain.length; i++) {
840+
ByteArrayInputStream bais = new ByteArrayInputStream(chain[i].getEncoded());
841+
bcChain.add((X509Certificate) bcCf.generateCertificate(bais));
842+
}
843+
844+
// Load CA bundle through BC
845+
byte[] caBytes = getBcCaBundleBytes();
846+
if (caBytes == null || caBytes.length == 0) {
847+
throw new TlsFatalAlert(AlertDescription.bad_certificate);
848+
}
849+
Collection caCerts = bcCf.generateCertificates(new ByteArrayInputStream(caBytes));
850+
java.util.Set anchors = new java.util.HashSet();
851+
java.util.Iterator it = caCerts.iterator();
852+
while (it.hasNext()) {
853+
anchors.add(new java.security.cert.TrustAnchor(
854+
(X509Certificate) it.next(), null));
855+
}
856+
857+
java.security.cert.CertPath cp = bcCf.generateCertPath(bcChain);
858+
java.security.cert.PKIXParameters params =
859+
new java.security.cert.PKIXParameters(anchors);
860+
params.setRevocationEnabled(false);
861+
java.security.cert.CertPathValidator cpv =
862+
java.security.cert.CertPathValidator.getInstance("PKIX", bcProv);
863+
cpv.validate(cp, params);
864+
} catch (TlsFatalAlert e) {
865+
throw e;
866+
} catch (Throwable t) {
867+
Log.e(TAG, "BC PKIX chain validation failed: " + t);
868+
throw new TlsFatalAlert(AlertDescription.bad_certificate);
869+
}
870+
if (!verifyHostname(hostname, chain[0])) {
871+
Log.e(TAG, "BC hostname verification failed for " + hostname);
872+
throw new TlsFatalAlert(AlertDescription.bad_certificate);
873+
}
874+
}
875+
876+
/** Raw bytes of ca_bundle.pem, populated when the bundle is first loaded. */
877+
private static byte[] bcCaBundleBytes = null;
878+
879+
private static synchronized byte[] getBcCaBundleBytes() {
880+
return bcCaBundleBytes;
881+
}
882+
883+
private static java.security.Provider bcProviderInstance = null;
884+
private static synchronized java.security.Provider getBcProvider() {
885+
if (bcProviderInstance == null) {
886+
try {
887+
bcProviderInstance = (java.security.Provider)
888+
Class.forName("org.spongycastle.jce.provider.BouncyCastleProvider")
889+
.newInstance();
890+
} catch (Throwable t) {
891+
Log.e(TAG, "Could not instantiate BouncyCastleProvider", t);
892+
}
893+
}
894+
return bcProviderInstance;
895+
}
896+
817897
private static synchronized X509TrustManager getTrustManager(Context context) {
818898
if (trustManager != null) {
819899
return trustManager;
@@ -825,6 +905,18 @@ private static synchronized X509TrustManager getTrustManager(Context context) {
825905
InputStream is = null;
826906
try {
827907
is = context.getResources().openRawResource(R.raw.ca_bundle);
908+
// Cache raw bytes for spongycastle PKIX validator (which needs to re-parse
909+
// the bundle through BC's own CertificateFactory to support ECDSA)
910+
if (bcCaBundleBytes == null) {
911+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
912+
byte[] buf = new byte[4096];
913+
int n;
914+
while ((n = is.read(buf)) != -1) baos.write(buf, 0, n);
915+
bcCaBundleBytes = baos.toByteArray();
916+
Log.d(TAG, "BC CA bundle cached: " + bcCaBundleBytes.length + " bytes");
917+
is.close();
918+
is = new ByteArrayInputStream(bcCaBundleBytes);
919+
}
828920
CertificateFactory cf = CertificateFactory.getInstance("X.509");
829921
Collection certs = cf.generateCertificates(is);
830922
if (certs == null || certs.size() == 0) {

src/com/bpmct/trmnl_nook_simple_touch/CredentialsActivity.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,9 @@ public void onClick(View v) {
191191
}
192192
ApiPrefs.saveCredentials(CredentialsActivity.this, id, token);
193193
ApiPrefs.saveApiBaseUrl(CredentialsActivity.this, baseUrl);
194-
// Auto-disable gift mode when credentials are saved
194+
// Auto-disable gift/showcase mode when credentials are saved
195195
ApiPrefs.setGiftModeEnabled(CredentialsActivity.this, false);
196+
ApiPrefs.setShowcaseModeEnabled(CredentialsActivity.this, false);
196197
statusView.setText("Saved.");
197198
android.content.Intent intent = new android.content.Intent(CredentialsActivity.this, DisplayActivity.class);
198199
intent.putExtra(DisplayActivity.EXTRA_CLEAR_IMAGE, true);

0 commit comments

Comments
 (0)