Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/core/Engine/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
} from './controllers/core-backend';
import { assetsControllerInit } from './controllers/assets-controller/assets-controller-init';
import { AppStateWebSocketManager } from '../AppStateWebSocketManager';
import { WidgetSyncManager } from '../WidgetData/WidgetSyncManager';
import { backupVault } from '../BackupVault';
import {
CaipAssetType,
Expand Down Expand Up @@ -249,6 +250,12 @@ export class Engine {
*/
appStateWebSocketManager: AppStateWebSocketManager;

/**
* Syncs the held-token list into the iOS home-screen widget. No-ops on
* Android and on builds without the native widget bridge.
*/
widgetSyncManager: WidgetSyncManager;

accountsController: AccountsController;
gasFeeController: GasFeeController;
gatorPermissionsController: GatorPermissionsController;
Expand Down Expand Up @@ -519,6 +526,7 @@ export class Engine {
this.appStateWebSocketManager = new AppStateWebSocketManager(
backendWebSocketService,
);
this.widgetSyncManager = new WidgetSyncManager();
const accountActivityService =
messengerClientsByName.AccountActivityService;
const ohlcvService = messengerClientsByName.OHLCVService;
Expand Down Expand Up @@ -1240,6 +1248,9 @@ export class Engine {

// Cleanup AppStateWebSocketManager
this.appStateWebSocketManager.cleanup();

// Cleanup WidgetSyncManager
this.widgetSyncManager.cleanup();
}

async destroyEngineInstance() {
Expand Down
10 changes: 9 additions & 1 deletion app/core/NativeModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { NativeModules } from 'react-native';
// Minimizer module allows the app to be pushed to the background
const { Minimizer } = NativeModules;

/**
* iOS-only bridge that writes token data into the home-screen widget's App Group
* container and reloads the widget timelines. Undefined on Android and on builds
* (e.g. Flask) that do not embed the widget extension — callers must no-op when
* it is missing.
*/
const { RCTWidgetBridge } = NativeModules;

// TODO: add native modules named exports here
/* eslint-disable import-x/prefer-default-export */
export { Minimizer };
export { Minimizer, RCTWidgetBridge as WidgetBridge };
129 changes: 129 additions & 0 deletions app/core/WidgetData/WidgetSyncManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {
AppState,
AppStateStatus,
NativeEventSubscription,
Platform,
} from 'react-native';
import Logger from '../../util/Logger';
import ReduxService from '../redux';
import { WidgetBridge } from '../NativeModules';
import { buildWidgetPayload, WidgetTokenPayload } from './buildWidgetPayload';
import { syncWidgetLogos } from './syncWidgetLogos';

/**
* Debounce window for store-driven syncs. Balances are recomputed on many Redux
* ticks; we coalesce them so the native bridge writes/downloads at most once per
* window.
*/
const SYNC_DEBOUNCE_MS = 5_000;

/**
* Manages the iOS home-screen token widget: pushes the current token list into
* the widget's App Group container on app-state transitions and (debounced) on
* Redux store changes.
*
* No-ops on Android and on builds where the native {@link WidgetBridge} module
* is absent (e.g. Flask), so it is safe to construct unconditionally.
*/
export class WidgetSyncManager {
private appStateSubscription: NativeEventSubscription | null = null;
private storeUnsubscribe: (() => void) | null = null;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private lastSerialized: string | null = null;

constructor() {
if (!this.isSupported()) {
return;
}
this.appStateSubscription = AppState.addEventListener(
'change',
this.handleAppStateChange,
);
// Defensive: the Redux store may not be registered yet at Engine-init time.
// A failure here must never break Engine construction — the AppState
// listener will still drive syncs once the app is interactive.
try {
this.storeUnsubscribe = ReduxService.store.subscribe(
this.scheduleDebouncedSync,
);
// Initial push so the widget reflects state from app start.
this.sync();
} catch (error) {
Logger.error(
error as Error,
'WidgetSyncManager: store not ready at init',
);
}
}

private isSupported(): boolean {
return Platform.OS === 'ios' && Boolean(WidgetBridge?.setTokens);
}

private handleAppStateChange = (nextAppState: AppStateStatus): void => {
// Refresh on foreground (fresh prices) and before backgrounding (so the
// last seen state is captured for the home screen).
if (nextAppState === 'active' || nextAppState === 'background') {
this.sync();
}
};

private scheduleDebouncedSync = (): void => {
if (this.debounceTimer) {
return;
}
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.sync();
}, SYNC_DEBOUNCE_MS);
};

/**
* Build the payload from current state and push it to the widget. Skips the
* native call when the payload is unchanged since the last push. Dedup runs on
* the pre-logo payload (which is deterministic for a given state), so cached
* logos aren't re-downloaded on every Redux tick.
*/
sync(): void {
if (!this.isSupported()) {
return;
}
try {
const payload = buildWidgetPayload(ReduxService.store.getState());
const serialized = JSON.stringify(payload);
if (serialized === this.lastSerialized) {
return;
}
this.lastSerialized = serialized;
this.pushToWidget(payload).catch((error: unknown) => {
Logger.error(error as Error, 'WidgetSyncManager: push failed');
});
} catch (error) {
Logger.error(error as Error, 'WidgetSyncManager: sync failed');
}
}

/**
* Downloads the token logos into the widget's App Group container (in JS) and
* writes the final payload through the native bridge.
*/
private async pushToWidget(payload: WidgetTokenPayload): Promise<void> {
const output = await syncWidgetLogos(payload);
await WidgetBridge.setTokens(JSON.stringify(output));
}

cleanup(): void {
if (this.appStateSubscription) {
this.appStateSubscription.remove();
this.appStateSubscription = null;
}
if (this.storeUnsubscribe) {
this.storeUnsubscribe();
this.storeUnsubscribe = null;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
}
}
Loading
Loading