Skip to content

Commit 7d89298

Browse files
refactor(ios): move widget logo + payload logic from Swift to React Native
The widget bridge previously did logo downloading, image downscaling, JSON parsing and field-remapping in Swift (~196 lines). All of that moves to JS, keeping only what RN can't do natively in the bridge. - RCTWidgetBridge.swift slimmed to two methods: getLogosDirectoryPath (so JS can write into the App Group container) and a verbatim setTokens + reload. Removed processTokens/cacheLogo/downscale/sanitize and all URLSession/UIImage code. - New syncWidgetLogos.ts downloads logos via react-native-fs straight into the App Group dir (skip-if-exists, skips empty/non-http/SVG); failures fall back to the widget monogram. - buildWidgetPayload now emits the final widget field names (deeplink), so Swift does no remapping; WidgetSyncManager.sync pushes through the JS logo step. - SwiftUI widget views unchanged (WidgetKit can't run RN — irreducible). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 778bdd9 commit 7d89298

7 files changed

Lines changed: 339 additions & 169 deletions

File tree

app/core/WidgetData/WidgetSyncManager.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
import Logger from '../../util/Logger';
88
import ReduxService from '../redux';
99
import { WidgetBridge } from '../NativeModules';
10-
import { buildWidgetPayload } from './buildWidgetPayload';
10+
import { buildWidgetPayload, WidgetTokenPayload } from './buildWidgetPayload';
11+
import { syncWidgetLogos } from './syncWidgetLogos';
1112

