super-productivity/src/app/features/android/android-interface.ts
Johannes Millan 519ea02167
fix(android): keep material icons aligned with system font scaling (#8992)
Counter-scale fixed-size font icons by the Android WebView text zoom while preserving accessible text and inline icon scaling. Covers Angular icons, raw Material Symbols, and shared pseudo-element icons.

Fixes #5694
2026-07-14 12:38:12 +02:00

304 lines
10 KiB
TypeScript

import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { nanoid } from 'nanoid';
import { BehaviorSubject, merge, Observable, ReplaySubject, Subject } from 'rxjs';
import { mapTo } from 'rxjs/operators';
import { DroidLog } from '../../core/log';
export interface AndroidShareData {
title: string;
subject: string;
type: 'FILE' | 'LINK' | 'IMG' | 'COMMAND' | 'NOTE';
path: string;
}
export interface AndroidInterface {
getVersion?(): string;
getTextZoom?(): number;
// Launches the native Play In-App Review card (play flavor). No-op on fdroid.
// The outcome is intentionally opaque (Play policy) — nothing is returned.
requestReview?(): void;
showToast(s: string): void;
// save
saveToDbWrapped(key: string, value: string): Promise<void>;
saveToDb(rId: string, key: string, value: string): void;
saveToDbCallback(rId: string): void;
// load
loadFromDbWrapped(key: string): Promise<string | null>;
loadFromDb(rId: string, key: string): void;
loadFromDbCallback(rId: string, data: string): void;
// remove
removeFromDbWrapped(key: string): Promise<void>;
removeFromDb(rId: string, key: string): void;
removeFromDbCallback(rId: string): void;
// clear db
clearDbWrapped(): Promise<void>;
clearDb(rId: string): void; // @deprecated
clearDbCallback(rId: string): void;
triggerGetShareData?(): void;
getPendingShareData?(): string | null;
// Foreground service methods for background time tracking
startTrackingService?(taskId: string, taskTitle: string, timeSpentMs: number): void;
stopTrackingService?(): void;
updateTrackingService?(timeSpentMs: number): void;
getTrackingElapsed?(): string;
openAppNotificationSettings?(): void;
// Foreground service methods for focus mode timer
startFocusModeService?(
title: string,
durationMs: number,
remainingMs: number,
isBreak: boolean,
isPaused: boolean,
taskTitle: string | null,
): void;
stopFocusModeService?(): void;
updateFocusModeService?(
title: string,
remainingMs: number,
isPaused: boolean,
isBreak: boolean,
taskTitle: string | null,
): void;
// Read back the live focus session for cold-start/resume recovery (#7855).
// Returns a JSON string, or 'null' when no focus session is running.
getFocusModeElapsed?(): string;
// Native reminder scheduling (snooze handled entirely in background)
scheduleNativeReminder?(
notificationId: number,
reminderId: string,
relatedId: string,
title: string,
reminderType: string,
triggerAtMs: number,
useAlarmStyle: boolean,
isOngoing: boolean,
): void;
cancelNativeReminder?(notificationId: number): void;
// Reminder tap queue - get task ID from notification tap (cold start)
getReminderTapQueue?(): string | null;
// Reminder done queue - get task IDs marked done from notifications
getReminderDoneQueue?(): string | null;
// Widget task queue - get queued tasks from home screen widget
getWidgetTaskQueue?(): string | null;
// Widget done queue - get pending done-state changes from the home screen
// widget as a JSON object string `{taskId: targetIsDone}` and clear the queue
getWidgetDoneQueue?(): string | null;
// Re-render the home screen widget from the current widget_data snapshot
updateWidget?(): void;
// Startup overlay
getStartupOverlayPartialText?(): string | null;
hideStartupOverlay?(): void;
dismissStartupOverlay?(): void;
// added here only
// ReplaySubject so cold-start onResume emissions arriving before the JS
// subscriber attaches are still delivered (relevant for tracking recovery
// after the WebView was killed in the background — issue #7390).
onResume$: ReplaySubject<void>;
onPause$: Subject<void>;
isInBackground$: Observable<boolean>;
isKeyboardShown$: Subject<boolean>;
onShareWithAttachment$: Subject<AndroidShareData>;
// Notification action callbacks
onPauseTracking$: Subject<void>;
onMarkTaskDone$: Subject<void>;
// Focus mode notification action callbacks
onFocusPause$: Subject<void>;
onFocusResume$: Subject<void>;
onFocusSkip$: Subject<void>;
onFocusComplete$: Subject<void>;
// Focus mode timer completion (native service detected timer reached 0)
onFocusModeTimerComplete$: Subject<boolean>; // boolean indicates isBreak
onForegroundServiceStartFailed$: ReplaySubject<ForegroundServiceStartFailure>;
// Reminder notification action callbacks
onReminderTap$: ReplaySubject<string>; // emits taskId
onReminderDone$: ReplaySubject<string>; // emits taskId
onReminderSnooze$: ReplaySubject<{ taskId: string; newRemindAt: number }>; // emits snooze events
getReminderSnoozeQueue?(): string | null;
// Contentless "drain now" signal after a widget done-checkbox tap while the app
// is alive; the queued IDs are always pulled via getWidgetDoneQueue() (cold
// start/resume drains are triggered by onResume$ instead)
onWidgetDoneDrainRequest$: Subject<void>;
// Background sync credential bridge (for WorkManager-based reminder cancellation)
setSuperSyncCredentials?(baseUrl: string, accessToken: string): void;
clearSuperSyncCredentials?(): void;
}
export type ForegroundServiceStartFailure = {
service: 'tracking' | 'focusMode';
reason: 'startNotAllowed' | 'promotionFailed';
};
// setInterval(() => {
// androidInterface.updatePermanentNotification?.(new Date().toString(), '', -1);
// }, 7000);
export const androidInterface: AndroidInterface = (window as any).SUPAndroid;
if (IS_ANDROID_WEB_VIEW) {
if (!androidInterface) {
throw new Error('Cannot initialize androidInterface');
}
androidInterface.onResume$ = new ReplaySubject(1);
androidInterface.onPause$ = new Subject();
androidInterface.onPauseTracking$ = new Subject();
androidInterface.onMarkTaskDone$ = new Subject();
androidInterface.onFocusPause$ = new Subject();
androidInterface.onFocusResume$ = new Subject();
androidInterface.onFocusSkip$ = new Subject();
androidInterface.onFocusComplete$ = new Subject();
androidInterface.onFocusModeTimerComplete$ = new Subject();
androidInterface.onForegroundServiceStartFailed$ = new ReplaySubject(3);
androidInterface.onReminderTap$ = new ReplaySubject(5);
androidInterface.onReminderDone$ = new ReplaySubject(20);
androidInterface.onReminderSnooze$ = new ReplaySubject(20);
androidInterface.onWidgetDoneDrainRequest$ = new Subject();
androidInterface.onShareWithAttachment$ = new ReplaySubject(1);
androidInterface.isKeyboardShown$ = new BehaviorSubject(false);
androidInterface.isInBackground$ = merge(
androidInterface.onResume$.pipe(mapTo(false)),
androidInterface.onPause$.pipe(mapTo(true)),
);
const requestMap: {
[key: string]: {
resolve: (returnVal?: any) => void;
reject: (error?: any) => void;
};
} = {};
const getRequestMapPromise = (rId: string): Promise<any> => {
return new Promise((resolve, reject) => {
requestMap[rId] = { resolve, reject };
});
};
androidInterface.saveToDbCallback = (rId: string) => {
requestMap[rId].resolve();
delete requestMap[rId];
};
androidInterface.saveToDbWrapped = (key: string, value: string): Promise<void> => {
const rId = nanoid();
androidInterface.saveToDb(rId, key, value);
return getRequestMapPromise(rId);
};
androidInterface.loadFromDbCallback = (rId: string, k: string, result?: string) => {
requestMap[rId].resolve(result || null);
delete requestMap[rId];
};
androidInterface.loadFromDbWrapped = (key: string): Promise<string | null> => {
const rId = nanoid();
androidInterface.loadFromDb(rId, key);
return getRequestMapPromise(rId);
};
androidInterface.removeFromDbWrapped = (key: string): Promise<void> => {
const rId = nanoid();
androidInterface.removeFromDb(rId, key);
return getRequestMapPromise(rId);
};
androidInterface.removeFromDbCallback = (rId: string) => {
requestMap[rId].resolve();
delete requestMap[rId];
};
androidInterface.clearDbWrapped = (): Promise<void> => {
const rId = nanoid();
androidInterface.clearDb?.(rId);
return getRequestMapPromise(rId);
};
androidInterface.clearDbCallback = (rId: string) => {
requestMap[rId].resolve();
delete requestMap[rId];
};
DroidLog.log('Android Web View interfaces initialized', androidInterface);
// Pull-based: retrieve share data persisted in SharedPreferences (survives process death)
try {
const pendingShare = androidInterface.getPendingShareData?.();
if (pendingShare) {
const parsed = JSON.parse(pendingShare);
DroidLog.log('Pulled pending share data from SharedPreferences');
androidInterface.onShareWithAttachment$.next(parsed);
}
} catch (e) {
DroidLog.err('Failed to parse pending share data', e);
}
// Pull-based: retrieve queued tap task ID from notification tap (cold start)
try {
const tapTaskId = androidInterface.getReminderTapQueue?.();
if (tapTaskId) {
DroidLog.log('Pulled reminder tap queue from SharedPreferences', tapTaskId);
androidInterface.onReminderTap$.next(tapTaskId);
}
} catch (e) {
DroidLog.err('Failed to parse reminder tap queue', e);
}
// Pull-based: retrieve queued "Done" task IDs from notification actions
try {
const doneQueue = androidInterface.getReminderDoneQueue?.();
if (doneQueue) {
const taskIds: string[] = JSON.parse(doneQueue);
DroidLog.log('Pulled reminder done queue from SharedPreferences', taskIds);
for (const id of taskIds) {
androidInterface.onReminderDone$.next(id);
}
}
} catch (e) {
DroidLog.err('Failed to parse reminder done queue', e);
}
// Pull-based: retrieve queued snooze events from notification actions
try {
const snoozeQueue = androidInterface.getReminderSnoozeQueue?.();
if (snoozeQueue) {
const events: { taskId: string; newRemindAt: number }[] = JSON.parse(snoozeQueue);
DroidLog.log('Pulled reminder snooze queue from SharedPreferences', events);
for (const event of events) {
androidInterface.onReminderSnooze$.next(event);
}
}
} catch (e) {
DroidLog.err('Failed to parse reminder snooze queue', e);
}
// Push-based: sets isFrontendReady=true on native side for warm-start shares
androidInterface.triggerGetShareData?.();
}