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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ android/ Capacitor-generated Gradle project (appId md.zennotes)
app/src/main/java/md/zennotes/
MainActivity.java registers native plugins, stashes ACTION_SEND shares
DirectUploadPlugin.java streams signed object PUTs on Android 7+
EdgeSwipePlugin.java Android-only: claims a mid-screen band of the left edge from
the system Back gesture so the edge swipe can open Browse
ShareInboxPlugin.java Android ShareInbox (same jsName/contract as iOS)
WidgetBridgePlugin.java ZenWidgets (same jsName/contract as iOS): writes the snapshot
widgets/ New Note, Recent Notes, Today's Tasks: AppWidgetProviders +
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 25
versionName "1.1.22"
versionCode 26
versionName "1.1.23"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
75 changes: 75 additions & 0 deletions android/app/src/main/java/md/zennotes/EdgeSwipePlugin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package md.zennotes;

import android.graphics.Rect;
import android.os.Build;
import android.view.View;

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

import java.util.Collections;

/**
* Keeps the shell's left-edge swipe (open Browse) alive under gesture
* navigation. Android claims every swipe that starts at a screen edge as
* Back: the WebView saw touchstart then touchcancel, Browse never opened, and
* the swipe navigated back instead, or left the app from Home (Play review,
* 1.1.21). Excluding a band of the left edge from the Back gesture hands those
* touches to the WebView, as androidx DrawerLayout does for its drawer edge.
*
* The system honours at most 200dp per edge, so the band sits mid-screen,
* where a thumb swipes. Back keeps working above and below it and along the
* whole right edge. The JS shell decides when the band is wanted (phone
* layout, drawer closed), because only it knows the layout override and the
* drawer state. Android-only; iOS has no edge Back gesture to contend with.
*/
@CapacitorPlugin(name = "EdgeSwipe")
public class EdgeSwipePlugin extends Plugin {

private static final int BAND_WIDTH_DP = 32;
private static final int BAND_HEIGHT_DP = 200;

private boolean claimed = false;
private View.OnLayoutChangeListener relayout;

@PluginMethod
public void setLeftEdgeClaimed(PluginCall call) {
boolean claim = Boolean.TRUE.equals(call.getBoolean("claimed", false));
getActivity().runOnUiThread(() -> {
claimed = claim;
apply();
call.resolve();
});
}

private void apply() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return; // no gesture navigation, nothing to exclude
}
View webView = getBridge().getWebView();
if (relayout == null) {
// Rects are in view coordinates: follow rotation and the keyboard.
relayout = (v, l, t, r, b, ol, ot, or, ob) -> exclude(v);
webView.addOnLayoutChangeListener(relayout);
}
exclude(webView);
}