1213
/**
1314
* Debounce window for store-driven syncs. Balances are recomputed on many Redux
@@ -48,7 +49,10 @@ export class WidgetSyncManager {
4849
// Initial push so the widget reflects state from app start.
4950
this.sync();
5051
} catch (error) {
51-
Logger.error(error as Error, 'WidgetSyncManager: store not ready at init');
52+
Logger.error(
53+
error as Error,
54+
'WidgetSyncManager: store not ready at init',
55+
);
5256
}
5357
}
5458

@@ -76,7 +80,9 @@ export class WidgetSyncManager {
7680

7781
/**
7882
* Build the payload from current state and push it to the widget. Skips the
79-
* native call when the payload is unchanged since the last successful push.
83+
* native call when the payload is unchanged since the last push. Dedup runs on
84+
* the pre-logo payload (which is deterministic for a given state), so cached
85+
* logos aren't re-downloaded on every Redux tick.
8086
*/
8187
sync(): void {
8288
if (!this.isSupported()) {
@@ -89,14 +95,23 @@ export class WidgetSyncManager {
8995
return;
9096
}
9197
this.lastSerialized = serialized;
92-
WidgetBridge.setTokens(serialized)?.catch?.((error: unknown) => {
93-
Logger.error(error as Error, 'WidgetSyncManager: setTokens failed');
98+
this.pushToWidget(payload).catch((error: unknown) => {
99+
Logger.error(error as Error, 'WidgetSyncManager: push failed');
94100
});
95101
} catch (error) {
96102
Logger.error(error as Error, 'WidgetSyncManager: sync failed');
97103
}
98104
}
99105

106+
/**
107+
* Downloads the token logos into the widget's App Group container (in JS) and
108+
* writes the final payload through the native bridge.
109+
*/
110+
private async pushToWidget(payload: WidgetTokenPayload): Promise<void> {
111+
const output = await syncWidgetLogos(payload);
112+
await WidgetBridge.setTokens(JSON.stringify(output));
113+
}
114+
100115
cleanup(): void {
101116
if (this.appStateSubscription) {
102117
this.appStateSubscription.remove();

app/core/WidgetData/buildWidgetPayload.test.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@ import { RootState } from '../../reducers';
22
import { selectAssetsBySelectedAccountGroup } from '../../selectors/assets/assets-list';
33
import { selectTokenMarketData } from '../../selectors/tokenRatesController';
44
import { selectMultichainAssetsRates } from '../../selectors/multichain/multichain';
5-
import {
6-
buildWidgetPayload,
7-
WIDGET_MAX_TOKENS,
8-
} from './buildWidgetPayload';
5+
import { buildWidgetPayload, WIDGET_MAX_TOKENS } from './buildWidgetPayload';
96

107
jest.mock('../../selectors/assets/assets-list', () => ({
118
selectAssetsBySelectedAccountGroup: jest.fn(),
@@ -47,9 +44,9 @@ const makeAsset = (
4744
chainId,
4845
fiat: { balance: fiatBalance, currency: 'usd', conversionRate: 1 },
4946
...extra,
50-
} as unknown as ReturnType<
47+
}) as unknown as ReturnType<
5148
typeof selectAssetsBySelectedAccountGroup
52-
>[string][number]);
49+
>[string][number];
5350

5451
const state = {} as RootState;
5552

@@ -139,7 +136,7 @@ describe('buildWidgetPayload', () => {
139136

140137
const { tokens } = buildWidgetPayload(state);
141138

142-
expect(tokens[0].swapDeeplink).toBe(
139+
expect(tokens[0].deeplink).toBe(
143140
`metamask://swap?from=${encodeURIComponent(
144141
'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
145142
)}`,
@@ -150,11 +147,13 @@ describe('buildWidgetPayload', () => {
150147
it('reads an EVM token change from market data keyed by [chainId][address]', () => {
151148
const address = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
152149
mockSelector.mockReturnValue({
153-
'0x1': [makeAsset('USDC', '100', 100, undefined, address, '0x1', { address })],
150+
'0x1': [
151+
makeAsset('USDC', '100', 100, undefined, address, '0x1', { address }),
152+
],
154153
});
155154
mockMarketData.mockReturnValue({
156155
'0x1': { [address]: { pricePercentChange1d: 2.31 } },
157-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
156+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
158157
} as any);
159158

160159
expect(buildWidgetPayload(state).tokens[0].priceChange1d).toBe(2.31);
@@ -164,11 +163,15 @@ describe('buildWidgetPayload', () => {
164163
// getNativeTokenAddress(chainId) → the zero address.
165164
const nativeAddress = '0x0000000000000000000000000000000000000000';
166165
mockSelector.mockReturnValue({
167-
'0x1': [makeAsset('ETH', '2', 6000, undefined, '0x0', '0x1', { isNative: true })],
166+
'0x1': [
167+
makeAsset('ETH', '2', 6000, undefined, '0x0', '0x1', {
168+
isNative: true,
169+
}),
170+
],
168171
});
169172
mockMarketData.mockReturnValue({
170173
'0x1': { [nativeAddress]: { pricePercentChange1d: -1.5 } },
171-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
174+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
172175
} as any);
173176

174177
expect(buildWidgetPayload(state).tokens[0].priceChange1d).toBe(-1.5);
@@ -183,7 +186,7 @@ describe('buildWidgetPayload', () => {
183186
});
184187
mockMultichainRates.mockReturnValue({
185188
[caipAssetId]: { marketData: { pricePercentChange: { P1D: 4.2 } } },
186-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
189+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
187190
} as any);
188191

189192
expect(buildWidgetPayload(state).tokens[0].priceChange1d).toBe(4.2);

app/core/WidgetData/buildWidgetPayload.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,18 @@ export interface WidgetTokenEntry {
2828
symbol: string;
2929
/** Pre-formatted unit market price in the user's currency, e.g. "$1.00". */
3030
priceFormatted: string;
31-
/** Remote logo URL. The native bridge downloads/rasterizes this to a local PNG. */
31+
/**
32+
* Remote logo URL. Transient: {@link syncWidgetLogos} downloads this into the
33+
* App Group container and replaces it with a local `logoFile` before the
34+
* payload is written to the widget.
35+
*/
3236
logoUrl: string;
3337
/**
3438
* Deep link that opens the Swap screen with this token preselected as the
35-
* source. Undefined when a CAIP-19 asset id can't be derived.
39+
* source. Undefined when a CAIP-19 asset id can't be derived. Consumed
40+
* directly by the widget (Swift no longer remaps field names).
3641
*/
37-
swapDeeplink?: string;
42+
deeplink?: string;
3843
/**
3944
* 24h price change as a percentage (e.g. `2.31`, `-0.04`). Real market data;
4045
* undefined when no rate is available. The widget colors it green/red and
@@ -193,7 +198,7 @@ export function buildWidgetPayload(state: RootState): WidgetTokenPayload {
193198
{ style: 'currency', currency },
194199
),
195200
logoUrl: asset.image ?? '',
196-
swapDeeplink: caipAssetId ? buildSwapDeeplink(caipAssetId) : undefined,
201+
deeplink: caipAssetId ? buildSwapDeeplink(caipAssetId) : undefined,
197202
priceChange1d,
198203
sparkline: buildSparkline(asset.symbol, priceChange1d),
199204
};
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type RNFSType from 'react-native-fs';
2+
import type { WidgetTokenPayload } from './buildWidgetPayload';
3+
import type { syncWidgetLogos as SyncWidgetLogos } from './syncWidgetLogos';
4+
5+
jest.mock('react-native-fs', () => ({
6+
exists: jest.fn(),
7+
downloadFile: jest.fn(),
8+
unlink: jest.fn(),
9+
}));
10+
11+
jest.mock('../NativeModules', () => ({
12+
WidgetBridge: { getLogosDirectoryPath: jest.fn() },
13+
}));
14+
15+
jest.mock('../../util/Logger', () => ({ error: jest.fn() }));
16+
17+
const LOGOS_DIR = '/group/TokenLogos';
18+
19+
const download = (statusCode: number) =>
20+
({ promise: Promise.resolve({ statusCode }) }) as ReturnType<
21+
typeof RNFSType.downloadFile
22+
>;
23+
24+
const makePayload = (
25+
tokens: Partial<WidgetTokenPayload['tokens'][number]>[],
26+
): WidgetTokenPayload => ({
27+
tokens: tokens.map((t) => ({
28+
symbol: t.symbol ?? 'TKN',
29+
priceFormatted: t.priceFormatted ?? '$1.00',
30+
logoUrl: t.logoUrl ?? 'https://logos/TKN.png',
31+
...t,
32+
})),
33+
});
34+
35+
// The SUT memoizes the logos-dir path at module scope, so re-require it fresh
36+
// per test to keep cases independent.
37+
let syncWidgetLogos: typeof SyncWidgetLogos;
38+
let mockExists: jest.MockedFunction<typeof RNFSType.exists>;
39+
let mockDownload: jest.MockedFunction<typeof RNFSType.downloadFile>;
40+
let mockUnlink: jest.MockedFunction<typeof RNFSType.unlink>;
41+
let mockGetDir: jest.MockedFunction<() => Promise<string>>;
42+
43+
describe('syncWidgetLogos', () => {
44+
beforeEach(() => {
45+
jest.resetModules();
46+
// eslint-disable-next-line @typescript-eslint/no-require-imports
47+
const RNFS = require('react-native-fs');
48+
// eslint-disable-next-line @typescript-eslint/no-require-imports
49+
const { WidgetBridge } = require('../NativeModules');
50+
// eslint-disable-next-line @typescript-eslint/no-require-imports
51+
({ syncWidgetLogos } = require('./syncWidgetLogos'));
52+
53+
mockExists = RNFS.exists;
54+
mockDownload = RNFS.downloadFile;
55+
mockUnlink = RNFS.unlink;
56+
mockGetDir = WidgetBridge.getLogosDirectoryPath;
57+
58+
mockGetDir.mockResolvedValue(LOGOS_DIR);
59+
mockExists.mockResolvedValue(false);
60+
mockDownload.mockReturnValue(download(200));
61+
mockUnlink.mockResolvedValue(undefined);
62+
});
63+
64+
it('downloads a logo and rewrites logoUrl to logoFile', async () => {
65+
const { tokens } = await syncWidgetLogos(
66+
makePayload([{ symbol: 'ETH', logoUrl: 'https://logos/eth.png' }]),
67+
);
68+
69+
expect(mockDownload).toHaveBeenCalledWith({
70+
fromUrl: 'https://logos/eth.png',
71+
toFile: `${LOGOS_DIR}/ETH.png`,
72+
});
73+
expect(tokens[0]).toMatchObject({ symbol: 'ETH', logoFile: 'ETH.png' });
74+
expect(tokens[0]).not.toHaveProperty('logoUrl');
75+
});
76+
77+
it('skips the download when the file already exists', async () => {
78+
mockExists.mockResolvedValue(true);
79+
80+
const { tokens } = await syncWidgetLogos(makePayload([{ symbol: 'ETH' }]));
81+
82+
expect(mockDownload).not.toHaveBeenCalled();
83+
expect(tokens[0].logoFile).toBe('ETH.png');
84+
});
85+
86+
it.each([
87+
['empty', ''],
88+
['non-http', 'data:image/png;base64,abc'],
89+
['svg', 'https://logos/eth.svg'],
90+
['svg with query', 'https://logos/eth.svg?v=2'],
91+
])('does not download a %s logo url', async (_label, logoUrl) => {
92+
const { tokens } = await syncWidgetLogos(
93+
makePayload([{ symbol: 'ETH', logoUrl }]),
94+
);
95+
96+
expect(mockDownload).not.toHaveBeenCalled();
97+
expect(tokens[0]).not.toHaveProperty('logoFile');
98+
expect(tokens[0]).not.toHaveProperty('logoUrl');
99+
});
100+
101+
it('drops logoFile and cleans up on a non-2xx response', async () => {
102+
mockDownload.mockReturnValue(download(404));
103+
104+
const { tokens } = await syncWidgetLogos(makePayload([{ symbol: 'ETH' }]));
105+
106+
expect(tokens[0]).not.toHaveProperty('logoFile');
107+
expect(mockUnlink).toHaveBeenCalledWith(`${LOGOS_DIR}/ETH.png`);
108+
});
109+
110+
it('treats a download error as a missing logo', async () => {
111+
mockDownload.mockReturnValue({
112+
promise: Promise.reject(new Error('network')),
113+
} as ReturnType<typeof RNFSType.downloadFile>);
114+
115+
const { tokens } = await syncWidgetLogos(makePayload([{ symbol: 'ETH' }]));
116+
117+
expect(tokens[0]).not.toHaveProperty('logoFile');
118+
});
119+
120+
it('sanitizes the symbol into a safe filename', async () => {
121+
await syncWidgetLogos(makePayload([{ symbol: 'a/b c' }]));
122+
123+
expect(mockDownload).toHaveBeenCalledWith(
124+
expect.objectContaining({ toFile: `${LOGOS_DIR}/a_b_c.png` }),
125+
);
126+
});
127+
128+
it('returns the payload without logos when the container is unavailable', async () => {
129+
mockGetDir.mockRejectedValue(new Error('app_group_unavailable'));
130+
131+
const { tokens } = await syncWidgetLogos(
132+
makePayload([{ symbol: 'ETH' }, { symbol: 'USDC' }]),
133+
);
134+
135+
expect(mockDownload).not.toHaveBeenCalled();
136+
expect(tokens.map((t) => t.symbol)).toEqual(['ETH', 'USDC']);
137+
expect(tokens[0]).not.toHaveProperty('logoUrl');
138+
expect(tokens[0]).not.toHaveProperty('logoFile');
139+
});
140+
});

0 commit comments

Comments
 (0)