From 5abb3dc4cfda70089bdab5296e9781ab7b405646 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 17 Sep 2026 13:51:53 -0500 Subject: [PATCH 1/2] fix: the left-edge swipe opens Browse under gesture navigation, and databases get a menu A Play Store review reported that the sidebars "don't appear as intended" because of gesture control, and that databases could not be managed like folders. Android gesture navigation takes every swipe that starts at a screen edge as Back. The WebView received touchstart then touchcancel, Browse never opened, the swipe navigated back instead, and from Home it left the app. EdgeSwipePlugin excludes a 32dp x 200dp band of the left edge, centred mid-screen, from the Back gesture (200dp is the most Android honours per edge), as androidx DrawerLayout does for its drawer. The shell claims the band on the phone layout while the drawer is closed and releases it while the drawer is open, so Back still closes the drawer. Back keeps working above and below the band and along the whole right edge. Android-only; iOS has no edge Back gesture. The Swipe gestures setting said the edge swipe "always opens Browse", which was false here. It now says where the swipe works and points at the flick options, which already open Browse or the outline from anywhere on a note. Long-pressing a database in Browse went straight to the delete confirmation. It now opens the same sheet folders use, with Rename (requestRenameBrowseDatabase, public since core 2.51.0) and Delete. Moving a database needs a core action that ships with a later core. Verified on the Android 15 emulator with gesture navigation: Android holds the exclusion region while the drawer is closed and drops it while open; a left-edge swipe mid-screen opens Browse, the same swipe near the top is still Back, and Back closes the open drawer; renaming a database through the new sheet renamed its folder on disk with its contents intact. --- README.md | 2 + .../java/md/zennotes/EdgeSwipePlugin.java | 75 +++++++++++++++++++ .../main/java/md/zennotes/MainActivity.java | 1 + src/bridge/edge-swipe.ts | 19 +++++ src/ui-mobile/MobileDrawer.tsx | 46 ++++++------ src/ui-mobile/MobileShell.tsx | 18 ++++- 6 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 android/app/src/main/java/md/zennotes/EdgeSwipePlugin.java create mode 100644 src/bridge/edge-swipe.ts diff --git a/README.md b/README.md index 0dda828..ec00e72 100644 --- a/README.md +++ b/README.md @@ -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 + diff --git a/android/app/src/main/java/md/zennotes/EdgeSwipePlugin.java b/android/app/src/main/java/md/zennotes/EdgeSwipePlugin.java new file mode 100644 index 0000000..540a803 --- /dev/null +++ b/android/app/src/main/java/md/zennotes/EdgeSwipePlugin.java @@ -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))); + } +} diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index 1f805e1..ee357d3 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -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). diff --git a/src/bridge/edge-swipe.ts b/src/bridge/edge-swipe.ts new file mode 100644 index 0000000..82dfee5 --- /dev/null +++ b/src/bridge/edge-swipe.ts @@ -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 +} + +const EdgeSwipe = registerPlugin('EdgeSwipe') + +export function setLeftEdgeClaimed(claimed: boolean): void { + void EdgeSwipe.setLeftEdgeClaimed({ claimed }).catch(() => {}) +} diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index d366a23..23c542d 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -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' @@ -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 } | null>(null) + const [folderMenu, setFolderMenu] = useState<{ kind: 'folder' | 'database'; subpath: string; name: string; host: ReturnType } | null>(null) const pinNote = (notePath: string): void => { if (!vaultRoot) return @@ -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) @@ -1134,7 +1132,7 @@ function MobileDrawerBody(props: { + {folderMenu.kind === 'folder' && ( + + )}