From ef0aa2a8478950466933065d0f71ab33e762caec Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sun, 26 Apr 2026 14:32:16 +1000 Subject: [PATCH] feat: Implement Android share functionality and background sync - Add ShareRoomPicker component for selecting rooms to share Android intents. - Introduce AndroidShare utility for handling shared files and payloads from Android. - Implement background synchronization with UnifiedPush for Android notifications. - Enhance notification handling in Tauri and Capacitor environments. - Update TypeScript configuration for better module resolution. - Create Capacitor configuration file for Android integration. - Add SVG icon for notifications. - Refactor media URL authentication checks in matrix utility. --- capacitor.config.json | 12 + config.json | 8 +- public/res/svg/notification.svg | 15 + src/app/features/ShareRoomPicker.tsx | 255 +++++++++ .../notifications/SystemNotification.tsx | 140 ++++- src/app/hooks/useAuthenticatedMediaUrl.ts | 8 +- src/app/pages/client/ClientNonUIFeatures.tsx | 202 ++++++- src/app/utils/androidShare.ts | 73 +++ src/app/utils/backgroundSync.ts | 518 ++++++++++++++++++ src/app/utils/matrix.ts | 6 +- src/app/utils/tauri.ts | 131 +++++ tsconfig.json | 3 +- 12 files changed, 1354 insertions(+), 17 deletions(-) create mode 100644 capacitor.config.json create mode 100644 public/res/svg/notification.svg create mode 100644 src/app/features/ShareRoomPicker.tsx create mode 100644 src/app/utils/androidShare.ts create mode 100644 src/app/utils/backgroundSync.ts diff --git a/capacitor.config.json b/capacitor.config.json new file mode 100644 index 0000000..1ed63b7 --- /dev/null +++ b/capacitor.config.json @@ -0,0 +1,12 @@ +{ + "appId": "com.paarrot.app", + "appName": "Paarrot", + "webDir": "dist", + "bundledWebRuntime": false, + "plugins": { + "LocalNotifications": { + "smallIcon": "ic_stat_paarrot", + "iconColor": "#FF8A00" + } + } +} diff --git a/config.json b/config.json index 80bd7a0..57c577a 100644 --- a/config.json +++ b/config.json @@ -1,8 +1,6 @@ { - "defaultHomeserver": 0, + "defaultHomeserver": 2, "homeserverList": [ - "ruv.wtf", - "exau.dev", "converser.eu", "envs.net", "matrix.org", @@ -12,6 +10,10 @@ ], "allowCustomHomeservers": true, + "calling": { + "livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt" + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/public/res/svg/notification.svg b/public/res/svg/notification.svg new file mode 100644 index 0000000..2d22bcc --- /dev/null +++ b/public/res/svg/notification.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/app/features/ShareRoomPicker.tsx b/src/app/features/ShareRoomPicker.tsx new file mode 100644 index 0000000..bd10d0f --- /dev/null +++ b/src/app/features/ShareRoomPicker.tsx @@ -0,0 +1,255 @@ +import { useAtomValue } from 'jotai'; +import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import FocusTrap from 'focus-trap-react'; +import { + Avatar, + Box, + Header, + Icon, + IconButton, + Icons, + Input, + MenuItem, + Modal, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Text, + config, +} from 'folds'; +import { useMatrixClient } from '../hooks/useMatrixClient'; +import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom'; +import { allRoomsAtom } from '../state/room-list/roomList'; +import { mDirectAtom } from '../state/mDirectList'; +import { useDirects, useRooms } from '../state/hooks/roomList'; +import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room'; +import { RoomAvatar, RoomIcon } from '../components/room-avatar'; +import { nameInitials } from '../utils/common'; +import { useMediaAuthentication } from '../hooks/useMediaAuthentication'; +import { factoryRoomIdByActivity } from '../utils/sort'; +import { + SearchItemStrGetter, + useAsyncSearch, + UseAsyncSearchOptions, +} from '../hooks/useAsyncSearch'; +import { VirtualTile } from '../components/virtualizer'; +import { AndroidSharePayload } from '../utils/androidShare'; +import { stopPropagation } from '../utils/keyboard'; + +const SEARCH_OPTS: UseAsyncSearchOptions = { + limit: 200, + matchOptions: { + contain: true, + }, +}; + +type ShareRoomPickerProps = { + /** The pending share payload to display a preview of. */ + share: AndroidSharePayload; + /** Called when the user picks a room. */ + onPick: (roomId: string) => void; + /** Called when the user dismisses without picking. */ + onDismiss: () => void; +}; + +/** + * Full-screen modal that lets the user pick a room to send + * an incoming Android share intent into. + */ +export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const scrollRef = useRef(null); + + const mDirects = useAtomValue(mDirectAtom); + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + const rooms = useRooms(mx, allRoomsAtom, mDirects); + const directs = useDirects(mx, allRoomsAtom, mDirects); + + const allItems = useMemo( + () => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)), + [mx, rooms, directs] + ); + + const getRoomNameStr: SearchItemStrGetter = useCallback( + (rId) => getRoom(rId)?.name ?? rId, + [getRoom] + ); + + const [searchResult, searchRoom, resetSearch] = useAsyncSearch( + allItems, + getRoomNameStr, + SEARCH_OPTS + ); + + const items = searchResult ? searchResult.items : allItems; + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 48, + overscan: 8, + }); + const vItems = virtualizer.getVirtualItems(); + + const handleSearchChange: ChangeEventHandler = (evt) => { + const value = evt.currentTarget.value.trim(); + if (!value) { + resetSearch(); + return; + } + searchRoom(value); + }; + + const handleRoomClick: React.MouseEventHandler = (evt) => { + const roomId = evt.currentTarget.getAttribute('data-room-id'); + if (roomId) { + onPick(roomId); + } + }; + + const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80); + const fileCount = share.files.length; + + return ( + }> + + + + +
+ + Share to Room + {(previewText || fileCount > 0) && ( + + {previewText + ? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"` + : `${fileCount} file${fileCount !== 1 ? 's' : ''}`} + + )} + + + + + + +
+ + + } + placeholder="Search rooms…" + size="400" + variant="Background" + outlined + autoFocus + /> + + + + + {vItems.length === 0 && ( + + + {searchResult ? 'No Match Found' : 'No Rooms'} + + + {searchResult + ? `No rooms found for "${searchResult.query}".` + : 'You have no rooms to share into.'} + + + )} + + {vItems.map((vItem) => { + const roomId = items[vItem.index]; + const room = getRoom(roomId); + if (!room) return null; + const isDm = mDirects.has(roomId); + + const avatarSrc = isDm + ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) + : getRoomAvatarUrl(mx, room, 96, useAuthentication); + + return ( + + + {avatarSrc ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + } + > + + + {room.name} + + + + + ); + })} + + + +
+
+
+
+
+ ); +} diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index b002575..4b31548 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Box, Text, Switch, Button, color, Spinner } from 'folds'; import { IPusherRequest } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; @@ -10,6 +10,13 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri'; +import { + isBackgroundSyncSupported, + getBackgroundSyncStatus, + requestResetPushRegistration, + triggerBackgroundSyncPing, +} from '../../../utils/backgroundSync'; function EmailNotification() { const mx = useMatrixClient(); @@ -86,14 +93,15 @@ function EmailNotification() { export function SystemNotification() { const notifPermission = usePermissionState('notifications', getNotificationState()); + const capacitorNative = isCapacitorNative(); const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); const [isNotificationSounds, setIsNotificationSounds] = useSetting( settingsAtom, 'isNotificationSounds' ); - const requestNotificationPermission = () => { - window.Notification.requestPermission(); + const requestNotificationPermission = async () => { + await requestSystemNotificationPermission(); }; return ( @@ -125,7 +133,7 @@ export function SystemNotification() { ) : ( @@ -145,6 +153,7 @@ export function SystemNotification() { after={} /> + {isBackgroundSyncSupported() && } ); } + +type PushStatus = { + registered: boolean; + distributor: string; + endpoint: string; +}; + +/** Android-only section showing UnifiedPush registration status and controls. */ +function AndroidPushNotifications() { + const [status, setStatus] = useState(undefined); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + const s = await getBackgroundSyncStatus(); + setStatus( + s + ? { + registered: s.registered, + distributor: s.distributor || '', + endpoint: s.endpoint || '', + } + : undefined + ); + setLoading(false); + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const [resetState, reset] = useAsyncCallback( + useCallback(async () => { + await requestResetPushRegistration(); + await refresh(); + }, [refresh]) + ); + + const [pingState, ping] = useAsyncCallback( + useCallback(async () => { + await triggerBackgroundSyncPing('manual-test'); + }, []) + ); + + const isBusy = + loading || + resetState.status === AsyncStatus.Loading || + pingState.status === AsyncStatus.Loading; + + return ( + + Android Push (UnifiedPush) + + + Failed to read push status. + + ) : status.registered ? ( + <> + + {`Distributor: ${status.distributor || 'unknown'}`} + + + ) : ( + + Not registered. Tap "Reset" to choose a distributor app. + + ) + } + after={loading ? : undefined} + /> + void reset()} + > + {resetState.status === AsyncStatus.Loading ? ( + + ) : ( + Reset + )} + + } + /> + void ping()} + > + {pingState.status === AsyncStatus.Loading ? ( + + ) : ( + Send Test + )} + + } + /> + + + ); +} diff --git a/src/app/hooks/useAuthenticatedMediaUrl.ts b/src/app/hooks/useAuthenticatedMediaUrl.ts index 97dd085..0570d00 100644 --- a/src/app/hooks/useAuthenticatedMediaUrl.ts +++ b/src/app/hooks/useAuthenticatedMediaUrl.ts @@ -29,7 +29,9 @@ export const useAuthenticatedMediaUrl = ( // Check if this is an authenticated media URL const isAuthenticatedMediaUrl = src.includes('/_matrix/client/v1/media/download') || - src.includes('/_matrix/client/v1/media/thumbnail'); + src.includes('/_matrix/client/v1/media/thumbnail') || + (src.includes('/_matrix/media/') && + (src.includes('/download/') || src.includes('/thumbnail/'))); if (!isAuthenticatedMediaUrl) { setBlobUrl(src); @@ -99,7 +101,9 @@ export const useAuthenticatedMediaFetch = () => { async (src: string): Promise => { const isAuthenticatedMediaUrl = src.includes('/_matrix/client/v1/media/download') || - src.includes('/_matrix/client/v1/media/thumbnail'); + src.includes('/_matrix/client/v1/media/thumbnail') || + (src.includes('/_matrix/media/') && + (src.includes('/download/') || src.includes('/thumbnail/'))); if (!isAuthenticatedMediaUrl) { return src; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5973009..8f6072f 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,5 +1,6 @@ import { useAtomValue } from 'jotai'; -import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; +import { useAtom, useSetAtom } from 'jotai'; +import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; @@ -28,8 +29,35 @@ import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { isTauri, isElectron, sendNotification, setupNotificationTapListener } from '../../utils/tauri'; +import { plainToEditorInput, toPlainText } from '../../components/editor'; +import { ShareRoomPicker } from '../../features/ShareRoomPicker'; +import { + isTauri, + isElectron, + isCapacitorNative, + sendNotification, + setupNotificationTapListener, +} from '../../utils/tauri'; import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api'; +import { + startBackgroundSync, + stopBackgroundSync, + setAppForegroundState, +} from '../../utils/backgroundSync'; +import { + TUploadItem, + roomIdToMsgDraftAtomFamily, + roomIdToUploadItemsAtomFamily, +} from '../../state/room/roomInputDrafts'; +import { encryptFile } from '../../utils/matrix'; +import { + AndroidSharePayload, + clearPendingAndroidShare, + getPendingAndroidShare, + isAndroidShareSupported, + listenForAndroidShares, + materializeSharedFile, +} from '../../utils/androidShare'; /** * Applies the selected emoji style font to the document. @@ -118,7 +146,7 @@ function InviteNotifications() { }); } - if (isTauri() && !isElectron()) { + if ((isTauri() && !isElectron()) || isCapacitorNative()) { sendNotification({ title: 'Invitation', body, @@ -257,7 +285,7 @@ function MessageNotifications() { console.warn('[Notifications] flashFrame not available, isElectron:', isElectron()); } - if (isTauri() && !isElectron()) { + if ((isTauri() && !isElectron()) || isCapacitorNative()) { const roomPath = isDm ? getDirectRoomPath(roomId, eventId) : getHomeRoomPath(roomId, eventId); @@ -350,7 +378,10 @@ function MessageNotifications() { return; } - if (showNotifications && ((isTauri() && !isElectron()) || notificationPermission('granted'))) { + if ( + showNotifications && + ((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted')) + ) { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); const content = mEvent.getContent(); @@ -404,6 +435,36 @@ function MessageNotifications() { return null; } +/** + * Configures native Android push-ping background sync on login, keeps it + * informed of foreground state so it doesn't double-fire notifications, + * and clears it cleanly on unmount (logout). + * Only active on Android Capacitor builds. + */ +function BackgroundSyncSetup() { + const mx = useMatrixClient(); + // Try to make a simple fetch to verify component is rendering + try { + fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {}); + } catch {} + + useEffect(() => { + console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId()); + startBackgroundSync(mx); + + const onVisibility = () => setAppForegroundState(!document.hidden); + document.addEventListener('visibilitychange', onVisibility); + setAppForegroundState(!document.hidden); + + return () => { + document.removeEventListener('visibilitychange', onVisibility); + stopBackgroundSync(); + }; + }, [mx]); + + return null; +} + /** * Initializes the Paarrot API for Electron integration * Registers the navigate function and sets up IPC handlers @@ -454,6 +515,135 @@ function TaskbarFlashStopper() { return null; } +function AndroidShareIntentHandler() { + const mx = useMatrixClient(); + const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); + const [pendingShare, setPendingShare] = useState(null); + const [pickedRoomId, setPickedRoomId] = useState(null); + const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__')); + const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__')); + + const applyPendingShare = useCallback( + async (share: AndroidSharePayload, roomId: string) => { + const room = mx.getRoom(roomId); + if (!room) return false; + + const nextParts = [share.subject?.trim(), share.text?.trim()].filter( + (value): value is string => !!value + ); + if (nextParts.length > 0) { + const existingText = toPlainText(msgDraft, isMarkdown).trim(); + const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n'); + setMsgDraft(plainToEditorInput(nextText, isMarkdown)); + } + + if (share.files.length > 0) { + const uploadItems: TUploadItem[] = []; + for (const sharedFile of share.files) { + const originalFile = await materializeSharedFile(sharedFile, share.receivedAt); + if (room.hasEncryptionStateEvent()) { + const encrypted = await encryptFile(originalFile); + uploadItems.push({ + file: encrypted.file, + originalFile, + metadata: { markedAsSpoiler: false }, + encInfo: encrypted.encInfo, + }); + continue; + } + + uploadItems.push({ + file: originalFile, + originalFile, + metadata: { markedAsSpoiler: false }, + encInfo: undefined, + }); + } + + setUploadItems({ + type: 'PUT', + item: uploadItems, + }); + } + + await clearPendingAndroidShare(); + return true; + }, + [isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems] + ); + + const handlePickRoom = useCallback( + (roomId: string) => { + setPickedRoomId(roomId); + if (!pendingShare) return; + + applyPendingShare(pendingShare, roomId) + .then((applied) => { + if (applied) { + setPendingShare(null); + setPickedRoomId(null); + } + }) + .catch((err) => { + console.error('[AndroidShare] Failed to apply share after pick:', err); + setPendingShare(null); + setPickedRoomId(null); + }); + }, + [applyPendingShare, pendingShare] + ); + + const handleDismiss = useCallback(() => { + clearPendingAndroidShare().catch(() => {}); + setPendingShare(null); + setPickedRoomId(null); + }, []); + + useEffect(() => { + if (!isAndroidShareSupported()) return; + + let mounted = true; + let listenerHandle: { remove: () => Promise } | undefined; + + getPendingAndroidShare() + .then((share) => { + if (mounted && share) { + setPendingShare(share); + } + }) + .catch((err) => { + console.error('[AndroidShare] Failed to get pending share:', err); + }); + + listenForAndroidShares((share) => { + if (mounted) { + setPendingShare(share); + } + }) + .then((handle) => { + listenerHandle = handle; + }) + .catch((err) => { + console.error('[AndroidShare] Failed to listen for shares:', err); + }); + + return () => { + mounted = false; + void listenerHandle?.remove(); + }; + }, []); + + if (!pendingShare) return null; + + return ( + + ); +} + type ClientNonUIFeaturesProps = { children: ReactNode; }; @@ -466,8 +656,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + + {children} ); diff --git a/src/app/utils/androidShare.ts b/src/app/utils/androidShare.ts new file mode 100644 index 0000000..445cc96 --- /dev/null +++ b/src/app/utils/androidShare.ts @@ -0,0 +1,73 @@ +import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core'; + +/** Metadata for a file received from an Android share intent. */ +export type AndroidSharedFile = { + path: string; + name: string; + mimeType: string; + size: number; +}; + +/** Payload persisted by the native Android share handler. */ +export type AndroidSharePayload = { + text?: string; + subject?: string; + files: AndroidSharedFile[]; + receivedAt: number; +}; + +interface AndroidShareHandlerPlugin { + /** Returns the last pending share payload, if one exists. */ + getPendingShare(): Promise<{ share: AndroidSharePayload | null }>; + /** Clears the last pending share payload after it has been consumed. */ + clearPendingShare(): Promise; + addListener( + eventName: 'shareReceived', + listenerFunc: (payload: AndroidSharePayload) => void + ): Promise; +} + +const AndroidShareHandler = registerPlugin('AndroidShareHandler'); + +/** Returns true when the Android share bridge is available. */ +export const isAndroidShareSupported = (): boolean => + Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android'; + +/** Fetches the current pending native share payload. */ +export const getPendingAndroidShare = async (): Promise => { + if (!isAndroidShareSupported()) return null; + const result = await AndroidShareHandler.getPendingShare(); + return result.share; +}; + +/** Clears the native pending share payload. */ +export const clearPendingAndroidShare = async (): Promise => { + if (!isAndroidShareSupported()) return; + await AndroidShareHandler.clearPendingShare(); +}; + +/** Subscribes to new incoming native share payloads. */ +export const listenForAndroidShares = async ( + listener: (payload: AndroidSharePayload) => void +): Promise => { + if (!isAndroidShareSupported()) return undefined; + return AndroidShareHandler.addListener('shareReceived', listener); +}; + +/** Converts a cached native shared file into a browser File for upload. */ +export const materializeSharedFile = async ( + sharedFile: AndroidSharedFile, + receivedAt: number +): Promise => { + const fileUrl = Capacitor.convertFileSrc(sharedFile.path); + const response = await fetch(fileUrl); + if (!response.ok) { + throw new Error(`Failed to read shared file: ${sharedFile.name}`); + } + + const blob = await response.blob(); + return new File([blob], sharedFile.name, { + type: sharedFile.mimeType || blob.type, + lastModified: receivedAt, + }); +}; \ No newline at end of file diff --git a/src/app/utils/backgroundSync.ts b/src/app/utils/backgroundSync.ts new file mode 100644 index 0000000..9f31676 --- /dev/null +++ b/src/app/utils/backgroundSync.ts @@ -0,0 +1,518 @@ +import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core'; +import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk'; + +type UnifiedPushStatus = { + running: boolean; + endpoint: string; + instance: string; + registered: boolean; + distributor: string; + distributors: string[] | string; +}; + +type UnifiedPushEndpointEvent = { + endpoint: string; + previousEndpoint: string; + instance: string; +}; + +type UnifiedPushUnregisteredEvent = { + previousEndpoint: string; + instance: string; +}; + +type UnifiedPushRegistrationFailedEvent = { + reason: string; + instance: string; +}; + +interface MatrixBackgroundSyncPlugin { + /** Persist credentials and request UnifiedPush registration. */ + start(options: { + homeserverUrl: string; + accessToken: string; + userId: string; + deviceId: string; + }): Promise; + /** Trigger a one-shot fetch as if a push ping arrived. */ + triggerPing(options: { reason?: string }): Promise; + /** Re-open distributor setup flow and retry registration. */ + requestDistributorSetup(): Promise<{ success: boolean }>; + /** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */ + stop(): Promise; + /** + * Notify the service whether the app UI is visible. + * When foreground is true, the service suppresses native notifications + * because the JS layer handles them via LocalNotifications. + */ + setAppForeground(options: { foreground: boolean }): Promise; + /** Returns fetch state and current UnifiedPush registration details. */ + getStatus(): Promise; + addListener( + eventName: 'unifiedPushNewEndpoint', + listenerFunc: (event: UnifiedPushEndpointEvent) => void + ): Promise; + addListener( + eventName: 'unifiedPushUnregistered', + listenerFunc: (event: UnifiedPushUnregisteredEvent) => void + ): Promise; + addListener( + eventName: 'unifiedPushRegistrationFailed', + listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void + ): Promise; +} + +type StoredPusherState = { + endpoint: string; + appId: string; +}; + +const MatrixBackgroundSync = registerPlugin('MatrixBackgroundSync'); +const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify'; +const PUSHER_APP_ID_BASE = 'com.paarrot.app.android'; +const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush'; + +/** Returns true when the current platform is Android Capacitor. */ +export const isBackgroundSyncSupported = (): boolean => + Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android'; + +const getStoredPusherKey = (userId: string | null, deviceId: string | null): string => + `${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`; + +const buildPusherAppId = (deviceId: string | null): string => { + const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE; + return raw.length > 64 ? raw.slice(0, 64) : raw; +}; + +const loadStoredPusherState = ( + userId: string | null, + deviceId: string | null +): StoredPusherState | undefined => { + if (typeof window === 'undefined' || !window.localStorage) return undefined; + + const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId)); + if (!raw) return undefined; + + try { + return JSON.parse(raw) as StoredPusherState; + } catch { + return undefined; + } +}; + +const saveStoredPusherState = ( + userId: string | null, + deviceId: string | null, + state: StoredPusherState +): void => { + if (typeof window === 'undefined' || !window.localStorage) return; + window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state)); +}; + +const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => { + if (typeof window === 'undefined' || !window.localStorage) return; + window.localStorage.removeItem(getStoredPusherKey(userId, deviceId)); +}; + +const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => { + if (Array.isArray(raw)) return raw; + if (typeof raw !== 'string') return []; + + const trimmed = raw.trim(); + if (!trimmed || trimmed === '[]') return []; + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return trimmed + .slice(1, -1) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + } + + return [trimmed]; +}; + +const resolveUnifiedPushGateway = async (endpoint: string): Promise => { + try { + const discoveryUrl = new URL(endpoint); + discoveryUrl.pathname = '/_matrix/push/v1/notify'; + discoveryUrl.search = ''; + + const response = await fetch(discoveryUrl.toString(), { method: 'GET' }); + if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY; + + const body = (await response.json()) as { + gateway?: string; + unifiedpush?: { gateway?: string }; + }; + + if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') { + return discoveryUrl.toString(); + } + } catch (err) { + console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err); + } + + return DEFAULT_UNIFIED_PUSH_GATEWAY; +}; + +class AndroidUnifiedPushManager { + private client: MatrixClient | undefined; + + private listenerHandles: PluginListenerHandle[] = []; + + private listenersReady = false; + + private distributorSetupAttempted = false; + + private distributorPromptShown = false; + + /** Start native UnifiedPush registration and synchronize the Matrix pusher. */ + async start(mx: MatrixClient): Promise { + console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform()); + + if (!isBackgroundSyncSupported()) { + console.log('[BackgroundSync] Background sync not supported on this platform'); + return; + } + + const homeserverUrl = mx.getHomeserverUrl(); + const accessToken = mx.getAccessToken(); + const userId = mx.getUserId(); + const deviceId = mx.getDeviceId(); + + console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl }); + + if (!homeserverUrl || !accessToken || !userId) { + console.warn('[BackgroundSync] Missing credentials, not starting'); + return; + } + + this.client = mx; + this.distributorSetupAttempted = false; + this.distributorPromptShown = false; + await this.ensureListeners(); + + try { + console.log('[BackgroundSync] Calling plugin.start()...'); + await MatrixBackgroundSync.start({ + homeserverUrl, + accessToken, + userId, + deviceId: deviceId ?? '', + }); + console.log('[BackgroundSync] plugin.start() completed'); + } catch (err) { + console.error('[BackgroundSync] plugin.start() failed:', err); + throw err; + } + + await this.syncExistingEndpoint(); + await this.ensureDistributorPromptFromStatus(); + console.log('[BackgroundSync] UnifiedPush registration requested'); + } + + /** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */ + async stop(): Promise { + if (!isBackgroundSyncSupported()) return; + + const client = this.client; + if (client) { + const deviceId = client.getDeviceId(); + const userId = client.getUserId(); + const status = await this.safeGetStatus(); + const stored = loadStoredPusherState(userId, deviceId); + const endpoint = status?.endpoint || stored?.endpoint; + const appId = stored?.appId ?? buildPusherAppId(deviceId); + + if (endpoint) { + await this.removePusher(client, endpoint, appId); + } + clearStoredPusherState(userId, deviceId); + } + + await MatrixBackgroundSync.stop(); + await this.disposeListeners(); + this.client = undefined; + console.log('[BackgroundSync] UnifiedPush stopped'); + } + + /** Update the Matrix pusher when a new native endpoint is published. */ + private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise { + const client = this.client; + if (!client) return; + + if (event.previousEndpoint) { + await this.removePusher(client, event.previousEndpoint); + } + + await this.upsertPusher(client, event.endpoint); + } + + /** Remove the Matrix pusher when UnifiedPush unregisters this instance. */ + private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise { + const client = this.client; + if (!client) return; + + const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId()); + const endpoint = event.previousEndpoint || stored?.endpoint; + const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId()); + + if (endpoint) { + await this.removePusher(client, endpoint, appId); + } + + clearStoredPusherState(client.getUserId(), client.getDeviceId()); + } + + /** Log native registration failures so the missing distributor path is visible. */ + private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void { + console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason); + + if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) { + return; + } + + this.distributorSetupAttempted = true; + void this.tryRequestDistributorSetup(); + } + + /** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */ + private async tryRequestDistributorSetup(): Promise { + try { + const setupResult = await MatrixBackgroundSync.requestDistributorSetup(); + console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult); + const status = await this.safeGetStatus(); + console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status); + const distributors = normalizeDistributors(status?.distributors ?? []); + if (distributors.length === 0) { + console.error( + '[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.' + ); + this.showNoDistributorPrompt(); + } + } catch (err) { + console.warn('[BackgroundSync] requestDistributorSetup failed:', err); + } + } + + /** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */ + private async ensureDistributorPromptFromStatus(): Promise { + const status = await this.safeGetStatus(); + if (!status) return; + + const distributors = normalizeDistributors(status.distributors); + if (!status.registered && distributors.length === 0) { + console.error( + '[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.' + ); + this.showNoDistributorPrompt(); + } + } + + /** Show a one-time actionable prompt when no distributor app is installed. */ + private showNoDistributorPrompt(): void { + if (this.distributorPromptShown) return; + this.distributorPromptShown = true; + + const message = + 'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.'; + const docsUrl = 'https://unifiedpush.org/users/distributors/'; + + if (typeof window === 'undefined') return; + + try { + const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`); + if (openDocs) { + window.open(docsUrl, '_blank', 'noopener,noreferrer'); + } + } catch { + window.alert(message); + } + } + + /** Install plugin listeners once for the active Matrix client. */ + private async ensureListeners(): Promise { + if (this.listenersReady) return; + + this.listenerHandles = [ + await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => { + void this.handleNewEndpoint(event).catch((err) => { + console.error('[BackgroundSync] handleNewEndpoint failed:', err); + }); + }), + await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => { + void this.handleUnregistered(event); + }), + await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => { + this.handleRegistrationFailed(event); + }), + ]; + this.listenersReady = true; + } + + /** Remove all plugin listeners when the manager stops. */ + private async disposeListeners(): Promise { + await Promise.all(this.listenerHandles.map((handle) => handle.remove())); + this.listenerHandles = []; + this.listenersReady = false; + } + + /** Reconcile an already-persisted native endpoint after app startup. */ + private async syncExistingEndpoint(): Promise { + const client = this.client; + if (!client) return; + + const status = await this.safeGetStatus(); + console.log('[BackgroundSync] Native status before pusher sync:', status); + if (status?.registered && status.endpoint) { + await this.upsertPusher(client, status.endpoint); + } + } + + /** Create or refresh the Matrix HTTP pusher for the current device. */ + private async upsertPusher(mx: MatrixClient, endpoint: string): Promise { + const deviceId = mx.getDeviceId(); + const userId = mx.getUserId(); + const appId = buildPusherAppId(deviceId); + const gatewayUrl = await resolveUnifiedPushGateway(endpoint); + + console.log('[BackgroundSync] Upserting Matrix pusher', { + appId, + endpoint, + gatewayUrl, + deviceId, + userId, + }); + + try { + await mx.setPusher({ + kind: 'http', + app_id: appId, + pushkey: endpoint, + app_display_name: 'Paarrot', + device_display_name: deviceId ?? 'Android', + lang: 'en', + data: { + url: gatewayUrl, + format: 'event_id_only', + }, + append: false, + device_id: deviceId ?? undefined, + } as unknown as IPusherRequest); + + const pushers = (await mx.getPushers())?.pushers ?? []; + const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId); + console.log('[BackgroundSync] Matrix pusher upserted successfully', { + totalPushers: pushers.length, + foundThisPusher: Boolean(thisPusher), + }); + } catch (err) { + console.error('[BackgroundSync] Matrix pusher upsert failed:', err); + throw err; + } + + saveStoredPusherState(userId, deviceId, { endpoint, appId }); + } + + /** Remove a previously-registered Matrix HTTP pusher for this device. */ + private async removePusher( + mx: MatrixClient, + endpoint: string, + appId = buildPusherAppId(mx.getDeviceId()) + ): Promise { + try { + await mx.setPusher({ + pushkey: endpoint, + app_id: appId, + kind: null, + } as unknown as IPusherRequest); + } catch (err) { + console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err); + } + } + + /** Read native registration state without failing the caller. */ + private async safeGetStatus(): Promise { + try { + return await MatrixBackgroundSync.getStatus(); + } catch (err) { + console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err); + return undefined; + } + } +} + +const unifiedPushManager = new AndroidUnifiedPushManager(); + +/** Start native UnifiedPush registration and sync the Matrix pusher. */ +export const startBackgroundSync = async (mx: MatrixClient): Promise => { + await unifiedPushManager.start(mx); +}; + +/** + * Manually triggers a one-shot native fetch. + * Useful for diagnostics and bridge testing. + */ +export const triggerBackgroundSyncPing = async (reason?: string): Promise => { + if (!isBackgroundSyncSupported()) return; + + try { + await MatrixBackgroundSync.triggerPing({ reason }); + } catch (err) { + console.warn('[BackgroundSync] triggerPing failed:', err); + } +}; + +/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */ +export const stopBackgroundSync = async (): Promise => { + await unifiedPushManager.stop(); +}; + +/** + * Tells the native service whether the app UI is in the foreground. + * Call when document visibility changes so the service can suppress + * duplicate notifications while the JS layer is active. + * @param foreground true if the WebView UI is currently visible + */ +export const setAppForegroundState = async (foreground: boolean): Promise => { + if (!isBackgroundSyncSupported()) return; + + try { + await MatrixBackgroundSync.setAppForeground({ foreground }); + } catch (err) { + console.warn('[BackgroundSync] setAppForeground failed:', err); + } +}; + +/** + * Re-opens the UnifiedPush distributor selection dialog. + * Allows users to re-select or change their push notification distributor without terminal access. + * Useful when the current endpoint becomes unavailable. + */ +export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => { + if (!isBackgroundSyncSupported()) { + console.warn('[BackgroundSync] Background sync not supported on this platform'); + return { success: false }; + } + + try { + const result = await MatrixBackgroundSync.requestDistributorSetup(); + console.log('[BackgroundSync] requestDistributorSetup completed:', result); + return result ?? { success: false }; + } catch (err) { + console.error('[BackgroundSync] requestDistributorSetup failed:', err); + return { success: false }; + } +}; + +/** Returns the current native UnifiedPush status, or undefined if unavailable. */ +export const getBackgroundSyncStatus = async (): Promise => { + if (!isBackgroundSyncSupported()) return undefined; + try { + return await MatrixBackgroundSync.getStatus(); + } catch (err) { + console.warn('[BackgroundSync] getStatus failed:', err); + return undefined; + } +}; diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 7058371..3de6911 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -23,7 +23,7 @@ const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName); -const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])([^\s:]+):(\S+)$/); +const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])([^\s:]+):(\S+)$/); const validMxId = (id: string): boolean => !!matchMxId(id); @@ -301,7 +301,9 @@ export const mxcUrlToHttp = ( */ export const isAuthenticatedMediaUrl = (url: string): boolean => url.includes('/_matrix/client/v1/media/download') || - url.includes('/_matrix/client/v1/media/thumbnail'); + url.includes('/_matrix/client/v1/media/thumbnail') || + url.includes('/_matrix/media/') && + (url.includes('/download/') || url.includes('/thumbnail/')); /** * Downloads media with optional authentication. diff --git a/src/app/utils/tauri.ts b/src/app/utils/tauri.ts index 45f82f7..c8f3545 100644 --- a/src/app/utils/tauri.ts +++ b/src/app/utils/tauri.ts @@ -116,6 +116,9 @@ export const isYouTubeStreamingAvailable = (): boolean => { /** Callback for notification tap actions */ let notificationTapCallback: ((path: string) => void) | null = null; +const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot'; +const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00'; + /** * Bring the Tauri window to the front and focus it */ @@ -190,6 +193,21 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void console.warn('Failed to set up Tauri notification tap listener:', err); } } + + if (isCapacitorNative()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => { + await focusWindow(); + const path = event?.notification?.extra?.path; + if (path && typeof path === 'string' && notificationTapCallback) { + notificationTapCallback(path); + } + }); + } catch (err) { + console.warn('Failed to set up Capacitor notification tap listener:', err); + } + } }; /** @@ -204,6 +222,15 @@ export const isTauri = (): boolean => export const isElectron = (): boolean => typeof window !== 'undefined' && 'electron' in window; +/** + * Check if we're running inside a Capacitor native app + */ +export const isCapacitorNative = (): boolean => { + if (typeof window === 'undefined') return false; + const cap = (window as any).Capacitor; + return Boolean(cap?.isNativePlatform?.() || cap?.getPlatform?.() === 'android' || cap?.getPlatform?.() === 'ios'); +}; + /** * Check if we're running on a mobile platform (Android/iOS) */ @@ -305,6 +332,78 @@ const ensureNotificationChannel = async (): Promise => { } }; +const ensureCapacitorNotificationChannel = async (): Promise => { + if (notificationChannelCreated || !isAndroid()) return; + + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + await LocalNotifications.createChannel({ + id: 'messages', + name: 'Messages', + description: 'Message notifications from Matrix', + importance: 5, + sound: 'default', + visibility: 1, + vibration: true, + lights: true, + }); + notificationChannelCreated = true; + } catch (err) { + console.warn('Failed to create Capacitor notification channel:', err); + } +}; + +/** + * Request notification permission across Electron, Tauri, Capacitor, and browser + */ +export const requestSystemNotificationPermission = async (): Promise => { + if (isElectron() || (isTauri() && !isElectron())) { + if (!('Notification' in window)) return false; + const permission = await Notification.requestPermission(); + return permission === 'granted'; + } + + if (isCapacitorNative()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + let perm = await LocalNotifications.checkPermissions(); + if (perm.display !== 'granted') { + perm = await LocalNotifications.requestPermissions(); + } + return perm.display === 'granted'; + } catch (err) { + console.warn('Capacitor notification permission request failed:', err); + return false; + } + } + + if (!('Notification' in window)) return false; + const permission = await Notification.requestPermission(); + return permission === 'granted'; +}; + +export const getSystemNotificationPermissionState = async (): Promise => { + if (isCapacitorNative()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + const perm = await LocalNotifications.checkPermissions(); + return perm.display === 'granted' ? 'granted' : 'prompt'; + } catch (err) { + console.warn('Failed to check Capacitor notification permission:', err); + return 'denied'; + } + } + + if ('Notification' in window) { + if (window.Notification.permission === 'default') { + return 'prompt'; + } + return window.Notification.permission; + } + + return 'denied'; +}; + /** * Send a notification using Tauri's notification plugin * Falls back to browser Notification API if not in Tauri @@ -369,6 +468,38 @@ export const sendNotification = async (options: { console.warn('Tauri notification failed:', err); } } + + if (isCapacitorNative()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + let perm = await LocalNotifications.checkPermissions(); + if (perm.display !== 'granted') { + perm = await LocalNotifications.requestPermissions(); + } + + if (perm.display === 'granted') { + await ensureCapacitorNotificationChannel(); + + const id = Math.floor(Date.now() % 2147483647); + await LocalNotifications.schedule({ + notifications: [ + { + id, + title, + body, + channelId: isAndroid() ? 'messages' : undefined, + smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined, + iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined, + extra: path ? { path } : undefined, + }, + ], + }); + } + return; + } catch (err) { + console.warn('Capacitor notification failed:', err); + } + } // Fallback to browser notification sendBrowserNotification({ title, body, icon, onClick }); diff --git a/tsconfig.json b/tsconfig.json index 7608e55..2324e05 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,9 +7,10 @@ "allowJs": true, "strict": true, "esModuleInterop": true, - "moduleResolution": "Node", + "moduleResolution": "bundler", "resolveJsonModule": true, "outDir": "dist", + "rootDir": "./src", "skipLibCheck": true, "lib": ["ES2016", "DOM"] },