Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
applicationId "md.zennotes"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 51
versionName "1.1.48"
versionCode 52
versionName "1.1.49"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/java/md/zennotes/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public void onCreate(Bundle savedInstanceState) {
registerPlugin(ImagePastePlugin.class);
registerPlugin(EdgeSwipePlugin.class);
registerPlugin(OpenFilePlugin.class);
registerPlugin(SystemBarThemePlugin.class);
super.onCreate(savedInstanceState);
// Cold-start share: the launch intent IS the share. Stash it now; the
// WebView drains the inbox after the vault opens (importPendingShares).
Expand Down
96 changes: 96 additions & 0 deletions android/app/src/main/java/md/zennotes/SystemBarThemePlugin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package md.zennotes;

import android.content.res.Configuration;
import android.graphics.Color;
import android.os.Build;
import android.view.Window;

import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsControllerCompat;

import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;

/**
* Status and navigation bar icons in the ZenNotes theme's contrast, not the
* system's. Two Capacitor plugins drive the bars' icon color and neither
* knows the theme: core's SystemBars resolves its default style from the
* system's night mode when it loads (dark icons whenever the phone is in
* light mode) and posts it after the StatusBar plugin has applied the
* config's light icons, so it always wins; both then re-apply their own
* stale choice on every configuration change. On a WebView 140+ phone the
* page draws under the bars, so a dark theme got near-black icons on its
* dark background.
*
* The JS shell (src/bridge/system-bar-theme.ts) reads the theme's background
* and hands it here whenever it changes. This paints every strip the app
* owns with it, so what sits under the icons is the theme on any Android and
* WebView: from Android 15 the bars are transparent and below WebView 140
* Capacitor pads the decor view, whose background shows through; before
* Android 15 the window's bar colors are what show. Then the icons take the
* contrasting color. A configuration change (rotation, the system's dark
* mode) re-applies it after the other two plugins have had their say.
* Android-only; the iPhone status bar sits over the page already.
*/
@CapacitorPlugin(name = "SystemBarTheme")
public class SystemBarThemePlugin extends Plugin {

/** Null until the JS shell has measured the theme: the launch theme's
* dark chrome stays until then. */
private Integer color = null;
private boolean light = false;

@PluginMethod
public void setTheme(PluginCall call) {
String raw = call.getString("color");
if (raw == null || raw.isEmpty()) {
call.reject("color must be #rrggbb");
return;
}
int parsed;
try {
parsed = Color.parseColor(raw);
} catch (IllegalArgumentException e) {
call.reject("color must be #rrggbb");
return;
}
boolean isLight = Boolean.TRUE.equals(call.getBoolean("light", false));
getActivity().runOnUiThread(() -> {
color = parsed;
light = isLight;
apply();
call.resolve();
});
}

@Override
protected void handleOnConfigurationChanged(Configuration newConfig) {
super.handleOnConfigurationChanged(newConfig);
// SystemBars and StatusBar re-apply their styles in this same pass,
// in an order nothing promises; go after both.
getActivity().getWindow().getDecorView().post(this::apply);
}

@SuppressWarnings("deprecation")
private void apply() {
if (color == null) return;
Window window = getActivity().getWindow();
window.getDecorView().setBackgroundColor(color);
boolean edgeToEdge = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM;
if (!edgeToEdge) {
window.setStatusBarColor(color);
}
WindowInsetsControllerCompat bars = WindowCompat.getInsetsController(window, window.getDecorView());
bars.setAppearanceLightStatusBars(light);
// Dark navigation buttons arrived in Android 8; before that a light
// bar would hide its white buttons, so it keeps the launch theme's.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!edgeToEdge) {
window.setNavigationBarColor(color);
}
bars.setAppearanceLightNavigationBars(light);
}
}
}
24 changes: 22 additions & 2 deletions android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ static final class Row {
final int type;
final String iso;
final CalendarWidgetData.Task task;
/** Header: open tasks that day (including any past the snapshot's cap). */
/** Header: open tasks that day (including any past the snapshot's
* cap), less the ones a widget tap has ticked since. */
final int count;
/** Task: the latest widget tap on it, or null. */
final WidgetActions.Mark mark;
Expand All @@ -64,7 +65,7 @@ static List<Row> rows(CalendarWidgetData data, String selected, Map<String, Widg
}
for (String iso : week) {
CalendarWidgetData.Day day = data.agenda.get(iso);
rows.add(new Row(HEADER, iso, null, day == null ? 0 : day.total, null));
rows.add(new Row(HEADER, iso, null, openCount(day, marks), null));
if (day == null) continue;
int shown = Math.min(day.tasks.size(), MAX_TASKS_PER_DAY);
for (int i = 0; i < shown; i++) {
Expand All @@ -76,6 +77,25 @@ static List<Row> rows(CalendarWidgetData data, String selected, Map<String, Widg
return rows;
}

/**
* A day's open tasks as the list draws them. The snapshot's `total`
* counts every task open when the app published it, and its rows stay
* listed, ticked, until a snapshot built after the app saw the tap
* replaces it; a row the log draws ticked is no longer open, so it comes
* off the count with the same tap, and goes back on with an untick. A
* newer snapshot no longer lists a task it saw ticked and prunes the
* tap, so nothing is taken off twice.
*/
static int openCount(CalendarWidgetData.Day day, Map<String, WidgetActions.Mark> marks) {
if (day == null) return 0;
int ticked = 0;
for (CalendarWidgetData.Task task : day.tasks) {
WidgetActions.Mark mark = marks.get(task.id);
if (mark != null && mark.checked) ticked++;
}
return Math.max(0, day.total - ticked);
}

/** Position of the selected day's header, to scroll the list to. */
static int positionOf(List<Row> rows, String iso) {
for (int i = 0; i < rows.size(); i++) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package md.zennotes.widgets;

import static org.junit.Assert.assertEquals;

import org.json.JSONObject;
import org.junit.Test;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class CalendarAgendaTest {
/** A snapshot for the week of Mon 2026-10-26: four tasks open on Tuesday,
* two of them listed (the rest past the snapshot's cap), one on Wednesday. */
private static CalendarWidgetData week() throws Exception {
String json = "{"
+ "\"todayIso\":\"2026-10-27\",\"weekStart\":1,\"dailyEnabled\":true,"
+ "\"from\":\"2026-09-28\",\"to\":\"2026-11-08\",\"vaultKey\":\"zn://My Vault\","
+ "\"agenda\":{"
+ "\"2026-10-27\":{\"total\":4,\"tasks\":["
+ task("inbox/Errands.md#0", "Pick up the dry cleaning") + ","
+ task("inbox/Errands.md#1", "Return the library books") + "]},"
+ "\"2026-10-28\":{\"total\":1,\"tasks\":["
+ task("inbox/Errands.md#2", "Buy stamps") + "]}"
+ "}}";
return CalendarWidgetData.parse(new JSONObject(json));
}

private static String task(String id, String content) {
return "{\"id\":\"" + id + "\",\"path\":\"inbox/Errands.md\",\"noteTitle\":\"Errands\","
+ "\"content\":\"" + content + "\",\"line\":2,\"raw\":\"- [ ] " + content + "\","
+ "\"toggled\":\"- [x] " + content + "\"}";
}

private static int headerCount(List<CalendarAgenda.Row> rows, String iso) {
for (CalendarAgenda.Row row : rows) {
if (row.type == CalendarAgenda.HEADER && row.iso.equals(iso)) return row.count;
}
throw new AssertionError("no header for " + iso);
}

@Test
public void countsTheSnapshotsOpenTasksWhenNothingIsTicked() throws Exception {
List<CalendarAgenda.Row> rows = CalendarAgenda.rows(week(), "2026-10-27", new HashMap<>());
assertEquals(4, headerCount(rows, "2026-10-27"));
assertEquals(1, headerCount(rows, "2026-10-28"));
assertEquals(0, headerCount(rows, "2026-10-29"));
}

@Test
public void aTickComesOffItsDaysCountAtOnce() throws Exception {
Map<String, WidgetActions.Mark> marks = new HashMap<>();
marks.put("inbox/Errands.md#1", new WidgetActions.Mark(true, true));
List<CalendarAgenda.Row> rows = CalendarAgenda.rows(week(), "2026-10-27", marks);
assertEquals(3, headerCount(rows, "2026-10-27"));
assertEquals(1, headerCount(rows, "2026-10-28"));
}

@Test
public void aTickTheAppHasYetToWriteCountsToo() throws Exception {
Map<String, WidgetActions.Mark> marks = new HashMap<>();
marks.put("inbox/Errands.md#0", new WidgetActions.Mark(true, false));
marks.put("inbox/Errands.md#2", new WidgetActions.Mark(true, false));
List<CalendarAgenda.Row> rows = CalendarAgenda.rows(week(), "2026-10-27", marks);
assertEquals(3, headerCount(rows, "2026-10-27"));
assertEquals(0, headerCount(rows, "2026-10-28"));
}

@Test
public void anUntickPutsItBack() throws Exception {
Map<String, WidgetActions.Mark> marks = new HashMap<>();
marks.put("inbox/Errands.md#1", new WidgetActions.Mark(false, true));
assertEquals(4, headerCount(CalendarAgenda.rows(week(), "2026-10-27", marks), "2026-10-27"));
}

@Test
public void aTickOnATaskTheSnapshotNoLongerListsIsNotTakenOffTwice() throws Exception {
// The app applied the tick and published before the log was pruned:
// the task has left the list and the total already counts it out.
Map<String, WidgetActions.Mark> marks = new HashMap<>();
marks.put("inbox/Errands.md#9", new WidgetActions.Mark(true, true));
assertEquals(4, headerCount(CalendarAgenda.rows(week(), "2026-10-27", marks), "2026-10-27"));
}

@Test
public void neverDropsBelowZero() throws Exception {
// A snapshot whose total undercounts its own rows still reads 0, not -1.
CalendarWidgetData data = CalendarWidgetData.parse(new JSONObject("{"
+ "\"weekStart\":1,\"dailyEnabled\":true,\"from\":\"2026-09-28\",\"to\":\"2026-11-08\","
+ "\"agenda\":{\"2026-10-27\":{\"total\":1,\"tasks\":["
+ task("a.md#0", "One") + "," + task("a.md#1", "Two") + "]}}}"));
Map<String, WidgetActions.Mark> marks = new HashMap<>();
marks.put("a.md#0", new WidgetActions.Mark(true, true));
marks.put("a.md#1", new WidgetActions.Mark(true, true));
assertEquals(0, CalendarAgenda.openCount(data.agenda.get("2026-10-27"), marks));
assertEquals(0, CalendarAgenda.openCount(null, marks));
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
"version": "1.1.48",
"version": "1.1.49",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
Expand Down
2 changes: 1 addition & 1 deletion src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ import {
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
import { isPhoneViewport } from '../viewport'

let appVersion = '1.1.48'
let appVersion = '1.1.49'

export async function loadNativeAppVersion(): Promise<string> {
try {
Expand Down
73 changes: 73 additions & 0 deletions src/bridge/system-bar-theme-color.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { systemBarThemeFor } from './system-bar-theme-color.ts'

// `--z-bg` of every built-in theme in core 2.78.0 (dist/styles/index.css).
const DARK = {
'apple-dark': '28 28 30',
'dark-hard': '29 32 33',
'dark-medium': '40 40 40',
'dark-soft': '50 48 47',
'catppuccin-frappe': '48 52 70',
'catppuccin-macchiato': '36 39 58',
'catppuccin-mocha': '30 30 46',
'rose-pine-main': '25 23 36',
'rose-pine-moon': '35 33 54',
'github-dark': '13 17 23',
'github-dark-dimmed': '34 39 46',
'github-dark-high-contrast': '0 0 0',
'solarized-dark': '0 43 54',
'one-dark': '40 44 52',
'nord-dark': '46 52 64',
'tokyo-night-storm': '36 40 59',
'kanagawa-wave': '31 31 40',
'kanagawa-dragon': '24 22 22',
'kanagawa-paper-ink': '20 19 27',
'black-metal': '0 0 0'
}
const LIGHT = {
'apple-light': '255 255 255',
'light-hard': '249 245 215',
'light-medium': '251 241 199',
'light-soft': '242 229 188',
'catppuccin-latte': '239 241 245',
'rose-pine-dawn': '250 244 237',
'github-light': '255 255 255',
'solarized-light': '253 246 227',
'one-light': '250 250 250',
'nord-light': '236 239 244',
'tokyo-night-day': '225 226 231',
'kanagawa-lotus': '242 236 188',
'black-metal-day': '244 244 242'
}

describe('systemBarThemeFor', () => {
it('gives every dark built-in theme light icons', () => {
for (const [id, bg] of Object.entries(DARK)) {
assert.equal(systemBarThemeFor(bg)?.light, false, id)
}
})

it('gives every light built-in theme dark icons', () => {
for (const [id, bg] of Object.entries(LIGHT)) {
assert.equal(systemBarThemeFor(bg)?.light, true, id)
}
})

it('hands the native side the background as #rrggbb', () => {
assert.deepEqual(systemBarThemeFor('29 32 33'), { color: '#1d2021', light: false })
assert.deepEqual(systemBarThemeFor(' 251 241 199 '), { color: '#fbf1c7', light: true })
})

it('picks the icon color with more contrast around the middle', () => {
// Relative luminance about 0.16 and 0.20, either side of the tie.
assert.equal(systemBarThemeFor('112 112 112')?.light, false)
assert.equal(systemBarThemeFor('124 124 124')?.light, true)
})

it('ignores anything that is not three channels', () => {
for (const raw of ['', '29 32', '29 32 33 1', 'rgb(29, 32, 33)', '#1d2021', '29 32 256', '29.5 32 33']) {
assert.equal(systemBarThemeFor(raw), null, raw)
}
})
})
Loading
Loading