mobile: add home shortcuts

Add ability to create shortcuts on launcher homescreen for notes, notebooks and tags.
This commit is contained in:
Ammar Ahmed
2025-12-22 18:59:36 +05:00
parent 50d0827972
commit 0a7f8631b6
15 changed files with 291 additions and 27 deletions

View File

@@ -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<ShortcutInfo> 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<ShortcutInfo> pinnedShortcuts = shortcutManager.getPinnedShortcuts();
if (!pinnedShortcuts.isEmpty()) {
List<String> 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];
}
}

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#008837"
android:alpha="1">
<path
android:fillColor="@android:color/white"
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,160Q720,160 720,160Q720,160 720,160L640,160L640,440L540,380L440,440L440,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800ZM240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800ZM440,440L540,380L640,440L640,440L540,380L440,440Z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

View File

@@ -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
}
/>
</Pressable>
@@ -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
}
/>

View File

@@ -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;
};

View File

@@ -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(

View File

@@ -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");

View File

@@ -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<boolean>;
removeShortcut: (id: string) => Promise<boolean>;
updateShortcut: (
id: string,
title: string,
description?: string
) => Promise<boolean>;
removeAllShortcuts: () => Promise<boolean>;
}
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
});

View File

@@ -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);

View File

@@ -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"

View File

@@ -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 ""

View File

@@ -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`
};