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.
This commit is contained in:
2026-04-26 14:32:16 +10:00
parent 7b69ed6df8
commit ef0aa2a847
12 changed files with 1354 additions and 17 deletions

View File

@@ -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<AndroidSharePayload | null>(null);
const [pickedRoomId, setPickedRoomId] = useState<string | null>(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<void> } | 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 (
<ShareRoomPicker
share={pendingShare}
onPick={handlePickRoom}
onDismiss={handleDismiss}
/>
);
}
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
@@ -466,8 +656,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<FaviconUpdater />
<InviteNotifications />
<MessageNotifications />
<BackgroundSyncSetup />
<PaarrotAPIInitializer />
<TaskbarFlashStopper />
<AndroidShareIntentHandler />
{children}
</>
);