fix: sharing on android

This commit is contained in:
Johannes Millan 2025-10-29 15:48:21 +01:00
parent d5d106225a
commit 5fac5193e6
6 changed files with 593 additions and 98 deletions

View file

@ -21,6 +21,17 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<!-- FileProvider for secure file sharing via Share plugin -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Set FullscreenActivity as the default launcher -->
<activity
android:name=".FullscreenActivity"

14
package-lock.json generated
View file

@ -19111,6 +19111,20 @@
"uuid": "^11.1.0"
}
},
"node_modules/mermaid/node_modules/marked": {
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.1.tgz",
"integrity": "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==",
"dev": true,
"license": "MIT",
"optional": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",

View file

@ -25,7 +25,8 @@ export type ShareTarget =
| 'email'
| 'mastodon'
| 'clipboard-text'
| 'native';
| 'native'
| 'download';
/**
* Result of a share operation
@ -39,6 +40,10 @@ export interface ShareResult {
target?: ShareTarget;
/** Whether native share was attempted */
usedNative?: boolean;
/** Optional local filesystem path for download results */
path?: string;
/** Optional URI for native downloads */
uri?: string;
}
/**
@ -66,3 +71,30 @@ export interface ShareDialogOptions {
/** Pre-selected Mastodon instance */
mastodonInstance?: string;
}
/**
* Options for sharing a canvas element as an image
*/
export interface ShareCanvasImageParams {
/** Canvas element that should be shared */
canvas: HTMLCanvasElement;
/** Output filename including extension */
filename?: string;
/** Optional title to use for share dialogs */
shareTitle?: string;
/** Optional tagline configuration appended below the canvas */
tagline?: ShareCanvasTagline;
/** Disable download fallback when sharing is unavailable */
fallbackToDownload?: boolean;
/** Sharing mode, defaults to auto */
mode?: 'auto' | 'download-only';
}
export interface ShareCanvasTagline {
/** Tagline text rendered below the canvas image */
text: string;
/** Optional tagline area height; defaults to 48 */
height?: number;
/** Optional text color */
color?: string;
}

View file

