mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-21 02:20:12 +00:00
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
304 lines
10 KiB
TypeScript
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?.();
|
|
}
|