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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under
`vendor/zennotes/` with their source identity and checksums (`manifest.json`),
and `package-lock.json` pins the complete install. No source checkout is used.
The vendored set is the core release
[core-2.77.0-core.hfe38f5ee2a70bd64](https://github.com/ZenNotes/zennotes/releases/tag/core-2.77.0-core.hfe38f5ee2a70bd64)
(desktop commit `3f0356de`, clean tree). Run `npm run
[core-2.78.0-core.h0bc32cbd73a78fac](https://github.com/ZenNotes/zennotes/releases/tag/core-2.78.0-core.h0bc32cbd73a78fac)
(desktop commit `52ab86fd`, clean tree). Run `npm run
boundaries:check` to verify archives, installed versions, singleton
editor/React peers, and imports; it refuses an archive built from a dirty
upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out.
Expand Down
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 50
versionName "1.1.47"
versionCode 51
versionName "1.1.48"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
21 changes: 21 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,27 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_tasks_info" />
</receiver>
<!-- Calendar: the month grid and the week's tasks to tick. Its taps go
to the receiver below, not exported, since a tick writes into a
note. -->
<receiver
android:name=".widgets.CalendarWidgetProvider"
android:exported="true"
android:label="@string/widget_calendar_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_calendar_info" />
</receiver>
<receiver
android:name=".widgets.CalendarWidgetActionReceiver"
android:exported="false" />
<service
android:name=".widgets.CalendarAgendaService"
android:exported="false"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name=".widgets.RecentNotesWidgetService"
android:exported="false"
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 @@ -39,6 +39,7 @@ public void onCreate(Bundle savedInstanceState) {
registerPlugin(WidgetBridgePlugin.class);
registerPlugin(ImagePastePlugin.class);
registerPlugin(EdgeSwipePlugin.class);
registerPlugin(OpenFilePlugin.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
91 changes: 91 additions & 0 deletions android/app/src/main/java/md/zennotes/OpenFilePlugin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package md.zennotes;

import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.webkit.MimeTypeMap;

import androidx.core.content.FileProvider;

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

import java.io.File;
import java.util.Locale;

/**
* Opens a vault file in the app the user keeps for its type, through a VIEW
* intent: the bridge contract's openAssetExternally, which the shell never
* had. The WebView has no PDF viewer, so a PDF embed or reference pane drew
* an empty page in place (Dave, Discord, 2026-10-27); this is how a PDF shows
* at all on Android, and how any other attachment opens.
*
* A file:// path (the app's own vault folder, or a copy staged out of a remote
* vault) goes out through the app's FileProvider. A content:// document under
* a picked folder goes out as it is, and the read grant this app holds on it
* is passed along. Rejects with NO_APP when nothing on the phone views the
* type, so the shell can say so instead of failing silently.
*/
@CapacitorPlugin(name = "OpenFile")
public class OpenFilePlugin extends Plugin {

@PluginMethod
public void open(PluginCall call) {
String raw = call.getString("uri");
if (raw == null || raw.isEmpty()) {
call.reject("No file to open.");
return;
}
Context context = getContext();
Uri uri;
try {
Uri parsed = Uri.parse(raw);
if ("content".equals(parsed.getScheme())) {
uri = parsed;
} else if ("file".equals(parsed.getScheme()) && parsed.getPath() != null) {
uri = FileProvider.getUriForFile(
context, context.getPackageName() + ".fileprovider", new File(parsed.getPath()));
} else {
call.reject("This file is not on this phone.");
return;
}
} catch (IllegalArgumentException e) {
call.reject("This file cannot be handed to another app.", e);
return;
}
Intent intent = new Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, mimeType(context, uri, raw))
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
getActivity().runOnUiThread(() -> {
try {
getActivity().startActivity(intent);
call.resolve();
} catch (ActivityNotFoundException e) {
call.reject("No app on this phone opens this kind of file.", "NO_APP");
}
});
}

/** The provider's answer first (a picked folder knows its documents' types),
* then the file name's extension, then any type at all. */
private static String mimeType(Context context, Uri uri, String raw) {
String type = null;
try {
type = context.getContentResolver().getType(uri);
} catch (RuntimeException ignored) {
// Fall back to the name below.
}
if (type != null) return type;
String name = Uri.decode(raw);
int dot = name.lastIndexOf('.');
int slash = name.lastIndexOf('/');
if (dot > slash && dot < name.length() - 1) {
String ext = name.substring(dot + 1).toLowerCase(Locale.ROOT);
type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
}
return type != null ? type : "*/*";
}
}
33 changes: 32 additions & 1 deletion android/app/src/main/java/md/zennotes/WidgetBridgePlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.IOException;

import md.zennotes.widgets.WidgetActions;
import md.zennotes.widgets.WidgetSnapshot;
import md.zennotes.widgets.WidgetUpdater;

Expand All @@ -21,7 +25,9 @@
* and ui-mobile/deep-links.ts run unchanged). The WebView publishes the
* widget snapshot; this writes it into the app's private files and
* re-renders every placed widget (md.zennotes.widgets). The widgets never
* see the vault itself.
* see the vault itself, except that the Calendar widget writes a tick into
* the task's own line when that line is still the one the snapshot named;
* readActions hands its tap log back (src/bridge/widget-calendar.ts).
*/
@CapacitorPlugin(name = "ZenWidgets")
public class WidgetBridgePlugin extends Plugin {
Expand Down Expand Up @@ -68,11 +74,36 @@ public void update(PluginCall call) {
call.reject("Could not write the widget snapshot: " + e.getMessage());
return;
}
WidgetActions.prune(context, actionsAck(json));
WidgetUpdater.refreshAll(context);
call.resolve();
}, "zn-widgets").start();
}

/** The newest Calendar widget tap this snapshot says the app applied. */
private static long actionsAck(String json) {
try {
JSONObject calendar = new JSONObject(json).optJSONObject("calendar");
return calendar == null ? 0 : (long) calendar.optDouble("actionsAck", 0);
} catch (JSONException e) {
return 0;
}
}

@PluginMethod
public void readActions(PluginCall call) {
final Context context = getContext().getApplicationContext();
new Thread(() -> {
try {
JSObject ret = new JSObject();
ret.put("actions", new com.getcapacitor.JSArray(WidgetActions.json(context)));
call.resolve(ret);
} catch (JSONException e) {
call.reject("Could not read the widget's taps: " + e.getMessage());
}
}, "zn-widgets").start();
}

@PluginMethod
public void clear(PluginCall call) {
final Context context = getContext().getApplicationContext();
Expand Down
162 changes: 162 additions & 0 deletions android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package md.zennotes.widgets;

import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.Paint;
import android.os.Build;
import android.view.View;
import android.widget.RemoteViews;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import md.zennotes.R;

/**
* The rows of the Calendar widget's week list, the panel's agenda: each day
* of the selected day's week as a header ("27 TUE", Today, a count), then
* that day's open tasks. Built in one place for both ways the list is fed
* (CalendarWidgetProvider's collection items on Android 12+, the
* RemoteViewsService before), so they agree on rows and positions.
*/
final class CalendarAgenda {
private CalendarAgenda() {}

static final int HEADER = 0;
static final int TASK = 1;
static final int MESSAGE = 2;
/** "+N more" under a busy day (the message layout). */
static final int MORE = 3;
/** Distinct layouts: MORE shares MESSAGE's. */
static final int VIEW_TYPES = 3;
/** Rows per day. The whole week travels in one widget update, which has
* a size limit; past this a day ends in "+N more", opening the app. */
static final int MAX_TASKS_PER_DAY = 12;

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). */
final int count;
/** Task: the latest widget tap on it, or null. */
final WidgetActions.Mark mark;

Row(int type, String iso, CalendarWidgetData.Task task, int count, WidgetActions.Mark mark) {
this.type = type;
this.iso = iso;
this.task = task;
this.count = count;
this.mark = mark;
}
}

static List<Row> rows(CalendarWidgetData data, String selected, Map<String, WidgetActions.Mark> marks) {
if (data == null || !data.dailyEnabled) return Collections.emptyList();
List<Row> rows = new ArrayList<>();
List<String> week = CalendarMath.weekOf(selected, data.weekStart);
if (!data.covers(week.get(0)) || !data.covers(week.get(6))) {
rows.add(new Row(MESSAGE, selected, null, 0, null));
return rows;
}
for (String iso : week) {
CalendarWidgetData.Day day = data.agenda.get(iso);
rows.add(new Row(HEADER, iso, null, day == null ? 0 : day.total, null));
if (day == null) continue;
int shown = Math.min(day.tasks.size(), MAX_TASKS_PER_DAY);
for (int i = 0; i < shown; i++) {
CalendarWidgetData.Task task = day.tasks.get(i);
rows.add(new Row(TASK, iso, task, 0, marks.get(task.id)));
}
if (day.total > shown) rows.add(new Row(MORE, iso, null, day.total - shown, null));
}
return rows;
}

/** 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++) {
if (rows.get(i).type == HEADER && rows.get(i).iso.equals(iso)) return i;
}
return 0;
}

static RemoteViews view(Context context, WidgetSnapshot.Palette p, Row row, String selected, String today) {
switch (row.type) {
case HEADER:
return header(context, p, row, selected, today);
case TASK:
return task(context, p, row);
case MORE:
return more(context, p, row);
default:
return message(context, p, row);
}
}

private static RemoteViews header(Context context, WidgetSnapshot.Palette p, Row row, String selected, String today) {
RemoteViews v = new RemoteViews(context.getPackageName(), R.layout.widget_cal_agenda_day);
v.setTextViewText(R.id.zn_agenda_day_label, CalendarMath.shortDay(row.iso).toUpperCase());
v.setTextColor(R.id.zn_agenda_day_label, row.iso.equals(selected) ? p.fg : p.muted);
v.setViewVisibility(R.id.zn_agenda_today, row.iso.equals(today) ? View.VISIBLE : View.GONE);
v.setInt(R.id.zn_agenda_today_bg, "setColorFilter", WidgetSnapshot.Palette.over(p.accent, 0.15f, p.bg));
v.setTextColor(R.id.zn_agenda_today_text, p.accent);
v.setTextViewText(R.id.zn_agenda_day_count, row.count > 0 ? String.valueOf(row.count) : "");
v.setTextColor(R.id.zn_agenda_day_count, p.muted);
v.setOnClickFillInIntent(R.id.zn_agenda_day, CalendarWidgetActionReceiver.selectFillIn(row.iso));
return v;
}

private static RemoteViews task(Context context, WidgetSnapshot.Palette p, Row row) {
CalendarWidgetData.Task task = row.task;
RemoteViews v = new RemoteViews(context.getPackageName(), R.layout.widget_cal_agenda_task);
boolean checked = row.mark != null && row.mark.checked;
// A tick the app has yet to apply (it could not be written here)
// shows in the muted color until it lands.
int boxColor = checked && row.mark.written ? p.accent : p.muted;
String content = task.content.trim();
v.setTextViewText(R.id.zn_task_title, content.isEmpty() ? "Untitled task" : content);
v.setTextColor(R.id.zn_task_title, checked ? p.muted : p.fg);
v.setInt(R.id.zn_task_title, "setPaintFlags",
Paint.ANTI_ALIAS_FLAG | (checked ? Paint.STRIKE_THRU_TEXT_FLAG : 0));
v.setViewVisibility(R.id.zn_task_note, task.inDailyNote ? View.GONE : View.VISIBLE);
v.setTextViewText(R.id.zn_task_note, task.noteTitle);
v.setTextColor(R.id.zn_task_note, p.muted);
v.setContentDescription(R.id.zn_task_check, context.getString(
checked ? R.string.widget_calendar_mark_not_done : R.string.widget_calendar_mark_done));
Intent tick = CalendarWidgetActionReceiver.toggleFillIn(task, !checked);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
v.setCompoundButtonChecked(R.id.zn_task_check, checked);
v.setColorStateList(R.id.zn_task_check, "setButtonTintList", ColorStateList.valueOf(boxColor));
v.setOnCheckedChangeResponse(R.id.zn_task_check, RemoteViews.RemoteResponse.fromFillInIntent(tick));
} else {
v.setImageViewResource(R.id.zn_task_check, checked ? R.drawable.ic_zn_check_square : R.drawable.ic_zn_square);
v.setInt(R.id.zn_task_check, "setColorFilter", boxColor);
v.setOnClickFillInIntent(R.id.zn_task_check, tick);
}
v.setOnClickFillInIntent(R.id.zn_task_open,
CalendarWidgetActionReceiver.openFillIn(WidgetLinks.task(task.id, task.path)));
return v;
}

private static RemoteViews more(Context context, WidgetSnapshot.Palette p, Row row) {
RemoteViews v = new RemoteViews(context.getPackageName(), R.layout.widget_cal_agenda_message);
v.setTextViewText(R.id.zn_agenda_message, context.getString(R.string.widget_calendar_more, row.count));
v.setTextColor(R.id.zn_agenda_message, p.muted);
v.setOnClickFillInIntent(R.id.zn_agenda_message,
CalendarWidgetActionReceiver.openFillIn(WidgetLinks.calendar(row.iso, false)));
return v;
}

private static RemoteViews message(Context context, WidgetSnapshot.Palette p, Row row) {
RemoteViews v = new RemoteViews(context.getPackageName(), R.layout.widget_cal_agenda_message);
v.setTextViewText(R.id.zn_agenda_message, context.getString(R.string.widget_calendar_out_of_range));
v.setTextColor(R.id.zn_agenda_message, p.muted);
v.setOnClickFillInIntent(R.id.zn_agenda_message,
CalendarWidgetActionReceiver.openFillIn(WidgetLinks.calendar(row.iso, false)));
return v;
}
}
Loading
Loading