private void exclude(View view) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return;
}
if (!claimed || view.getHeight() == 0) {
view.setSystemGestureExclusionRects(Collections.emptyList());
return;
}
float density = view.getResources().getDisplayMetrics().density;
int width = Math.round(BAND_WIDTH_DP * density);
int height = Math.min(Math.round(BAND_HEIGHT_DP * density), view.getHeight());
int top = (view.getHeight() - height) / 2;
view.setSystemGestureExclusionRects(
Collections.singletonList(new Rect(0, top, width, top + height)));
}
}
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 @@ -38,6 +38,7 @@ public void onCreate(Bundle savedInstanceState) {
registerPlugin(DirectUploadPlugin.class);
registerPlugin(WidgetBridgePlugin.class);
registerPlugin(ImagePastePlugin.class);
registerPlugin(EdgeSwipePlugin.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
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.22",
"version": "1.1.23",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
Expand Down
19 changes: 19 additions & 0 deletions src/bridge/edge-swipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Android gesture navigation claims every swipe that starts at a screen edge
* as Back, so the shell's left-edge swipe never reached the WebView. The
* native side excludes a mid-screen band of the left edge from that gesture
* while the shell asks for it (EdgeSwipePlugin.java has the full story).
* Android-only: there is no iOS counterpart and no web implementation, so
* every call is best-effort.
*/
import { registerPlugin } from '@capacitor/core'

interface EdgeSwipePlugin {
setLeftEdgeClaimed(options: { claimed: boolean }): Promise<void>
}

const EdgeSwipe = registerPlugin<EdgeSwipePlugin>('EdgeSwipe')

export function setLeftEdgeClaimed(claimed: boolean): void {
void EdgeSwipe.setLeftEdgeClaimed({ claimed }).catch(() => {})
}
2 changes: 1 addition & 1 deletion src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ import {
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
import { isPhoneViewport } from '../viewport'

let appVersion = '1.1.22'
let appVersion = '1.1.23'

export async function loadNativeAppVersion(): Promise<string> {
try {
Expand Down
46 changes: 25 additions & 21 deletions src/ui-mobile/MobileDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'
import ReactDOM from 'react-dom/client'
import { getShellSnapshot, useShellSnapshot, setNoteSortOrder, type NoteSortOrder } from '@zennotes/app-core/shell'
import { getBrowseSnapshot, useBrowseSnapshot, getBrowseDirectory, requestCreateBrowseFolder,
requestRenameBrowseFolder, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse'
requestRenameBrowseFolder, requestRenameBrowseDatabase, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse'
import { useWorkspaceSnapshot, openLocalVault, pickLocalVault, refreshRemoteProfiles, connectRemoteWorkspace,
connectRemoteProfile, changeRemoteVaultPath, deleteRemoteProfile } from '@zennotes/app-core/workspace'
import { openNote, openAppPage } from '@zennotes/app-core/navigation'
Expand Down Expand Up @@ -893,7 +893,7 @@ function MobileDrawerBody(props: {
// Rename/Delete here. Prompts overlay the open drawer (Modal layers above
// z-49), so the drawer stays put and its list refreshes in place via the
// vault change events.
const [folderMenu, setFolderMenu] = useState<{ subpath: string; name: string; host: ReturnType<typeof captureMobileWorkspace> } | null>(null)
const [folderMenu, setFolderMenu] = useState<{ kind: 'folder' | 'database'; subpath: string; name: string; host: ReturnType<typeof captureMobileWorkspace> } | null>(null)

const pinNote = (notePath: string): void => {
if (!vaultRoot) return
Expand Down Expand Up @@ -973,15 +973,13 @@ function MobileDrawerBody(props: {

const renameFolderFromDrawer = (subpath: string, _name: string): void => {
const host = folderMenu?.host ?? captureMobileWorkspace()
const rename = folderMenu?.kind === 'database' ? requestRenameBrowseDatabase : requestRenameBrowseFolder
setFolderMenu(null)
void requestRenameBrowseFolder(host, subpath).catch(reportActionError)
void rename(host, subpath).catch(reportActionError)
}
const newFolderHere = (): void => {
void requestCreateBrowseFolder(captureMobileWorkspace(), path).catch(reportActionError)
}
const deleteDatabase = (subpath: string, _title: string): void => {
void requestDeleteBrowseDirectory(captureMobileWorkspace(), subpath).catch(reportActionError)
}
const deleteFolder = (subpath: string, _name: string): void => {
const host = folderMenu?.host ?? captureMobileWorkspace()
void requestDeleteBrowseDirectory(host, subpath).catch(reportActionError)
Expand Down Expand Up @@ -1134,7 +1132,7 @@ function MobileDrawerBody(props: {
<button
type="button"
onClick={() => setPath(subpath)}
{...lp(() => setFolderMenu({ subpath, name, host: captureMobileWorkspace() }))}
{...lp(() => setFolderMenu({ kind: 'folder', subpath, name, host: captureMobileWorkspace() }))}
>
<Icon d={dateDirs.has(subpath) ? D.calendar : D.folder} />
<span className="zn-truncate">{name}</span>
Expand All @@ -1153,7 +1151,7 @@ function MobileDrawerBody(props: {
key={tabPath}
type="button"
onClick={() => go(() => openNote(tabPath))}
{...lp(() => deleteDatabase(subpath, title))}
{...lp(() => setFolderMenu({ kind: 'database', subpath, name: title, host: captureMobileWorkspace() }))}
>
<Icon d={D.database} />
<span className="zn-truncate">{title}</span>
Expand Down Expand Up @@ -1217,23 +1215,29 @@ function MobileDrawerBody(props: {
onClick={() => setFolderMenu(null)}
role="presentation"
/>
<div className="zn-mobile-sheet" role="menu" aria-label="Folder actions">
<div
className="zn-mobile-sheet"
role="menu"
aria-label={folderMenu.kind === 'database' ? 'Database actions' : 'Folder actions'}
>
<SheetHandle onDismiss={() => setFolderMenu(null)} />
<div className="zn-mobile-sheet-title zn-truncate">{folderMenu.name}</div>
<div className="zn-mobile-sheet-scroll">
<div className="zn-mobile-sheet-group">
<button
type="button"
className="zn-mobile-sheet-row"
onClick={() => {
const sp = folderMenu.subpath
setFolderMenu(null)
pinFolder(sp)
}}
>
<Icon d={D.pin} />
{pinnedFolders.includes(folderMenu.subpath) ? 'Unpin' : 'Pin'}
</button>
{folderMenu.kind === 'folder' && (
<button
type="button"
className="zn-mobile-sheet-row"
onClick={() => {
const sp = folderMenu.subpath
setFolderMenu(null)
pinFolder(sp)
}}
>
<Icon d={D.pin} />
{pinnedFolders.includes(folderMenu.subpath) ? 'Unpin' : 'Pin'}
</button>
)}
<button
type="button"
className="zn-mobile-sheet-row"
Expand Down
18 changes: 16 additions & 2 deletions src/ui-mobile/MobileShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { MobileEditorToolbar } from './EditorToolbar'
import { captureMobileWorkspace, reportActionError } from './workspace-context'
import { MobileDrawer } from './MobileDrawer'
import { isDrawerOpen, setDrawerOpen, useDrawerOpen } from './drawer-state'
import { setLeftEdgeClaimed } from '../bridge/edge-swipe'
import { goHome } from './nav'
import { installNoteRowGestures, NOTE_ROW_SELECTOR } from './note-row-gestures'
import { NoteActionSheet } from './note-actions'
Expand Down Expand Up @@ -824,8 +825,20 @@ function useTagsEmptyStateHint(): void {
* (kanban, toolbars) are never hijacked. (A swipe-to-go-back variant was
* tried 2026-07-16 and reverted at Adib's request — back lives in the
* header chevron.)
*
* Android gesture navigation takes every edge swipe as Back, so while the
* drawer is closed the shell claims a mid-screen band of the left edge
* (bridge/edge-swipe.ts). It is released while the drawer is open, so Back
* from that edge still closes it.
*/
function useEdgeSwipeDrawer(): void {
const drawerOpen = useDrawerOpen()
useEffect(() => {
if (!isPhoneWidth()) return
setLeftEdgeClaimed(!drawerOpen)
return () => setLeftEdgeClaimed(false)
}, [drawerOpen])

useEffect(() => {
if (!isPhoneWidth()) return
const EDGE = 28
Expand Down Expand Up @@ -2461,8 +2474,9 @@ function SettingsGesturesRow(): React.JSX.Element {
<div className="zn-settings-layout-title">Swipe gestures</div>
<div className="zn-settings-layout-desc">
One-handed shortcuts over an open note. A quick flick left or right,
or a pull down from the top of the note. Swiping in from the left
screen edge always opens Browse.
or a pull down from the top of the note. Swiping in from the middle
of the left screen edge opens Browse; to open Browse or the outline
from anywhere on a note, set a flick to it here.
</div>
</div>
<div className="zn-settings-gestures-rows">
Expand Down