From 0a7f8631b65e5f9a01f1d59d15468637ef247769 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Mon, 22 Dec 2025 18:59:36 +0500 Subject: [PATCH] mobile: add home shortcuts Add ability to create shortcuts on launcher homescreen for notes, notebooks and tags. --- .../notesnook/RCTNNativeModule.java | 180 ++++++++++++++++++ .../main/res/drawable-anydpi/ic_notebook.xml | 11 ++ .../main/res/drawable-hdpi/ic_notebook.png | Bin 0 -> 284 bytes .../main/res/drawable-mdpi/ic_notebook.png | Bin 0 -> 211 bytes .../main/res/drawable-xhdpi/ic_notebook.png | Bin 0 -> 440 bytes .../main/res/drawable-xxhdpi/ic_notebook.png | Bin 0 -> 807 bytes .../app/components/properties/items.tsx | 18 +- apps/mobile/app/hooks/use-actions.tsx | 25 ++- apps/mobile/app/hooks/use-app-events.tsx | 46 +++-- .../app/screens/editor/tiptap/use-editor.ts | 5 +- apps/mobile/app/utils/notesnook-module.ts | 19 +- apps/mobile/scripts/optimize-fonts.mjs | 3 +- packages/intl/locale/en.po | 4 + packages/intl/locale/pseudo-LOCALE.po | 4 + packages/intl/src/strings.ts | 3 +- 15 files changed, 291 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/android/app/src/main/res/drawable-anydpi/ic_notebook.xml create mode 100644 apps/mobile/android/app/src/main/res/drawable-hdpi/ic_notebook.png create mode 100644 apps/mobile/android/app/src/main/res/drawable-mdpi/ic_notebook.png create mode 100644 apps/mobile/android/app/src/main/res/drawable-xhdpi/ic_notebook.png create mode 100644 apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_notebook.png diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/RCTNNativeModule.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/RCTNNativeModule.java index b79daf2ac..9cd572e67 100644 --- a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/RCTNNativeModule.java +++ b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/RCTNNativeModule.java @@ -6,7 +6,14 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.Bitmap; +import android.graphics.Canvas; import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.Icon; import android.os.Build; import android.os.Bundle; import android.provider.Settings; @@ -237,6 +244,179 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule { } } + @ReactMethod + public void addShortcut(final String id, final String type, final String title, final String description, Promise promise) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher"); + return; + } + try { + ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class); + if (shortcutManager == null) { + promise.reject("ERROR", "ShortcutManager not available"); + return; + } + + String uri = "https://app.notesnook.com/open_" + type + "?id=" + id; + Intent intent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(uri)); + intent.setPackage(mContext.getPackageName()); + + Icon icon = createLetterIcon(type, title); + + ShortcutInfo shortcut = new ShortcutInfo.Builder(mContext, id) + .setShortLabel(title) + .setLongLabel(description != null && !description.isEmpty() ? description : title) + .setIcon(icon) + .setIntent(intent) + .build(); + + shortcutManager.requestPinShortcut(shortcut, null); + promise.resolve(true); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + @ReactMethod + public void removeShortcut(final String id, Promise promise) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher"); + return; + } + + try { + ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class); + if (shortcutManager == null) { + promise.reject("ERROR", "ShortcutManager not available"); + return; + } + + shortcutManager.disableShortcuts(java.util.Collections.singletonList(id)); + promise.resolve(true); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + @ReactMethod + public void updateShortcut(final String id, final String title, final String description, Promise promise) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher"); + return; + } + + try { + ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class); + if (shortcutManager == null) { + promise.reject("ERROR", "ShortcutManager not available"); + return; + } + + // Get existing shortcut to preserve icon and intent + List shortcuts = shortcutManager.getPinnedShortcuts(); + ShortcutInfo existingShortcut = null; + for (ShortcutInfo s : shortcuts) { + if (s.getId().equals(id)) { + existingShortcut = s; + break; + } + } + + if (existingShortcut == null) { + promise.reject("NOT_FOUND", "Shortcut not found"); + return; + } + + ShortcutInfo updatedShortcut = new ShortcutInfo.Builder(mContext, id) + .setShortLabel(title) + .setLongLabel(description != null && !description.isEmpty() ? description : title) + .setIntent(existingShortcut.getIntent()) + .build(); + + shortcutManager.updateShortcuts(java.util.Collections.singletonList(updatedShortcut)); + promise.resolve(true); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + @ReactMethod + public void removeAllShortcuts(Promise promise) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher"); + return; + } + + try { + ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class); + if (shortcutManager == null) { + promise.reject("ERROR", "ShortcutManager not available"); + return; + } + + List pinnedShortcuts = shortcutManager.getPinnedShortcuts(); + if (!pinnedShortcuts.isEmpty()) { + List ids = new ArrayList<>(); + for (ShortcutInfo shortcut : pinnedShortcuts) { + ids.add(shortcut.getId()); + } + shortcutManager.disableShortcuts(ids); + } + promise.resolve(true); + } catch (Exception e) { + promise.reject("ERROR", e.getMessage()); + } + } + + private Icon createLetterIcon(String type, String title) { + + String letter = type.contains("tag") ? "#" : title != null && !title.isEmpty() + ? title.substring(0, 1).toUpperCase() + : "?"; + int color = getColorForLetter(letter); + + if (type.equals("notebook")) return Icon.createWithResource(mContext, R.drawable.ic_notebook); + // Use a larger canvas and fill it completely to avoid white borders from launcher masking. + int iconSize = 256; + Bitmap bitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + + + Paint textPaint = new Paint(); + textPaint.setColor(color); + textPaint.setTextSize(130); + textPaint.setAntiAlias(true); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setTypeface(android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD)); + + float x = iconSize / 2f; + float y = (iconSize / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f); + + canvas.drawText(letter, x, y, textPaint); + + return Icon.createWithBitmap(bitmap); + } + + private int getColorForLetter(String letter) { + int[] colors = { + 0xFF1976D2, // Blue + 0xFFD32F2F, // Red + 0xFF388E3C, // Green + 0xFFF57C00, // Orange + 0xFF7B1FA2, // Purple + 0xFF0097A7, // Cyan + 0xFFC2185B, // Pink + 0xFF455A64, // Blue Grey + 0xFF6A1B9A, // Deep Purple + 0xFF00796B, // Teal + 0xFF512DA8, // Indigo + 0xFF1565C0 // Dark Blue + }; + + int hash = Math.abs(letter.hashCode()); + return colors[hash % colors.length]; + } } diff --git a/apps/mobile/android/app/src/main/res/drawable-anydpi/ic_notebook.xml b/apps/mobile/android/app/src/main/res/drawable-anydpi/ic_notebook.xml new file mode 100644 index 000000000..89dcc5c15 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable-anydpi/ic_notebook.xml @@ -0,0 +1,11 @@ + + + diff --git a/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_notebook.png b/apps/mobile/android/app/src/main/res/drawable-hdpi/ic_notebook.png new file mode 100644 index 0000000000000000000000000000000000000000..16da59a349c4bdd618d851f5796bbb81e776c85d GIT binary patch literal 284 zcmV+%0ptFOP)*r<40U%}2P?mu8dgI^&<{ z`+n`cKgAgDHR`FgVxK`^c*BCV3fepNQwbs5Sg2M(`^tVQQh?Va*zJN1OS9A;$u*Ud zC}V+Gl(9f8%2*&4Wxg&DK63N?9>N`+bFR+zv=8Z2<&8wIwrc$E{dTTu|Wv0000$UubQwj&Gs z33m^p36nYe6!WsXSYEZZZI*Ak)XeU?qx$DdlfSZxEy0>kH>OXU_S;IL*2wa?_4iVf znct_*-1m!yta@1u5}d4uFFan=884G z+rm+@+;xGqF4Nsi!54nr4&^|p>__(=?(36yJagjQrBC!I zYS2DofAo$Ic3tltg*|fHyY?=(BiHRk-KQFi_-7p`d3Du2OnBen#PsxbBc8)&ZO-$* z-c^@lc(HW;+zP&#sgd_KJh?R)$m|rbT~xFBWS))x>?77wyPj%w-tPd-~zNUaccBraJAv%~lDJu0TA0#`_;{)a7q)4{V)KJ#*R1DZ=l~ zr{Ay1itp4q^ZuE3;Thd`#|roSu5vv-_g45KuE!1j9}AB>zi-j@zK8$nKB3(c65em- z=KN+yEyf?=eNR)&=Pf@wZF;pu<<0iz6MtNuUgH^8+4e!wd!6aW)AQF}Ut?|ke}+Hv Xn=b*y@828;#t4I_tDnm{r-UW|3vShE literal 0 HcmV?d00001 diff --git a/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_notebook.png b/apps/mobile/android/app/src/main/res/drawable-xxhdpi/ic_notebook.png new file mode 100644 index 0000000000000000000000000000000000000000..5b16af02d36f8cbda9a10da4766414bfcf6ab664 GIT binary patch literal 807 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2V0!22;uum9_jbdoV?>v(B#|4u}?S$KH! z!2|<|Khu3Kf2oR`=D+*yyYs4Xcf-u~J$lD--SuB)!sbDbpzFxVcnjx73-hpUj3T`)Xd~ zwD`*=dspxOlx?eDymt>Tmuynm)!EN~uB&+1K}{CNNA{7c^b+xjO}M}KbLbLaVuN)ZvOBTsifbKaR= z^H;z${+)4re!=|Y=Yatd!rcY&IqI#38#%roEejgT+-z&R{<*1zopr0EN?w?*IS* literal 0 HcmV?d00001 diff --git a/apps/mobile/app/components/properties/items.tsx b/apps/mobile/app/components/properties/items.tsx index 3a9def97e..34082fd27 100644 --- a/apps/mobile/app/components/properties/items.tsx +++ b/apps/mobile/app/components/properties/items.tsx @@ -57,6 +57,7 @@ const BOTTOM_BAR_ITEMS: ActionId[] = [ "export", "copy-link", "duplicate", + "launcher-shortcut", "trash" ]; @@ -76,6 +77,7 @@ const COLUMN_BAR_ITEMS: ActionId[] = [ "reorder", "rename-color", "rename-tag", + "launcher-shortcut", "restore", "trash", "delete" @@ -170,15 +172,15 @@ export const Items = ({ DDS.isTab ? AppFontSize.xxl : shouldShrink - ? AppFontSize.xxl - : AppFontSize.lg + ? AppFontSize.xxl + : AppFontSize.lg } color={ item.checked ? item.activeColor || colors.primary.accent : item.id.match(/(delete|trash)/g) - ? colors.error.icon - : colors.secondary.icon + ? colors.error.icon + : colors.secondary.icon } /> @@ -209,8 +211,8 @@ export const Items = ({ text: item.checked ? item.activeColor || colors.primary.accent : item.id === "delete" || item.id === "trash" - ? colors.error.paragraph - : colors.primary.paragraph + ? colors.error.paragraph + : colors.primary.paragraph }} testID={"icon-" + item.id} onPress={item.onPress} @@ -274,8 +276,8 @@ export const Items = ({ item.checked ? item.activeColor || colors.primary.accent : item.id === "delete" || item.id === "trash" - ? colors.error.icon - : colors.secondary.icon + ? colors.error.icon + : colors.secondary.icon } /> diff --git a/apps/mobile/app/hooks/use-actions.tsx b/apps/mobile/app/hooks/use-actions.tsx index c48c5c497..ebc83e520 100644 --- a/apps/mobile/app/hooks/use-actions.tsx +++ b/apps/mobile/app/hooks/use-actions.tsx @@ -23,6 +23,7 @@ import { Item, ItemReference, Note, + Notebook, VAULT_ERRORS } from "@notesnook/core"; import { strings } from "@notesnook/intl"; @@ -70,6 +71,7 @@ import { deleteItems } from "../utils/functions"; import { convertNoteToText } from "../utils/note-to-text"; import { sleep } from "../utils/time"; import { resetStoredState } from "./use-stored-state"; +import { NotesnookModule } from "../utils/notesnook-module"; export type ActionId = | "select" @@ -114,7 +116,8 @@ export type ActionId = | "remove-from-notebook" | "trash" | "default-homepage" - | "default-tag"; + | "default-tag" + | "launcher-shortcut"; export type Action = { id: ActionId; @@ -1207,5 +1210,25 @@ export const useActions = ({ }); } + if ( + Platform.OS === "android" && + (item.type === "tag" || item.type === "note" || item.type === "notebook") + ) { + actions.push({ + id: "launcher-shortcut", + title: strings.addToHome(), + icon: "cellphone-arrow-down", + onPress: async () => { + const added = await NotesnookModule.addShortcut( + item.id, + item.type, + item.title, + (item as Note).headline || (item as Notebook).description || "" + ); + console.log(added); + } + }); + } + return actions; }; diff --git a/apps/mobile/app/hooks/use-app-events.tsx b/apps/mobile/app/hooks/use-app-events.tsx index 83f20095b..62765f78b 100644 --- a/apps/mobile/app/hooks/use-app-events.tsx +++ b/apps/mobile/app/hooks/use-app-events.tsx @@ -171,7 +171,7 @@ const onAppOpenedFromURL = async (event: { url: string }) => { eSendEvent(eOnLoadNote, { newNote: true }); fluidTabsRef.current?.goToPage("editor", false); return; - } else if (url.startsWith("https://app.notesnook.com/open_note")) { + } else if (url.startsWith("https://app.notesnook.com/open_note?")) { const id = new URL(url).searchParams.get("id"); if (id) { const note = await db.notes.note(id); @@ -182,6 +182,26 @@ const onAppOpenedFromURL = async (event: { url: string }) => { fluidTabsRef.current?.goToPage("editor", false); } } + } else if (url.startsWith("https://app.notesnook.com/open_notebook?")) { + const id = new URL(url).searchParams.get("id"); + if (id) { + const notebook = await db.notebooks.notebook(id); + if (notebook) { + Navigation.navigate("Notebook", { + item: notebook + }); + } + } + } else if (url.startsWith("https://app.notesnook.com/open_tag?")) { + const id = new URL(url).searchParams.get("id"); + if (id) { + const tag = await db.tags.tag(id); + if (tag) { + Navigation.navigate("TaggedNotes", { + item: tag + }); + } + } } else if (url.startsWith("https://app.notesnook.com/open_reminder")) { const id = new URL(url).searchParams.get("id"); if (id) { @@ -774,23 +794,21 @@ export const useAppEvents = () => { } }; - if (!refValues.current.initialUrl) { - Linking.getInitialURL().then((url) => { - if (url) { - refValues.current.initialUrl = url; - } - }); - } let sub: NativeEventSubscription; if (!isAppLoading && !appLocked) { + if (!refValues.current.initialUrl) { + Linking.getInitialURL().then((url) => { + if (url) { + refValues.current.initialUrl = url; + onAppOpenedFromURL({ + url: refValues.current.initialUrl! + }); + } + }); + } + setTimeout(() => { sub = AppState.addEventListener("change", onAppStateChanged); - if (refValues.current.initialUrl) { - onAppOpenedFromURL({ - url: refValues.current.initialUrl! - }); - refValues.current.initialUrl = undefined; - } }, 1000); refValues.current.removeInternetStateListener = NetInfo.addEventListener( diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor.ts b/apps/mobile/app/screens/editor/tiptap/use-editor.ts index a9ce25b60..ea1afaee8 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor.ts +++ b/apps/mobile/app/screens/editor/tiptap/use-editor.ts @@ -1088,7 +1088,10 @@ export const useEditor = ( if (!state.current?.initialLoadCalled) { const url = await Linking.getInitialURL(); - let noteId = url && new URL(url).searchParams.get("id"); + let noteId = + url && + url.startsWith("https://app.notesnook.com/open_note?") && + new URL(url).searchParams.get("id"); if (noteId) { const note = await db.notes?.note(noteId); fluidTabsRef.current?.goToPage("editor"); diff --git a/apps/mobile/app/utils/notesnook-module.ts b/apps/mobile/app/utils/notesnook-module.ts index cf99eebac..aa337f35e 100644 --- a/apps/mobile/app/utils/notesnook-module.ts +++ b/apps/mobile/app/utils/notesnook-module.ts @@ -41,6 +41,19 @@ interface NotesnookModuleInterface { updateWidgetNote: (noteId: string, data: string) => void; updateReminderWidget: () => void; isGestureNavigationEnabled: () => boolean; + addShortcut: ( + id: string, + type: "note" | "notebook" | "tag", + title: string, + description?: string + ) => Promise; + removeShortcut: (id: string) => Promise; + updateShortcut: ( + id: string, + title: string, + description?: string + ) => Promise; + removeAllShortcuts: () => Promise; } export const NotesnookModule: NotesnookModuleInterface = Platform.select({ @@ -61,7 +74,11 @@ export const NotesnookModule: NotesnookModuleInterface = Platform.select({ hasWidgetNote: () => {}, updateWidgetNote: () => {}, updateReminderWidget: () => {}, - isGestureNavigationEnabled: () => true + isGestureNavigationEnabled: () => true, + addShortcut: () => Promise.resolve(false), + removeShortcut: () => Promise.resolve(false), + updateShortcut: () => Promise.resolve(false), + removeAllShortcuts: () => Promise.resolve(false) }, android: NativeModules.NNativeModule }); diff --git a/apps/mobile/scripts/optimize-fonts.mjs b/apps/mobile/scripts/optimize-fonts.mjs index a16fdcaf7..dc748cb14 100644 --- a/apps/mobile/scripts/optimize-fonts.mjs +++ b/apps/mobile/scripts/optimize-fonts.mjs @@ -115,7 +115,8 @@ const EXTRA_ICON_NAMES = [ "update", "notebook-minus", "calendar-blank", - "email-newsletter" + "email-newsletter", + "cellphone-arrow-down" ]; const __filename = fileURLToPath(import.meta.url); diff --git a/packages/intl/locale/en.po b/packages/intl/locale/en.po index 9cab86210..b40a97b48 100644 --- a/packages/intl/locale/en.po +++ b/packages/intl/locale/en.po @@ -735,6 +735,10 @@ msgstr "Add tags to multiple notes at once" msgid "Add to dictionary" msgstr "Add to dictionary" +#: src/strings.ts:2613 +msgid "Add to home" +msgstr "Add to home" + #: src/strings.ts:2476 msgid "Add to notebook" msgstr "Add to notebook" diff --git a/packages/intl/locale/pseudo-LOCALE.po b/packages/intl/locale/pseudo-LOCALE.po index 4b597f800..669131bca 100644 --- a/packages/intl/locale/pseudo-LOCALE.po +++ b/packages/intl/locale/pseudo-LOCALE.po @@ -735,6 +735,10 @@ msgstr "" msgid "Add to dictionary" msgstr "" +#: src/strings.ts:2613 +msgid "Add to home" +msgstr "" + #: src/strings.ts:2476 msgid "Add to notebook" msgstr "" diff --git a/packages/intl/src/strings.ts b/packages/intl/src/strings.ts index f71e13b4d..525f176e0 100644 --- a/packages/intl/src/strings.ts +++ b/packages/intl/src/strings.ts @@ -2609,5 +2609,6 @@ Use this if changes from other devices are not appearing on this device. This wi views: () => t`Views`, clickToUpdate: () => t`Click to update`, noPassword: () => t`No password`, - publishToTheWeb: () => t`Publish to the web` + publishToTheWeb: () => t`Publish to the web`, + addToHome: () => t`Add to home` };