diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle
index a064f6f..0c46991 100644
--- a/android/app/capacitor.build.gradle
+++ b/android/app/capacitor.build.gradle
@@ -9,7 +9,10 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
+ implementation project(':capacitor-app')
+ implementation project(':capacitor-browser')
implementation project(':capacitor-local-notifications')
+ implementation project(':capacitor-status-bar')
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a4bdfc2..6e38c67 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -40,6 +40,14 @@
+
+
+
+
+
+
+
+
void;
-};
-
-export const PdfViewer = as<'div', PdfViewerProps>(
- ({ className, name, src, requestClose, ...props }, ref) => {
- const containerRef = useRef(null);
- const scrollRef = useRef(null);
- const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
-
- const [pdfJSState, loadPdfJS] = usePdfJSLoader();
- const [docState, loadPdfDocument] = usePdfDocumentLoader(
- pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
- src
- );
- const isLoading =
- pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
- const isError =
- pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
- const [pageNo, setPageNo] = useState(1);
- const [jumpAnchor, setJumpAnchor] = useState();
-
- useEffect(() => {
- loadPdfJS();
- }, [loadPdfJS]);
- useEffect(() => {
- if (pdfJSState.status === AsyncStatus.Success) {
- loadPdfDocument();
- }
- }, [pdfJSState, loadPdfDocument]);
-
- useEffect(() => {
- if (docState.status === AsyncStatus.Success) {
- const doc = docState.data;
- if (pageNo < 0 || pageNo > doc.numPages) return;
- createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
- const container = containerRef.current;
- if (!container) return;
- container.textContent = '';
- container.append(canvas);
- scrollRef.current?.scrollTo({
- top: 0,
- });
- });
- }
- }, [docState, pageNo, zoom]);
-
- const handleDownload = async () => {
- try {
- const res = await fetch(src);
- if (!res.ok) throw new Error('Failed to fetch PDF');
- await saveMediaBlob(await res.blob(), name);
- } catch (error) {
- console.warn('[PdfViewer] Failed to download:', error);
- }
- };
-
- const handleJumpSubmit: FormEventHandler = (evt) => {
- evt.preventDefault();
- if (docState.status !== AsyncStatus.Success) return;
- const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
- if (!jumpInput) return;
- const jumpTo = parseInt(jumpInput.value, 10);
- setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
- setJumpAnchor(undefined);
- };
-
- const handlePrevPage = () => {
- setPageNo((n) => Math.max(n - 1, 1));
- };
-
- const handleNextPage = () => {
- if (docState.status !== AsyncStatus.Success) return;
- setPageNo((n) => Math.min(n + 1, docState.data.numPages));
- };
-
- const handleOpenJump: MouseEventHandler = (evt) => {
- setJumpAnchor(evt.currentTarget.getBoundingClientRect());
- };
-
- return (
-
-
-
-
-
-
-
- {name}
-
-
-
-
-
-
- setZoom(zoom === 1 ? 2 : 1)}>
- {Math.round(zoom * 100)}%
-
- 1 ? 'Success' : 'SurfaceVariant'}
- outlined={zoom > 1}
- size="300"
- radii="Pill"
- onClick={zoomIn}
- aria-label="Zoom In"
- >
-
-
- }
- >
- Download
-
-
-
-
- {isLoading && }
- {isError && (
- <>
- Failed to load PDF
- }
- onClick={loadPdfJS}
- >
- Retry
-
- >
- )}
- {docState.status === AsyncStatus.Success && (
-
-
-
-
-
- )}
-
- {docState.status === AsyncStatus.Success && (
-
- )}
-
- );
- }
-);
diff --git a/overlay/src/app/components/message/FileHeader.tsx b/overlay/src/app/components/message/FileHeader.tsx
deleted file mode 100644
index d86d5ff..0000000
--- a/overlay/src/app/components/message/FileHeader.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
-import { Icon, Icons } from '../icons';
-import React, { ReactNode, useCallback } from 'react';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { mimeTypeToExt } from '../../utils/mimeTypes';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
-import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
-import {
- decryptFile,
- downloadEncryptedMedia,
- downloadMedia,
- mxcUrlToHttp,
-} from '../../utils/matrix';
-import { getCurrentAccessToken } from '../../utils/auth';
-import { saveMediaBlob } from '../../utils/saveMedia';
-
-const badgeStyles = { maxWidth: toRem(100) };
-
-type FileDownloadButtonProps = {
- filename: string;
- url: string;
- mimeType: string;
- encInfo?: EncryptedAttachmentInfo;
-};
-export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
-
- const [downloadState, download] = useAsyncCallback(
- useCallback(async () => {
- const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
- // Always use current session's token to avoid stale tokens during account switches
- const accessToken = getCurrentAccessToken();
- const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
- : await downloadMedia(mediaUrl, accessToken);
-
- await saveMediaBlob(fileContent, filename);
- return true;
- }, [mx, url, useAuthentication, mimeType, encInfo, filename])
- );
-
- const downloading = downloadState.status === AsyncStatus.Loading;
- const hasError = downloadState.status === AsyncStatus.Error;
- return (
-
- {downloading ? (
-
- ) : (
-
- )}
-
- );
-}
-
-export type FileHeaderProps = {
- body: string;
- mimeType: string;
- after?: ReactNode;
-};
-export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
-
-
-
-
- {mimeTypeToExt(mimeType)}
-
-
-
-
-
- {body}
-
-
- {after}
-
-));
diff --git a/overlay/src/app/components/message/content/FileContent.tsx b/overlay/src/app/components/message/content/FileContent.tsx
deleted file mode 100644
index 82f9706..0000000
--- a/overlay/src/app/components/message/content/FileContent.tsx
+++ /dev/null
@@ -1,303 +0,0 @@
-import React, { ReactNode, useCallback, useState } from 'react';
-import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
-import { Icon, Icons } from '../../icons';
-import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
-import { saveMediaBlob } from '../../../utils/saveMedia';
-import FocusTrap from 'focus-trap-react';
-import { IFileInfo } from '../../../../types/matrix/common';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { bytesToSize } from '../../../utils/common';
-import {
- READABLE_EXT_TO_MIME_TYPE,
- READABLE_TEXT_MIME_TYPES,
- getFileNameExt,
- mimeTypeToExt,
-} from '../../../utils/mimeTypes';
-import { stopPropagation } from '../../../utils/keyboard';
-import {
- decryptFile,
- downloadEncryptedMedia,
- downloadMedia,
- mxcUrlToHttp,
-} from '../../../utils/matrix';
-import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
-import { getCurrentAccessToken } from '../../../utils/auth';
-import { ModalWide } from '../../../styles/Modal.css';
-
-const renderErrorButton = (retry: () => void, text: string) => (
-
- Failed to load file!
-
- }
- position="Top"
- align="Center"
- >
- {(triggerRef) => (
- }
- >
-
- {text}
-
-
- )}
-
-);
-
-type RenderTextViewerProps = {
- name: string;
- text: string;
- langName: string;
- requestClose: () => void;
-};
-type ReadTextFileProps = {
- body: string;
- mimeType: string;
- url: string;
- encInfo?: EncryptedAttachmentInfo;
- renderViewer: (props: RenderTextViewerProps) => ReactNode;
-};
-export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
- const [textViewer, setTextViewer] = useState(false);
-
- const [textState, loadText] = useAsyncCallback(
- useCallback(async () => {
- const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
- // Always use current session's token to avoid stale tokens during account switches
- const accessToken = getCurrentAccessToken();
- const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
- : await downloadMedia(mediaUrl, accessToken);
-
- const text = fileContent.text();
- setTextViewer(true);
- return text;
- }, [mx, useAuthentication, mimeType, encInfo, url])
- );
-
- return (
- <>
- {textState.status === AsyncStatus.Success && (
- }>
-
- setTextViewer(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- evt.stopPropagation()}
- >
- {renderViewer({
- name: body,
- text: textState.data,
- langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
- ? mimeTypeToExt(mimeType)
- : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
- requestClose: () => setTextViewer(false),
- })}
-
-
-
-
- )}
- {textState.status === AsyncStatus.Error ? (
- renderErrorButton(loadText, 'Open File')
- ) : (
-
- )}
- >
- );
-}
-
-type RenderPdfViewerProps = {
- name: string;
- src: string;
- requestClose: () => void;
-};
-export type ReadPdfFileProps = {
- body: string;
- mimeType: string;
- url: string;
- encInfo?: EncryptedAttachmentInfo;
- renderViewer: (props: RenderPdfViewerProps) => ReactNode;
-};
-export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
- const [pdfViewer, setPdfViewer] = useState(false);
-
- const [pdfState, loadPdf] = useAsyncCallback(
- useCallback(async () => {
- const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
- // Always use current session's token to avoid stale tokens during account switches
- const accessToken = getCurrentAccessToken();
- const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
- : await downloadMedia(mediaUrl, accessToken);
- setPdfViewer(true);
- return URL.createObjectURL(fileContent);
- }, [mx, url, useAuthentication, mimeType, encInfo])
- );
-
- return (
- <>
- {pdfState.status === AsyncStatus.Success && (
- }>
-
- setPdfViewer(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- evt.stopPropagation()}
- >
- {renderViewer({
- name: body,
- src: pdfState.data,
- requestClose: () => setPdfViewer(false),
- })}
-
-
-
-
- )}
- {pdfState.status === AsyncStatus.Error ? (
- renderErrorButton(loadPdf, 'Open PDF')
- ) : (
-
- )}
- >
- );
-}
-
-export type DownloadFileProps = {
- body: string;
- mimeType: string;
- url: string;
- info: IFileInfo;
- encInfo?: EncryptedAttachmentInfo;
-};
-export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
-
- const [downloadState, download] = useAsyncCallback(
- useCallback(async () => {
- const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
- // Always use current session's token to avoid stale tokens during account switches
- const accessToken = getCurrentAccessToken();
- const fileContent = encInfo
- ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
- : await downloadMedia(mediaUrl, accessToken);
-
- await saveMediaBlob(fileContent, body);
- return fileContent;
- }, [mx, url, useAuthentication, mimeType, encInfo, body])
- );
-
- return downloadState.status === AsyncStatus.Error ? (
- renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
- ) : (
-
- );
-}
-
-type FileContentProps = {
- body: string;
- mimeType: string;
- renderAsTextFile: () => ReactNode;
- renderAsPdfFile: () => ReactNode;
-};
-export const FileContent = as<'div', FileContentProps>(
- ({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
-
- {(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
- READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
- renderAsTextFile()}
- {mimeType === 'application/pdf' && renderAsPdfFile()}
- {children}
-
- )
-);
diff --git a/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
index 25c47e1..4809ebc 100644
--- a/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
+++ b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
@@ -45,22 +45,42 @@ type MobileSwipeGestureHostProps = {
children: ReactNode;
};
+const IGNORE_BACK_SELECTOR =
+ 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]';
+
+const IGNORE_REPLY_SELECTOR =
+ 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]';
+
+/** True when the touch starts on (or inside) a horizontally scrollable region like an image carousel. */
+const isInsideHorizontalScroller = (target: EventTarget | null): boolean => {
+ if (!(target instanceof Element)) return false;
+
+ let el: Element | null = target;
+ while (el && el !== document.documentElement) {
+ if (el instanceof HTMLElement) {
+ const style = window.getComputedStyle(el);
+ const overflowX = style.overflowX;
+ if (
+ (overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'overlay') &&
+ el.scrollWidth > el.clientWidth + 1
+ ) {
+ return true;
+ }
+ }
+ el = el.parentElement;
+ }
+
+ return false;
+};
+
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
- return Boolean(
- target.closest(
- 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
- )
- );
+ return Boolean(target.closest(IGNORE_BACK_SELECTOR)) || isInsideHorizontalScroller(target);
};
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
- return Boolean(
- target.closest(
- 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
- )
- );
+ return Boolean(target.closest(IGNORE_REPLY_SELECTOR)) || isInsideHorizontalScroller(target);
};
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
diff --git a/overlay/src/app/components/video-viewer/VideoViewer.tsx b/overlay/src/app/components/video-viewer/VideoViewer.tsx
deleted file mode 100644
index 99a3176..0000000
--- a/overlay/src/app/components/video-viewer/VideoViewer.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from 'react';
-import classNames from 'classnames';
-import { Box, Chip, Header, IconButton, Text, as } from 'folds';
-import { Icon, Icons } from '../icons';
-import * as css from './VideoViewer.css';
-import { downloadAndSaveMedia } from '../../utils/saveMedia';
-import { getCurrentAccessToken } from '../../utils/auth';
-
-export type VideoViewerProps = {
- alt: string;
- src: string;
- requestClose: () => void;
-};
-
-export const VideoViewer = as<'div', VideoViewerProps>(
- ({ className, alt, src, requestClose, ...props }, ref) => {
- const handleDownload = async () => {
- try {
- await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
- } catch (error) {
- console.warn('[VideoViewer] Failed to download media:', error);
- try {
- window.open(src, '_blank');
- } catch {
- // ignore
- }
- }
- };
-
- return (
-
-
-
-
-
-
-
- {alt}
-
-
-
- }
- >
- Download
-
-
-
-
-
-
-
- );
- }
-);
diff --git a/overlay/src/app/hooks/usePathWithOrigin.ts b/overlay/src/app/hooks/usePathWithOrigin.ts
new file mode 100644
index 0000000..e4959f3
--- /dev/null
+++ b/overlay/src/app/hooks/usePathWithOrigin.ts
@@ -0,0 +1,49 @@
+import { useMemo } from 'react';
+import { useClientConfig } from './useClientConfig';
+import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
+import { isCapacitorNative, isElectron } from '../utils/tauri';
+
+/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
+export const ELECTRON_PROTOCOL_HOST = 'desktop';
+
+/** Authority shown on Matrix SSO consent for the Android Capacitor app. */
+export const ANDROID_PROTOCOL_HOST = 'android';
+
+/**
+ * Build an absolute URL for the given in-app path.
+ *
+ * In Electron / Capacitor, SSO (and other browser handoffs) must return via
+ * paarrot://{host}/... — a named host so consent UI identifies the app, and a
+ * real pathname (not a hash) because browsers strip fragments when launching
+ * custom protocols.
+ */
+export const usePathWithOrigin = (path: string): string => {
+ const { hashRouter } = useClientConfig();
+ const { origin } = window.location;
+
+ const pathWithOrigin = useMemo(() => {
+ if (isElectron() || isCapacitorNative()) {
+ const host = isElectron() ? ELECTRON_PROTOCOL_HOST : ANDROID_PROTOCOL_HOST;
+ const basename = trimSlash(hashRouter?.basename ?? '');
+ const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
+ const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
+ return `paarrot://${host}${route}`;
+ }
+
+ let url: string = trimSlash(origin);
+
+ url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
+ url = trimTrailingSlash(url);
+
+ if (hashRouter?.enabled) {
+ url += `/#/${trimSlash(hashRouter.basename ?? '')}`;
+ url = trimTrailingSlash(url);
+ }
+
+ url += `/${trimLeadingSlash(path)}`;
+
+ return url;
+ }, [path, hashRouter, origin]);
+
+ return pathWithOrigin;
+};
diff --git a/overlay/src/app/utils/androidDeepLink.ts b/overlay/src/app/utils/androidDeepLink.ts
new file mode 100644
index 0000000..b4a22bd
--- /dev/null
+++ b/overlay/src/app/utils/androidDeepLink.ts
@@ -0,0 +1,127 @@
+import { App } from '@capacitor/app';
+import { Browser } from '@capacitor/browser';
+import { isCapacitorNative } from './tauri';
+
+const PROTOCOL_SCHEME = 'paarrot';
+
+/**
+ * Converts a paarrot:// deep link into an in-app hash route.
+ * Supports:
+ * - Hash routes: paarrot://anything/#/login/server/
+ * - Path routes (SSO-safe): paarrot://android/login/server/?loginToken=...
+ * Preserves SSO loginToken from the URL query (Synapse inserts it before `#`).
+ */
+export function deepLinkToHashRoute(deepLink: string): string | null {
+ if (!deepLink) {
+ return null;
+ }
+
+ if (!deepLink.toLowerCase().startsWith(`${PROTOCOL_SCHEME}://`)) {
+ return null;
+ }
+
+ let hashRoute: string | null = null;
+ let loginToken: string | null = null;
+
+ try {
+ const parsed = new URL(deepLink);
+ loginToken = parsed.searchParams.get('loginToken');
+
+ if (parsed.hash && parsed.hash.length > 1) {
+ const hashValue = parsed.hash.slice(1);
+ hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
+ } else if (parsed.pathname && parsed.pathname !== '/') {
+ // paarrot://android/login/... → /login/...
+ hashRoute = parsed.pathname;
+ if (parsed.search && parsed.search.length > 1) {
+ const params = new URLSearchParams(parsed.search);
+ params.delete('loginToken');
+ const rest = params.toString();
+ if (rest) {
+ hashRoute += `?${rest}`;
+ }
+ }
+ }
+ } catch {
+ const hashIndex = deepLink.indexOf('#');
+ if (hashIndex !== -1) {
+ const hashValue = deepLink.slice(hashIndex + 1);
+ if (hashValue) {
+ hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
+ }
+ }
+
+ const match = deepLink.match(/[?&]loginToken=([^]+)/i);
+ if (match) {
+ try {
+ loginToken = decodeURIComponent(match[1]);
+ } catch {
+ loginToken = match[1];
+ }
+ }
+ }
+
+ if (loginToken) {
+ if (!hashRoute) {
+ hashRoute = '/';
+ }
+ const queryIndex = hashRoute.indexOf('?');
+ const pathPart = queryIndex === -1 ? hashRoute : hashRoute.slice(0, queryIndex);
+ const params = new URLSearchParams(queryIndex === -1 ? '' : hashRoute.slice(queryIndex + 1));
+ if (!params.has('loginToken')) {
+ params.set('loginToken', loginToken);
+ }
+ const query = params.toString();
+ hashRoute = query ? `${pathPart}?${query}` : pathPart;
+ }
+
+ return hashRoute;
+}
+
+async function applyDeepLink(deepLink: string): Promise {
+ const hashRoute = deepLinkToHashRoute(deepLink);
+ if (!hashRoute) {
+ return;
+ }
+
+ try {
+ await Browser.close();
+ } catch {
+ // Browser may already be closed or never opened.
+ }
+
+ const nextHash = hashRoute.startsWith('/') ? `#${hashRoute}` : `#/${hashRoute}`;
+ if (window.location.hash === nextHash) {
+ // Force TokenLogin to re-run if we land on the same route again.
+ window.location.hash = '#/';
+ window.setTimeout(() => {
+ window.location.hash = nextHash;
+ }, 0);
+ return;
+ }
+
+ window.location.hash = nextHash;
+}
+
+/**
+ * Listen for paarrot:// SSO returns (and cold-start launch URLs) on Capacitor Android.
+ */
+export async function initAndroidDeepLinks(): Promise {
+ if (!isCapacitorNative()) {
+ return;
+ }
+
+ try {
+ const launch = await App.getLaunchUrl();
+ if (launch?.url) {
+ await applyDeepLink(launch.url);
+ }
+ } catch (error) {
+ console.warn('[androidDeepLink] Failed to read launch URL:', error);
+ }
+
+ await App.addListener('appUrlOpen', ({ url }) => {
+ if (!url) return;
+ void applyDeepLink(url);
+ });
+}
diff --git a/overlay/src/app/utils/saveMedia.ts b/overlay/src/app/utils/registerAndroidMediaSaver.ts
similarity index 52%
rename from overlay/src/app/utils/saveMedia.ts
rename to overlay/src/app/utils/registerAndroidMediaSaver.ts
index 98e1474..c4c23d5 100644
--- a/overlay/src/app/utils/saveMedia.ts
+++ b/overlay/src/app/utils/registerAndroidMediaSaver.ts
@@ -1,7 +1,5 @@
-import FileSaver from 'file-saver';
import { Capacitor, registerPlugin } from '@capacitor/core';
-import { downloadMedia } from './matrix';
-import { getCurrentAccessToken } from './auth';
+import { registerMediaSaver } from './saveMedia';
interface AndroidShareHandlerSavePlugin {
saveFile(options: {
@@ -9,19 +7,10 @@ interface AndroidShareHandlerSavePlugin {
mimeType?: string;
base64: string;
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
- shareFile(options: {
- filename: string;
- mimeType?: string;
- base64: string;
- }): Promise<{ shared: boolean }>;
}
const AndroidShareHandler = registerPlugin('AndroidShareHandler');
-const isAndroidNative = (): boolean =>
- Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
-
-/** Convert a Blob to raw base64 (no data: prefix). */
const blobToBase64 = (blob: Blob): Promise =>
new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -62,42 +51,17 @@ const guessMimeFromName = (filename: string): string | undefined => {
};
/**
- * Persist a Blob to disk. On Capacitor Android, uses MediaStore via the native
- * ShareHandler plugin (FileSaver / `` does not work in WebView).
- * Elsewhere falls back to FileSaver.
+ * Wire Capacitor Android MediaStore saves into the core saveMedia helpers.
+ * Call once at app startup from the mobile overlay entry.
*/
-export const saveMediaBlob = async (blob: Blob, filename: string): Promise => {
- if (isAndroidNative()) {
+export function registerAndroidMediaSaver(): void {
+ if (!(Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android')) {
+ return;
+ }
+
+ registerMediaSaver(async (blob, filename) => {
const base64 = await blobToBase64(blob);
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
await AndroidShareHandler.saveFile({ filename, mimeType, base64 });
- return;
- }
- FileSaver.saveAs(blob, filename);
-};
-
-/**
- * Fetch media (auth download or blob:/http URL) and save it via [saveMediaBlob].
- */
-export const downloadAndSaveMedia = async (
- src: string,
- filename: string,
- accessToken?: string | null
-): Promise => {
- let blob: Blob;
- try {
- if (src.startsWith('blob:') || src.startsWith('data:')) {
- const res = await fetch(src);
- if (!res.ok) throw new Error(`Failed to fetch ${src}`);
- blob = await res.blob();
- } else {
- blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken());
- }
- } catch (error) {
- console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error);
- const res = await fetch(src);
- if (!res.ok) throw error;
- blob = await res.blob();
- }
- await saveMediaBlob(blob, filename);
-};
+ });
+}
diff --git a/overlay/src/app/utils/tauri.ts b/overlay/src/app/utils/tauri.ts
index f03994c..8bde8b2 100644
--- a/overlay/src/app/utils/tauri.ts
+++ b/overlay/src/app/utils/tauri.ts
@@ -4,7 +4,7 @@
*/
export const openExternalUrl = async (url: string): Promise => {
console.log('[openExternalUrl] called with:', url);
-
+
// Use Electron's shell.openExternal if in Electron
if (isElectron()) {
try {
@@ -18,7 +18,20 @@ export const openExternalUrl = async (url: string): Promise => {
console.warn('[openExternalUrl] Electron shell.openExternal failed:', err);
}
}
-
+
+ // Capacitor: SSO (and other handoffs) need Chrome Custom Tabs / in-app Browser
+ // so paarrot:// redirects return into the app instead of a blank external tab.
+ if (isCapacitorNative()) {
+ try {
+ const { Browser } = await import('@capacitor/browser');
+ await Browser.open({ url, presentationStyle: 'popover' });
+ console.log('[openExternalUrl] Capacitor Browser.open succeeded');
+ return;
+ } catch (err) {
+ console.warn('[openExternalUrl] Capacitor Browser.open failed:', err);
+ }
+ }
+
// Use Tauri for actual Tauri builds
if (isTauri() && !isElectron()) {
try {
@@ -30,7 +43,7 @@ export const openExternalUrl = async (url: string): Promise => {
return;
} catch (pluginErr) {
console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr);
-
+
// Fallback: try the custom command (useful if plugin fails due to ACL)
try {
console.log('[openExternalUrl] trying Tauri invoke command fallback...');
@@ -43,7 +56,7 @@ export const openExternalUrl = async (url: string): Promise => {
}
}
}
-
+
console.log('[openExternalUrl] falling back to window.open');
window.open(url, '_blank');
};
diff --git a/overlay/src/index.tsx b/overlay/src/index.tsx
index d710701..b9487f5 100644
--- a/overlay/src/index.tsx
+++ b/overlay/src/index.tsx
@@ -16,6 +16,8 @@ import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
+import { initAndroidDeepLinks } from './app/utils/androidDeepLink';
+import { registerAndroidMediaSaver } from './app/utils/registerAndroidMediaSaver';
// import i18n (needs to be bundled ;))
import './app/i18n';
@@ -28,6 +30,12 @@ applySafeAreaInsets();
// Enable View Transitions API for smooth navigation
enableViewTransitionsForNavigation();
+// Capacitor: paarrot:// SSO return closes Custom Tabs and lands on loginToken
+void initAndroidDeepLinks();
+
+// Capacitor Android: MediaStore saves for images / videos / files
+registerAndroidMediaSaver();
+
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =
diff --git a/package-lock.json b/package-lock.json
index 0710034..39d7f9f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,17 @@
{
"name": "paarrot",
- "version": "4.11.121",
+ "version": "4.11.150",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paarrot",
- "version": "4.11.121",
+ "version": "4.11.150",
"license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
+ "@capacitor/app": "8.0.1",
+ "@capacitor/browser": "8.0.1",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2",
@@ -33,6 +35,24 @@
"@capacitor/core": "^8.3.0"
}
},
+ "node_modules/@capacitor/app": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.1.tgz",
+ "integrity": "sha512-yeG3yyA0ETKqvgqexwHMBlmVOF13A1hRXzv/km0Ptv5TrNIZvZJK4MTI3uiqvnbHrzoJGP5DwWAjEXEfi90v3Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@capacitor/core": ">=8.0.0"
+ }
+ },
+ "node_modules/@capacitor/browser": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.1.tgz",
+ "integrity": "sha512-cCEYK9DGJvamfCKOh95QP+VafApblySDR/eodOcl1Qt+d/Y+NNPzc8NYm/dztQlRf3aMLJ7ne1HrRCfu2UOMnw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@capacitor/core": ">=8.0.0"
+ }
+ },
"node_modules/@capacitor/cli": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",
diff --git a/package.json b/package.json
index 6476778..7b732bd 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,8 @@
"license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
+ "@capacitor/app": "8.0.1",
+ "@capacitor/browser": "8.0.1",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2",