@ -1,9 +1,17 @@
import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Capacitor } from '@capacitor/core';
import { Directory, Filesystem } from '@capacitor/filesystem';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { SnackService } from '../snack/snack.service';
import { SharePayload, ShareResult, ShareTarget, ShareTargetConfig } from './share.model';
import {
ShareCanvasImageParams,
ShareCanvasTagline,
SharePayload,
ShareResult,
ShareTarget,
ShareTargetConfig,
} from './share.model';
const FALLBACK_SHARE_URL = 'https://super-productivity.com';
@ -54,6 +62,54 @@ export class ShareService {
return 'failed';
}
canOpenDownloadResult(result: ShareResult): boolean {
if (!result) {
return false;
}
if (typeof window !== 'undefined' && result.path && (window as any).ea?.openPath) {
return true;
}
if (Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW) {
return false;
}
if (typeof window !== 'undefined' && (result.uri || result.path)) {
return true;
}
return false;
}
async openDownloadResult(result: ShareResult): Promise<void> {
if (!result) {
return;
}
const { uri, path } = result;
if (typeof window !== 'undefined' && path && (window as any).ea?.openPath) {
try {
(window as any).ea.openPath(path);
return;
} catch (error) {
console.warn('Failed to open path via Electron bridge:', error);
}
}
const candidate = uri ?? path;
if (typeof window !== 'undefined' && candidate) {
try {
window.open(candidate, '_blank', 'noopener');
return;
} catch (error) {
console.warn('Failed to open download in new window:', error);
}
}
}
async getShareSupport(): Promise<ShareSupport> {
if (typeof window === 'undefined') {
return 'none';
@ -115,6 +171,66 @@ export class ShareService {
}
}
/**
* Share a canvas element as an image, supporting native, web, and download fallbacks.
*/
async shareCanvasImage(params: ShareCanvasImageParams): Promise<ShareResult> {
if (typeof window === 'undefined' || !params?.canvas) {
return {
success: false,
error: 'Canvas not available',
};
}
const filename = this._sanitizeFilename(params.filename ?? 'shared-image.png');
const shareTitle = params.shareTitle ?? filename;
const canvasForExport = this._prepareCanvasForShare(params.canvas, params.tagline);
const dataUrl = canvasForExport.toDataURL('image/png', 1.0);
const base64 = this._extractBase64(dataUrl);
const blob = await this._canvasToBlob(canvasForExport);
if (!blob) {
return {
success: false,
error: 'Failed to export image',
};
}
if (params.mode === 'download-only') {
return this._saveCanvasDownload({
blob,
base64,
filename,
dataUrl,
});
}
if (base64) {
const nativeResult = await this._shareCanvasViaNative(base64, filename, shareTitle);
if (nativeResult.success || nativeResult.error === 'Share cancelled') {
return nativeResult;
}
}
const webResult = await this._shareCanvasViaWeb(blob, filename, shareTitle, dataUrl);
if (webResult.success || webResult.error === 'Share cancelled') {
return webResult;
}
if (params.fallbackToDownload !== false) {
return this._saveCanvasDownload({
blob,
base64,
filename,
dataUrl,
});
}
return {
success: false,
error: 'Share not available',
};
}
/**
* Try to use native share (Android, Web Share API).
* Public API for dialog component.
@ -617,6 +733,343 @@ export class ShareService {
return text.replace(/\p{Extended_Pictographic}|\uFE0F|\uFE0E|\u200D/gu, '');
}
private _prepareCanvasForShare(
canvas: HTMLCanvasElement,
tagline?: ShareCanvasTagline,
): HTMLCanvasElement {
if (!tagline?.text) {
return canvas;
}
const taglineHeight = Math.max(1, tagline.height ?? 48);
const newCanvas = document.createElement('canvas');
newCanvas.width = canvas.width;
newCanvas.height = canvas.height + taglineHeight;
const ctx = newCanvas.getContext('2d');
if (!ctx) {
return canvas;
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
ctx.drawImage(canvas, 0, 0);
ctx.fillStyle = tagline.color ?? 'rgba(0, 0, 0, 0.6)';
const baseFontSize = Math.max(20, Math.round(newCanvas.width / 40));
ctx.font = `${baseFontSize}px system-ui, -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const taglineOffset = taglineHeight / 2;
const taglineY = canvas.height + taglineOffset;
ctx.fillText(tagline.text, newCanvas.width / 2, taglineY);
return newCanvas;
}
private _canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), 'image/png', 1.0);
});
}
private async _shareCanvasViaNative(
base64: string,
filename: string,
title: string,
): Promise<ShareResult> {
const sharePlugin = await this._getCapacitorSharePlugin();
if (!sharePlugin) {
return {
success: false,
error: 'Native share not available',
};
}
const sanitizedName = this._sanitizeFilename(filename);
const relativePath = `shared-images/${Date.now()}-${sanitizedName}`;
let fileUrl: string | null = null;
let resolvedUri: string | null = null;
try {
const writeResult = await Filesystem.writeFile({
path: relativePath,
data: base64,
directory: Directory.Cache,
recursive: true,
});
resolvedUri =
writeResult.uri ??
(
await Filesystem.getUri({
directory: Directory.Cache,
path: relativePath,
})
).uri;
if (!resolvedUri) {
throw new Error('Failed to resolve native share uri');
}
fileUrl = resolvedUri.startsWith('file://')
? resolvedUri
: resolvedUri.startsWith('/')
? `file://${resolvedUri}`
: `file:///${resolvedUri}`;
console.debug('[ShareService] shareCanvasViaNative', {
resolvedUri,
fileUrl,
relativePath,
});
try {
const stat = await Filesystem.stat({
path: relativePath,
directory: Directory.Cache,
});
console.debug('[ShareService] shareCanvasViaNative stat', stat);
} catch (statError) {
console.warn('[ShareService] stat failed for shared image', statError);
}
const canShare = (await sharePlugin.canShare?.())?.value ?? true;
if (!canShare) {
return {
success: false,
error: 'Native share not available',
};
}
await sharePlugin.share({
title,
text: '',
files: [fileUrl],
dialogTitle: 'Share via',
});
this._snackService.open('Shared successfully!');
this._scheduleCacheCleanup(relativePath);
return {
success: true,
usedNative: true,
target: 'native',
};
} catch (error: any) {
if (error?.name === 'AbortError' || /Share canceled/i.test(error?.message)) {
return {
success: false,
error: 'Share cancelled',
};
}
console.warn('Native image share failed:', error, {
fileUrl: fileUrl ?? 'n/a',
resolvedUri: resolvedUri ?? 'n/a',
relativePath,
});
this._scheduleCacheCleanup(relativePath);
return {
success: false,
error: 'Native share failed',
};
}
}
private async _shareCanvasViaWeb(
blob: Blob,
filename: string,
title: string,
dataUrl: string,
): Promise<ShareResult> {
if (typeof navigator === 'undefined') {
return {
success: false,
error: 'Share not available',
};
}
try {
if (typeof File !== 'undefined') {
const file = new File([blob], filename, { type: 'image/png' });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title,
});
this._snackService.open('Shared successfully!');
return {
success: true,
usedNative: true,
target: 'native',
};
}
}
if (typeof navigator.share === 'function') {
await navigator.share({
title,
url: dataUrl,
});
this._snackService.open('Shared successfully!');
return {
success: true,
usedNative: true,
target: 'native',
};
}
} catch (error: any) {
if (error?.name === 'AbortError') {
return {
success: false,
error: 'Share cancelled',
};
}
console.warn('Web Share API failed:', error);
}
return {
success: false,
error: 'Share not available',
};
}
private async _saveCanvasDownload({
blob,
base64,
filename,
dataUrl,
}: {
blob: Blob;
base64?: string | null;
filename: string;
dataUrl: string;
}): Promise<ShareResult> {
if (base64 && (Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW)) {
const sanitizedName = this._sanitizeFilename(filename);
const relativePath = `shared-images/${Date.now()}-${sanitizedName}`;
try {
const writeResult = await Filesystem.writeFile({
path: relativePath,
data: base64,
directory: Directory.Documents,
recursive: true,
});
const uri =
writeResult.uri ??
(
await Filesystem.getUri({
directory: Directory.Documents,
path: relativePath,
})
).uri;
const storedPath = `Documents/${relativePath}`;
return {
success: true,
target: 'download',
usedNative: true,
path: storedPath,
uri,
};
} catch (error) {
console.warn('Native download failed, falling back to browser download:', error);
}
}
const downloaded = this._downloadBlob(blob, filename);
if (downloaded) {
return {
success: true,
target: 'download',
};
}
if (typeof window !== 'undefined' && dataUrl) {
try {
window.open(dataUrl, '_blank');
return {
success: true,
target: 'download',
};
} catch (error) {
console.warn('Opening image in new tab failed:', error);
}
}
return {
success: false,
error: 'Download failed',
};
}
private _downloadBlob(blob: Blob, filename: string): boolean {
if (typeof document === 'undefined') {
return false;
}
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
try {
anchor.click();
return true;
} catch (error) {
console.warn('Browser download failed:', error);
return false;
} finally {
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
}
private async _cleanupCacheFile(relativePath: string): Promise<void> {
if (!relativePath) {
return;
}
try {
await Filesystem.deleteFile({
path: relativePath,
directory: Directory.Cache,
});
} catch (cleanupError) {
console.warn('Failed to cleanup shared file:', cleanupError);
}
}
private _scheduleCacheCleanup(relativePath: string): void {
if (!relativePath) {
return;
}
setTimeout(() => {
void this._cleanupCacheFile(relativePath);
}, 15_000);
}
private _sanitizeFilename(filename: string): string {
const trimmed = filename.trim() || 'shared-image.png';
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]/g, '_');
if (!sanitized.toLowerCase().endsWith('.png')) {
return `${sanitized}.png`;
}
return sanitized;
}
private _extractBase64(dataUrl: string): string | null {
const commaIndex = dataUrl.indexOf(',');
if (commaIndex === -1) {
return null;
}
return dataUrl.slice(commaIndex + 1);
}
private async _detectShareSupport(): Promise<ShareSupport> {
if (await this._isCapacitorShareAvailable()) {
return 'native';
@ -642,15 +1095,24 @@ export class ShareService {
}
private async _getCapacitorSharePlugin(): Promise<any | null> {
if (!Capacitor.isNativePlatform() || typeof window === 'undefined') {
if (typeof window === 'undefined') {
return null;
}
const win = window as any;
const sharePlugin = win.Capacitor?.Plugins?.Share;
if (sharePlugin && typeof sharePlugin.share === 'function') {
return sharePlugin;
if (Capacitor.isNativePlatform()) {
const nativePlugin = win.Capacitor?.Plugins?.Share;
if (nativePlugin && typeof nativePlugin.share === 'function') {
return nativePlugin;
}
}
if (IS_ANDROID_WEB_VIEW) {
const webViewPlugin = win.Capacitor?.Plugins?.Share;
if (webViewPlugin && typeof webViewPlugin.share === 'function') {
return webViewPlugin;
}
}
return null;

View file

@ -21,6 +21,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MatIcon } from '@angular/material/icon';
import { SnackService } from '../../../core/snack/snack.service';
import { GlobalConfigService } from '../../config/global-config.service';
import { ShareService } from '../../../core/share/share.service';
interface DayData {
date: Date;
@ -48,6 +49,7 @@ export class ActivityHeatmapComponent {
private readonly _taskArchiveService = inject(TaskArchiveService);
private readonly _snackService = inject(SnackService);
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _shareService = inject(ShareService);
T: typeof T = T;
weeks: WeekData[] = [];
@ -450,35 +452,43 @@ export class ActivityHeatmapComponent {
const contextTitle = this._activeWorkContextTitle();
const canvas = this._renderToCanvas(data, contextTitle);
// Convert to blob
const blob: Blob | null = await new Promise((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/png', 1.0);
const result = await this._shareService.shareCanvasImage({
canvas,
filename: 'activity-heatmap.png',
shareTitle: 'Activity Heatmap',
});
if (!blob) {
throw new Error('Failed to generate image');
}
const file = new File([blob], 'activity-heatmap.png', { type: 'image/png' });
// Try native share API first
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: 'Activity Heatmap',
if (result.success) {
if (result.target === 'download') {
const message = result.path
? `Heatmap saved to ${result.path}`
: 'Heatmap saved to device storage';
const canOpen = this._shareService.canOpenDownloadResult(result);
const actionConfig = canOpen
? {
actionStr: T.GLOBAL_SNACK.FILE_DOWNLOADED_BTN,
actionFn: () => {
void this._shareService.openDownloadResult(result);
},
}
: {};
this._snackService.open({
type: 'SUCCESS',
msg: message,
isSkipTranslate: true,
...actionConfig,
});
}
} else if (result.error && result.error !== 'Share cancelled') {
console.error('Share failed:', result.error);
this._snackService.open({
type: 'ERROR',
msg: 'Failed to share heatmap',
});
} else {
// Fallback: Download the file
this._downloadFile(blob, 'activity-heatmap.png');
}
this._snackService.open({
type: 'SUCCESS',
msg: 'Heatmap shared successfully',
});
} catch (error: any) {
// User cancelled or error occurred
if (error?.name !== 'AbortError') {
const isAbort = error?.name === 'AbortError' || error?.error === 'Share cancelled';
if (!isAbort) {
console.error('Share failed:', error);
this._snackService.open({
type: 'ERROR',
@ -629,15 +639,4 @@ export class ActivityHeatmapComponent {
ctx.closePath();
ctx.fill();
}
private _downloadFile(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}

View file

@ -26,6 +26,9 @@ import { MatIconButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ShareService } from '../../../core/share/share.service';
import { SnackService } from '../../../core/snack/snack.service';
import { T } from '../../../t.const';
interface ChartClickEvent {
active: ActiveElement[];
@ -120,6 +123,8 @@ export class LazyChartComponent implements OnInit, OnDestroy {
private readonly chartLoaderService = inject(ChartLazyLoaderService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly shareService = inject(ShareService);
private readonly snackService = inject(SnackService);
isLoaded = false;
isSharing = false;
@ -205,26 +210,39 @@ export class LazyChartComponent implements OnInit, OnDestroy {
this.cdr.markForCheck();
try {
const decoratedCanvas = this._createCanvasWithTagline(this.chartInstance.canvas);
const blob: Blob | null = await new Promise((resolve) => {
decoratedCanvas.toBlob((b) => resolve(b), 'image/png', 1.0);
const result = await this.shareService.shareCanvasImage({
canvas: this.chartInstance.canvas,
filename: this.shareFileName,
shareTitle: this.shareFileName,
tagline: {
text: 'With the Super Productivity App',
},
});
if (!blob) {
throw new Error('Failed to export chart');
if (result.success && result.target === 'download') {
const message = result.path
? `Chart image saved to ${result.path}`
: 'Chart image saved to device storage';
const canOpen = this.shareService.canOpenDownloadResult(result);
const actionConfig = canOpen
? {
actionStr: T.GLOBAL_SNACK.FILE_DOWNLOADED_BTN,
actionFn: () => {
void this.shareService.openDownloadResult(result);
},
}
: {};
this.snackService.open({
type: 'SUCCESS',
msg: message,
isSkipTranslate: true,
...actionConfig,
});
}
const filename = this.shareFileName?.trim() || 'chart.png';
const file = new File([blob], filename, { type: 'image/png' });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: filename,
});
} else {
this._downloadFile(blob, filename);
if (!result.success && result.error && result.error !== 'Share cancelled') {
console.error('Share failed:', result.error);
}
} catch (error) {
console.error('Share failed:', error);
@ -233,45 +251,4 @@ export class LazyChartComponent implements OnInit, OnDestroy {
this.cdr.markForCheck();
}
}
private _createCanvasWithTagline(sourceCanvas: HTMLCanvasElement): HTMLCanvasElement {
const taglineHeight = 48;
const newCanvas = document.createElement('canvas');
newCanvas.width = sourceCanvas.width;
newCanvas.height = sourceCanvas.height + taglineHeight;
const ctx = newCanvas.getContext('2d');
if (!ctx) {
return sourceCanvas;
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
ctx.drawImage(sourceCanvas, 0, 0);
const shareLabel = `With the Super Productivity App`;
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
const baseFontSize = Math.max(20, Math.round(newCanvas.width / 40));
ctx.font = `${baseFontSize}px system-ui, -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const taglineOffset = taglineHeight / 2;
const taglineY = sourceCanvas.height + taglineOffset;
ctx.fillText(shareLabel, newCanvas.width / 2, taglineY);
return newCanvas;
}
private _downloadFile(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
}