diff --git a/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityAppStatus.ts b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityAppStatus.ts index 71e49ad2d778..6fc5c33c6f70 100644 --- a/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityAppStatus.ts +++ b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityAppStatus.ts @@ -28,6 +28,7 @@ export interface ISimpleDBAppStatus { fixHardwareLtcXPubMigrated?: boolean; btcFreshAddressSettingMigrated?: boolean; removeDeviceHomeScreenMigrated?: boolean; + lastWalletProfileAnalyticsAt?: number; walletAssetStatusAnalytics?: IWalletAssetStatusAnalyticsState; // OneKey IDs (onekeyUserId) that have already seen the KYT intro dialog. // Scoped per Prime user so each account is prompted once. diff --git a/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.ts b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.ts index 755231e53eb2..0b21fd8f9582 100644 --- a/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.ts +++ b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.ts @@ -37,6 +37,8 @@ import type { } from '@onekeyhq/core/src/types'; import { ECoreApiExportedSecretKeyType } from '@onekeyhq/core/src/types'; import type { IAllNetworkAccountInfo } from '@onekeyhq/kit-bg/src/services/ServiceAllNetwork/ServiceAllNetwork'; +import { analytics } from '@onekeyhq/shared/src/analytics'; +import type { IAnalyticsUserProfile } from '@onekeyhq/shared/src/analytics/type'; import type { IPbkdf2KdfParams } from '@onekeyhq/shared/src/appCrypto/modules/pbkdf2'; import { clearPbkdf2InvocationByProbeId, @@ -197,6 +199,10 @@ import keylessSyncCredentialStorage from '../ServiceKeylessWallet/utils/keylessS import { isBotWalletInCurrentKeylessSyncScope } from '../ServicePrimeCloudSync/botWalletCloudSyncUtils'; import keylessCloudSyncUtils from '../ServicePrimeCloudSync/keylessCloudSyncUtils'; +import { + buildAnalyticsWalletProfile, + shouldReportAnalyticsWalletProfile, +} from './analyticsWalletProfile'; import { buildDefaultBotWalletName, isDefaultBotWalletName, @@ -716,6 +722,44 @@ class ServiceAccount extends ServiceBase { }; } + private async getWalletProfileForAnalytics(): Promise< + IAnalyticsUserProfile | undefined + > { + const [{ wallets }, botWalletMetadata] = await Promise.all([ + this.getWallets({ + nestedHiddenWallets: true, + ignoreEmptySingletonWalletAccounts: true, + }), + simpleDb.botWallet.getMetadataMap(), + ]); + return buildAnalyticsWalletProfile({ wallets, botWalletMetadata }); + } + + @backgroundMethod() + async reportWalletProfileAnalyticsIfNeeded() { + const now = Date.now(); + const appStatus = await simpleDb.appStatus.getRawData(); + if ( + !shouldReportAnalyticsWalletProfile({ + lastReportedAt: appStatus?.lastWalletProfileAnalyticsAt, + now, + }) + ) { + return; + } + + const walletProfile = await this.getWalletProfileForAnalytics(); + if (walletProfile) { + analytics.updateUserProfile(walletProfile); + } + await simpleDb.appStatus.setRawData( + (value): ISimpleDBAppStatus => ({ + ...value, + lastWalletProfileAnalyticsAt: now, + }), + ); + } + @backgroundMethod() async getAllHdHwQrWallets(options?: IDBGetWalletsParams) { const r = await this.getWallets(options); diff --git a/packages/kit-bg/src/services/ServiceAccount/analyticsWalletProfile.test.ts b/packages/kit-bg/src/services/ServiceAccount/analyticsWalletProfile.test.ts new file mode 100644 index 000000000000..90f7a6474a13 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/analyticsWalletProfile.test.ts @@ -0,0 +1,199 @@ +import type { IDBWallet } from '@onekeyhq/kit-bg/src/dbs/local/types'; +import { + BOT_WALLET_STATUS_ACTIVE, + BOT_WALLET_STATUS_DEACTIVATED, +} from '@onekeyhq/shared/src/consts/dbConsts'; +import type { IBotWalletMetadataMap } from '@onekeyhq/shared/types/botWallet'; +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +import { + WALLET_PROFILE_ANALYTICS_INTERVAL_MS, + buildAnalyticsWalletProfile, + computeHwVendorProfile, + shouldReportAnalyticsWalletProfile, +} from './analyticsWalletProfile'; + +function createWallet({ + id, + type = 'hd', + isKeyless = false, + vendor, +}: { + id: string; + type?: IDBWallet['type']; + isKeyless?: boolean; + vendor?: EHardwareVendor; +}): IDBWallet { + return { + id, + name: id, + type, + isKeyless, + backuped: true, + accounts: [], + nextIds: {}, + walletNo: 1, + associatedDeviceInfo: vendor + ? ({ vendor } as IDBWallet['associatedDeviceInfo']) + : undefined, + } as IDBWallet; +} + +function createBotMetadata( + entries: Array<[string, boolean]>, +): IBotWalletMetadataMap { + return Object.fromEntries( + entries.map(([walletId, visible], index) => [ + walletId, + { + index, + name: walletId, + visible, + status: visible + ? BOT_WALLET_STATUS_ACTIVE + : BOT_WALLET_STATUS_DEACTIVATED, + createdAt: 1, + }, + ]), + ); +} + +describe('buildAnalyticsWalletProfile', () => { + it('returns no profile when there are no visible wallets', () => { + expect( + buildAnalyticsWalletProfile({ + wallets: [], + botWalletMetadata: {}, + }), + ).toBeUndefined(); + }); + + it('aggregates the startup profile and excludes hidden bot wallets', () => { + const visibleBotId = 'hd-bot--hd-keyless-parent-1--0'; + const hiddenBotId = 'hd-bot--hd-keyless-parent-1--1'; + const profile = buildAnalyticsWalletProfile({ + wallets: [ + createWallet({ + id: 'hd-keyless-parent-1', + isKeyless: true, + }), + createWallet({ id: visibleBotId, isKeyless: true }), + createWallet({ id: hiddenBotId, isKeyless: true }), + createWallet({ id: 'imported-1', type: 'imported' }), + createWallet({ id: 'hw-onekey', type: 'hw' }), + createWallet({ + id: 'hw-ledger', + type: 'hw', + vendor: EHardwareVendor.ledger, + }), + ], + botWalletMetadata: createBotMetadata([ + [visibleBotId, true], + [hiddenBotId, false], + ]), + }); + + expect(profile).toEqual({ + walletCount: 5, + hwWalletCount: 2, + appWalletCount: 3, + keylessWalletCount: 1, + hwVendors: ['ledger', 'onekey'], + primaryHwVendor: 'ledger', + }); + }); + + it('keeps a visible orphan bot wallet in the keyless count', () => { + const orphanBotId = 'hd-bot--missing-parent--0'; + + expect( + buildAnalyticsWalletProfile({ + wallets: [createWallet({ id: orphanBotId, isKeyless: true })], + botWalletMetadata: createBotMetadata([[orphanBotId, true]]), + }), + ).toEqual({ + walletCount: 1, + hwWalletCount: 0, + appWalletCount: 1, + keylessWalletCount: 1, + hwVendors: [], + primaryHwVendor: undefined, + }); + }); +}); + +describe('computeHwVendorProfile', () => { + it('returns an empty vendor profile without hardware wallets', () => { + expect(computeHwVendorProfile([createWallet({ id: 'hd-1' })])).toEqual({ + hwVendors: [], + primaryHwVendor: undefined, + }); + }); + + it('falls back to OneKey for legacy hardware wallet records', () => { + expect( + computeHwVendorProfile([createWallet({ id: 'hw-legacy', type: 'hw' })]), + ).toEqual({ + hwVendors: ['onekey'], + primaryHwVendor: 'onekey', + }); + }); + + it('returns sorted vendors and selects the majority vendor', () => { + const profile = computeHwVendorProfile([ + createWallet({ id: 'hw-legacy-1', type: 'hw' }), + createWallet({ id: 'hw-legacy-2', type: 'hw' }), + createWallet({ + id: 'hw-ledger', + type: 'hw', + vendor: EHardwareVendor.ledger, + }), + ]); + + expect(profile).toEqual({ + hwVendors: ['ledger', 'onekey'], + primaryHwVendor: 'onekey', + }); + }); + + it('breaks equal vendor counts by lexical order', () => { + const profile = computeHwVendorProfile([ + createWallet({ id: 'hw-onekey', type: 'hw' }), + createWallet({ + id: 'hw-ledger', + type: 'hw', + vendor: EHardwareVendor.ledger, + }), + ]); + + expect(profile.primaryHwVendor).toBe('ledger'); + }); +}); + +describe('shouldReportAnalyticsWalletProfile', () => { + const now = 2 * WALLET_PROFILE_ANALYTICS_INTERVAL_MS; + + it('allows the first report', () => { + expect( + shouldReportAnalyticsWalletProfile({ + lastReportedAt: undefined, + now, + }), + ).toBe(true); + }); + + it('limits reports to once per 24 hours', () => { + expect( + shouldReportAnalyticsWalletProfile({ + lastReportedAt: now - WALLET_PROFILE_ANALYTICS_INTERVAL_MS + 1, + now, + }), + ).toBe(false); + expect( + shouldReportAnalyticsWalletProfile({ + lastReportedAt: now - WALLET_PROFILE_ANALYTICS_INTERVAL_MS, + now, + }), + ).toBe(true); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceAccount/analyticsWalletProfile.ts b/packages/kit-bg/src/services/ServiceAccount/analyticsWalletProfile.ts new file mode 100644 index 000000000000..6203b86e175a --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/analyticsWalletProfile.ts @@ -0,0 +1,89 @@ +import type { IDBWallet } from '@onekeyhq/kit-bg/src/dbs/local/types'; +import type { IAnalyticsUserProfile } from '@onekeyhq/shared/src/analytics/type'; +import { WALLET_TYPE_HW } from '@onekeyhq/shared/src/consts/dbConsts'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import type { IBotWalletMetadataMap } from '@onekeyhq/shared/types/botWallet'; +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +type IHwVendorProfileWallet = Pick; + +export const WALLET_PROFILE_ANALYTICS_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export function shouldReportAnalyticsWalletProfile({ + lastReportedAt, + now, +}: { + lastReportedAt: number | undefined; + now: number; +}) { + return ( + lastReportedAt === undefined || + now - lastReportedAt >= WALLET_PROFILE_ANALYTICS_INTERVAL_MS + ); +} + +export function computeHwVendorProfile( + wallets: readonly IHwVendorProfileWallet[], +): { + hwVendors: string[]; + primaryHwVendor: string | undefined; +} { + const hwWallets = wallets.filter((wallet) => wallet.type === WALLET_TYPE_HW); + if (hwWallets.length === 0) { + return { hwVendors: [], primaryHwVendor: undefined }; + } + const counts = hwWallets.reduce>((acc, wallet) => { + const vendor = + wallet.associatedDeviceInfo?.vendor ?? EHardwareVendor.onekey; + acc[vendor] = (acc[vendor] ?? 0) + 1; + return acc; + }, {}); + const hwVendors = Object.keys(counts).toSorted(); + const primaryHwVendor = hwVendors.reduce((leader, vendor) => + counts[vendor] > counts[leader] ? vendor : leader, + ); + return { hwVendors, primaryHwVendor }; +} + +export function buildAnalyticsWalletProfile({ + wallets, + botWalletMetadata, +}: { + wallets: readonly IDBWallet[]; + botWalletMetadata: IBotWalletMetadataMap; +}): IAnalyticsUserProfile | undefined { + const visibleWallets = wallets.filter((wallet) => { + if (!accountUtils.isBotWallet({ walletId: wallet.id })) { + return true; + } + return botWalletMetadata[wallet.id]?.visible === true; + }); + const walletCount = visibleWallets.length; + if (walletCount === 0) { + return undefined; + } + + const hwWalletCount = visibleWallets.filter( + (wallet) => wallet.type === WALLET_TYPE_HW, + ).length; + const visibleWalletMap = new Map( + visibleWallets.map((wallet) => [wallet.id, wallet]), + ); + const keylessWalletCount = visibleWallets.filter((wallet) => { + if (!wallet.isKeyless) { + return false; + } + const parentWalletId = accountUtils.parseBotWalletId(wallet.id)?.parentId; + return !parentWalletId || !visibleWalletMap.get(parentWalletId)?.isKeyless; + }).length; + const { hwVendors, primaryHwVendor } = computeHwVendorProfile(visibleWallets); + + return { + walletCount, + hwWalletCount, + appWalletCount: walletCount - hwWalletCount, + keylessWalletCount, + hwVendors, + primaryHwVendor, + }; +} diff --git a/packages/kit-bg/src/services/ServiceBootstrap.ts b/packages/kit-bg/src/services/ServiceBootstrap.ts index 501b80de82b6..6f386ca56432 100644 --- a/packages/kit-bg/src/services/ServiceBootstrap.ts +++ b/packages/kit-bg/src/services/ServiceBootstrap.ts @@ -7,9 +7,12 @@ import systemTimeUtils from '@onekeyhq/shared/src/utils/systemTimeUtils'; import localDb from '../dbs/local/localDb'; import ServiceBase from './ServiceBase'; +import { scheduleWalletProfileAnalyticsChecks } from './walletProfileAnalyticsScheduler'; @backgroundClass() class ServiceBootstrap extends ServiceBase { + private walletProfileAnalyticsChecksScheduled = false; + constructor({ backgroundApi }: { backgroundApi: any }) { super({ backgroundApi }); } @@ -139,9 +142,19 @@ class ServiceBootstrap extends ServiceBase { timedDeferred('serviceContextMenu.init', () => this.backgroundApi.serviceContextMenu.init(), ), - timedDeferred('serviceDevSetting.initAnalytics', () => - this.backgroundApi.serviceDevSetting.initAnalytics(), - ), + timedDeferred('serviceDevSetting.initAnalytics', async () => { + await this.backgroundApi.serviceDevSetting.initAnalytics(); + if (!this.walletProfileAnalyticsChecksScheduled) { + this.walletProfileAnalyticsChecksScheduled = true; + scheduleWalletProfileAnalyticsChecks(() => + timedDeferred( + 'serviceAccount.reportWalletProfileAnalyticsIfNeeded', + () => + this.backgroundApi.serviceAccount.reportWalletProfileAnalyticsIfNeeded(), + ), + ); + } + }), // ext MV3 only: re-warm providers of already-connected dapps after a // service-worker restart so notifyDApp* can reach them. Native/desktop // rebuild their webviews on restart (dapp reconnects), so no warmup diff --git a/packages/kit-bg/src/services/walletProfileAnalyticsScheduler.test.ts b/packages/kit-bg/src/services/walletProfileAnalyticsScheduler.test.ts new file mode 100644 index 000000000000..9073113d8c88 --- /dev/null +++ b/packages/kit-bg/src/services/walletProfileAnalyticsScheduler.test.ts @@ -0,0 +1,39 @@ +import { + WALLET_PROFILE_ANALYTICS_CHECK_INTERVAL_MS, + WALLET_PROFILE_ANALYTICS_INITIAL_DELAY_MS, + scheduleWalletProfileAnalyticsChecks, +} from './walletProfileAnalyticsScheduler'; + +describe('scheduleWalletProfileAnalyticsChecks', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('checks after the startup delay and then every 60 minutes', async () => { + const check = jest.fn(() => Promise.resolve()); + + scheduleWalletProfileAnalyticsChecks(check); + + await jest.advanceTimersByTimeAsync( + WALLET_PROFILE_ANALYTICS_INITIAL_DELAY_MS - 1, + ); + expect(check).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(1); + expect(check).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync( + WALLET_PROFILE_ANALYTICS_CHECK_INTERVAL_MS, + ); + expect(check).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync( + WALLET_PROFILE_ANALYTICS_CHECK_INTERVAL_MS, + ); + expect(check).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/kit-bg/src/services/walletProfileAnalyticsScheduler.ts b/packages/kit-bg/src/services/walletProfileAnalyticsScheduler.ts new file mode 100644 index 000000000000..0d1f949f8021 --- /dev/null +++ b/packages/kit-bg/src/services/walletProfileAnalyticsScheduler.ts @@ -0,0 +1,13 @@ +export const WALLET_PROFILE_ANALYTICS_INITIAL_DELAY_MS = 30 * 1000; +export const WALLET_PROFILE_ANALYTICS_CHECK_INTERVAL_MS = 60 * 60 * 1000; + +export function scheduleWalletProfileAnalyticsChecks( + check: () => Promise, +) { + setTimeout(() => { + void check(); + setInterval(() => { + void check(); + }, WALLET_PROFILE_ANALYTICS_CHECK_INTERVAL_MS); + }, WALLET_PROFILE_ANALYTICS_INITIAL_DELAY_MS); +} diff --git a/packages/kit/src/routes/Tab/Navigator.tsx b/packages/kit/src/routes/Tab/Navigator.tsx index 047405b08a23..619627c0a9f0 100644 --- a/packages/kit/src/routes/Tab/Navigator.tsx +++ b/packages/kit/src/routes/Tab/Navigator.tsx @@ -19,7 +19,6 @@ import { useSplitSubView, } from '@onekeyhq/components'; import { ETranslations } from '@onekeyhq/shared/src/locale'; -import { getDevicePerformanceTier } from '@onekeyhq/shared/src/performance/devicePerformanceTier'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { ETabRoutes } from '@onekeyhq/shared/src/routes'; import { ERootRoutes } from '@onekeyhq/shared/src/routes/root'; @@ -31,7 +30,7 @@ import { BottomMenu } from '../../provider/Container/PortalBodyContainer/BottomM import { WebPageTabBar } from '../../provider/Container/PortalBodyContainer/WebPageTabBar'; import { TabFreezeOnBlurContext } from '../../provider/Container/TabFreezeOnBlurContainer'; -import { defaultPreloadEntry, tabPreloadConfig } from './preloadConfig'; +import { getTabPreloadPolicy } from './preloadPolicy'; import { tabExtraConfig, useTabRouterConfig } from './router'; // prevent pushModal from using unreleased Navigation instances during iOS modal animation by temporary exclusion, @@ -143,7 +142,7 @@ export function TabNavigator() { useGlobalShortcuts(); useCheckTabsChangedInDev(config); - // Progressively preload tabs during idle time, driven by device performance tier. + // Progressively preload tabs during idle time using a feature-specific policy. // Tabs are lazy-loaded on all platforms; this ensures key tabs are // pre-rendered in the background before the user navigates to them. // IMPORTANT: Must use `target` to send the PRELOAD action directly to the @@ -153,10 +152,8 @@ export function TabNavigator() { // Also do NOT pass params — mismatched params cause TabRouter to regenerate // route keys via nanoid(), which unmounts/remounts screens. useEffect(() => { - const tier = getDevicePerformanceTier(); - const { queue: preloadQueue, intervalMs: PRELOAD_INTERVAL_MS } = - tabPreloadConfig[tier] ?? defaultPreloadEntry; + getTabPreloadPolicy(); if (preloadQueue.length === 0) return; let index = 0; @@ -212,18 +209,6 @@ export function TabNavigator() { }; }, []); - // Calibrate performance tier after UI is visible (async, result used on next launch) - useEffect(() => { - const timer = setTimeout(() => { - void (async () => { - const { calibrateDevicePerformanceTier } = - await import('@onekeyhq/shared/src/performance/devicePerformanceTier'); - await calibrateDevicePerformanceTier(); - })(); - }, 5000); - return () => clearTimeout(timer); - }, []); - return ( <> diff --git a/packages/kit/src/routes/Tab/preloadConfig.ts b/packages/kit/src/routes/Tab/preloadConfig.ts index 1fd9f151af70..14b9faa52424 100644 --- a/packages/kit/src/routes/Tab/preloadConfig.ts +++ b/packages/kit/src/routes/Tab/preloadConfig.ts @@ -1,26 +1,24 @@ -import { EDevicePerformanceTier } from '@onekeyhq/shared/src/performance/devicePerformanceTier'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { ETabRoutes } from '@onekeyhq/shared/src/routes'; -type IPreloadEntry = { queue: ETabRoutes[]; intervalMs: number }; +import { ETabPreloadMode } from './preloadPolicyResolver'; -// Preload config per platform × tier -// high → preload all key tabs -// medium → preload high-frequency tabs only -// low → no preload, fully on-demand -const nativePreloadConfig: Record = { - [EDevicePerformanceTier.high]: { +export type IPreloadEntry = { queue: ETabRoutes[]; intervalMs: number }; + +const nativePreloadConfig: Record = { + [ETabPreloadMode.full]: { queue: [ETabRoutes.Swap, ETabRoutes.Discovery, ETabRoutes.Perp], intervalMs: 2000, }, - [EDevicePerformanceTier.medium]: { + [ETabPreloadMode.light]: { queue: [ETabRoutes.Swap, ETabRoutes.Perp], intervalMs: 3000, }, + [ETabPreloadMode.disabled]: { queue: [], intervalMs: 0 }, }; -const webPreloadConfig: Record = { - [EDevicePerformanceTier.high]: { +const webPreloadConfig: Record = { + [ETabPreloadMode.full]: { queue: [ ETabRoutes.Swap, ETabRoutes.Discovery, @@ -30,25 +28,27 @@ const webPreloadConfig: Record = { ], intervalMs: 2000, }, - [EDevicePerformanceTier.medium]: { + [ETabPreloadMode.light]: { queue: [ETabRoutes.Swap, ETabRoutes.Market, ETabRoutes.Discovery], intervalMs: 2500, }, + [ETabPreloadMode.disabled]: { queue: [], intervalMs: 0 }, }; // Ext popup/side panel hides the bottom tab bar, so Discovery/Perp/Device/ // ReferFriends tabs are unreachable there — preloading them only mounts dead // screens in the popup runtime. Keep just Swap: it warms the SwapMainLand // chunk shared with the Trade modal, the one reachable swap surface. -const extPopupPreloadConfig: Record = { - [EDevicePerformanceTier.high]: { +const extPopupPreloadConfig: Record = { + [ETabPreloadMode.full]: { queue: [ETabRoutes.Swap], intervalMs: 1500, }, - [EDevicePerformanceTier.medium]: { + [ETabPreloadMode.light]: { queue: [ETabRoutes.Swap], intervalMs: 2500, }, + [ETabPreloadMode.disabled]: { queue: [], intervalMs: 0 }, }; function resolveWebPreloadConfig() { @@ -58,11 +58,10 @@ function resolveWebPreloadConfig() { return webPreloadConfig; } -export const tabPreloadConfig = platformEnv.isNative +const tabPreloadConfig = platformEnv.isNative ? nativePreloadConfig : resolveWebPreloadConfig(); -export const defaultPreloadEntry: IPreloadEntry = { - queue: [], - intervalMs: 0, -}; +export function getTabPreloadEntry(mode: ETabPreloadMode): IPreloadEntry { + return tabPreloadConfig[mode]; +} diff --git a/packages/kit/src/routes/Tab/preloadPolicy.native.test.ts b/packages/kit/src/routes/Tab/preloadPolicy.native.test.ts new file mode 100644 index 000000000000..f7c9ec81f21c --- /dev/null +++ b/packages/kit/src/routes/Tab/preloadPolicy.native.test.ts @@ -0,0 +1,65 @@ +const deviceCapabilityDetectedMock = jest.fn(); +const getDevicePerformanceProfileMock = jest.fn(); +const getTabPreloadEntryMock = jest.fn(); + +jest.mock('@onekeyhq/shared/src/logger/logger', () => ({ + defaultLogger: { + app: { + perf: { + deviceCapabilityDetected: deviceCapabilityDetectedMock, + }, + }, + }, +})); + +jest.mock('@onekeyhq/shared/src/performance/devicePerformanceTier', () => ({ + getDevicePerformanceProfile: getDevicePerformanceProfileMock, +})); + +jest.mock('./preloadConfig', () => ({ + getTabPreloadEntry: getTabPreloadEntryMock, +})); + +describe('getTabPreloadPolicy', () => { + beforeEach(() => { + jest.resetModules(); + deviceCapabilityDetectedMock.mockReset(); + getDevicePerformanceProfileMock.mockReset(); + getTabPreloadEntryMock.mockReset(); + getDevicePerformanceProfileMock.mockReturnValue({ + cpu: { + tier: 'high', + source: 'iosModelId', + confidence: 'high', + }, + memory: { + class: 'standard', + totalGB: 6, + }, + dataVersion: 'native-cpu-v2', + }); + getTabPreloadEntryMock.mockReturnValue({ + queue: [], + intervalMs: 2000, + }); + }); + + it('logs a sanitized capability decision only once per JS runtime', () => { + const { getTabPreloadPolicy } = + require('./preloadPolicy.native') as typeof import('./preloadPolicy.native'); + + getTabPreloadPolicy(); + getTabPreloadPolicy(); + + expect(deviceCapabilityDetectedMock).toHaveBeenCalledTimes(1); + expect(deviceCapabilityDetectedMock).toHaveBeenCalledWith({ + cpuTier: 'high', + cpuSource: 'iosModelId', + cpuConfidence: 'high', + memoryClass: 'standard', + tabPreloadMode: 'full', + tabPreloadReason: 'cpu-high', + dataVersion: 'native-cpu-v2', + }); + }); +}); diff --git a/packages/kit/src/routes/Tab/preloadPolicy.native.ts b/packages/kit/src/routes/Tab/preloadPolicy.native.ts new file mode 100644 index 000000000000..e861f465aac5 --- /dev/null +++ b/packages/kit/src/routes/Tab/preloadPolicy.native.ts @@ -0,0 +1,39 @@ +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import { getDevicePerformanceProfile } from '@onekeyhq/shared/src/performance/devicePerformanceTier'; + +import { getTabPreloadEntry } from './preloadConfig'; +import { + type ITabPreloadDecision, + resolveTabPreloadDecision, +} from './preloadPolicyResolver'; + +export interface ITabPreloadPolicy extends ITabPreloadDecision { + queue: ReturnType['queue']; + intervalMs: number; +} + +let hasLoggedDeviceCapability = false; + +export function getTabPreloadPolicy(): ITabPreloadPolicy { + const profile = getDevicePerformanceProfile(); + const decision = resolveTabPreloadDecision({ + cpuTier: profile.cpu.tier, + memoryClass: profile.memory.class, + }); + if (!hasLoggedDeviceCapability) { + hasLoggedDeviceCapability = true; + defaultLogger.app.perf.deviceCapabilityDetected({ + cpuTier: profile.cpu.tier, + cpuSource: profile.cpu.source, + cpuConfidence: profile.cpu.confidence, + memoryClass: profile.memory.class, + tabPreloadMode: decision.mode, + tabPreloadReason: decision.reason, + dataVersion: profile.dataVersion, + }); + } + return { + ...decision, + ...getTabPreloadEntry(decision.mode), + }; +} diff --git a/packages/kit/src/routes/Tab/preloadPolicy.test.ts b/packages/kit/src/routes/Tab/preloadPolicy.test.ts new file mode 100644 index 000000000000..21891f0f5836 --- /dev/null +++ b/packages/kit/src/routes/Tab/preloadPolicy.test.ts @@ -0,0 +1,135 @@ +const deviceCapabilityDetectedMock = jest.fn(); +const getDevicePerformanceProfileMock = jest.fn(); +const getTabPreloadEntryMock = jest.fn(); +const platformEnvMock = { + isExtensionUiPopup: false, + isExtensionUiSidePanel: false, + isWebMobile: false, +}; + +jest.mock('@onekeyhq/shared/src/logger/logger', () => ({ + defaultLogger: { + app: { + perf: { + deviceCapabilityDetected: deviceCapabilityDetectedMock, + }, + }, + }, +})); + +jest.mock('@onekeyhq/shared/src/performance/devicePerformanceTier', () => ({ + getDevicePerformanceProfile: getDevicePerformanceProfileMock, +})); + +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: platformEnvMock, +})); + +jest.mock('./preloadConfig', () => ({ + getTabPreloadEntry: getTabPreloadEntryMock, +})); + +describe('getTabPreloadPolicy for web capabilities', () => { + beforeEach(() => { + jest.resetModules(); + deviceCapabilityDetectedMock.mockReset(); + getDevicePerformanceProfileMock.mockReset(); + getTabPreloadEntryMock.mockReset(); + platformEnvMock.isExtensionUiPopup = false; + platformEnvMock.isExtensionUiSidePanel = false; + platformEnvMock.isWebMobile = false; + getDevicePerformanceProfileMock.mockReturnValue({ + cpu: { + tier: 'medium', + source: 'browserHardwareConcurrency', + confidence: 'medium', + }, + memory: { + class: 'standard', + totalGB: 4, + }, + dataVersion: 'non-native-capabilities-v1', + }); + getTabPreloadEntryMock.mockReturnValue({ + queue: [], + intervalMs: 2500, + }); + }); + + it('uses the capability profile and logs the decision once per JS runtime', () => { + const { getTabPreloadPolicy } = + require('./preloadPolicy') as typeof import('./preloadPolicy'); + + getTabPreloadPolicy(); + getTabPreloadPolicy(); + + expect(getTabPreloadEntryMock).toHaveBeenCalledWith('light'); + expect(deviceCapabilityDetectedMock).toHaveBeenCalledTimes(1); + expect(deviceCapabilityDetectedMock).toHaveBeenCalledWith({ + cpuTier: 'medium', + cpuSource: 'browserHardwareConcurrency', + cpuConfidence: 'medium', + memoryClass: 'standard', + tabPreloadMode: 'light', + tabPreloadReason: 'cpu-medium', + dataVersion: 'non-native-capabilities-v1', + }); + }); + + it('keeps preloading light when memory capacity is unknown', () => { + getDevicePerformanceProfileMock.mockReturnValue({ + cpu: { + tier: 'high', + source: 'browserHardwareConcurrency', + confidence: 'medium', + }, + memory: { + class: 'unknown', + totalGB: null, + }, + dataVersion: 'non-native-capabilities-v1', + }); + const { getTabPreloadPolicy } = + require('./preloadPolicy') as typeof import('./preloadPolicy'); + + getTabPreloadPolicy(); + + expect(getTabPreloadEntryMock).toHaveBeenCalledWith('light'); + expect(deviceCapabilityDetectedMock).toHaveBeenCalledWith( + expect.objectContaining({ + memoryClass: 'unknown', + tabPreloadMode: 'light', + tabPreloadReason: 'memory-unknown', + }), + ); + }); + + it('caps side panel preloading at light on high-tier devices', () => { + platformEnvMock.isExtensionUiSidePanel = true; + getDevicePerformanceProfileMock.mockReturnValue({ + cpu: { + tier: 'high', + source: 'browserHardwareConcurrency', + confidence: 'medium', + }, + memory: { + class: 'standard', + totalGB: 8, + }, + dataVersion: 'non-native-capabilities-v1', + }); + const { getTabPreloadPolicy } = + require('./preloadPolicy') as typeof import('./preloadPolicy'); + + getTabPreloadPolicy(); + + expect(getTabPreloadEntryMock).toHaveBeenCalledWith('light'); + expect(deviceCapabilityDetectedMock).toHaveBeenCalledWith( + expect.objectContaining({ + tabPreloadMode: 'light', + tabPreloadReason: 'surface-limited', + }), + ); + }); +}); diff --git a/packages/kit/src/routes/Tab/preloadPolicy.ts b/packages/kit/src/routes/Tab/preloadPolicy.ts new file mode 100644 index 000000000000..6c6ed96b7dbc --- /dev/null +++ b/packages/kit/src/routes/Tab/preloadPolicy.ts @@ -0,0 +1,44 @@ +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import { getDevicePerformanceProfile } from '@onekeyhq/shared/src/performance/devicePerformanceTier'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; + +import { getTabPreloadEntry } from './preloadConfig'; +import { + type ITabPreloadDecision, + resolveTabPreloadDecision, +} from './preloadPolicyResolver'; + +export interface ITabPreloadPolicy extends ITabPreloadDecision { + queue: ReturnType['queue']; + intervalMs: number; +} + +let hasLoggedDeviceCapability = false; + +export function getTabPreloadPolicy(): ITabPreloadPolicy { + const profile = getDevicePerformanceProfile(); + const decision = resolveTabPreloadDecision({ + cpuTier: profile.cpu.tier, + memoryClass: profile.memory.class, + allowFullPreload: + !platformEnv.isWebMobile && + !platformEnv.isExtensionUiPopup && + !platformEnv.isExtensionUiSidePanel, + }); + if (!hasLoggedDeviceCapability) { + hasLoggedDeviceCapability = true; + defaultLogger.app.perf.deviceCapabilityDetected({ + cpuTier: profile.cpu.tier, + cpuSource: profile.cpu.source, + cpuConfidence: profile.cpu.confidence, + memoryClass: profile.memory.class, + tabPreloadMode: decision.mode, + tabPreloadReason: decision.reason, + dataVersion: profile.dataVersion, + }); + } + return { + ...decision, + ...getTabPreloadEntry(decision.mode), + }; +} diff --git a/packages/kit/src/routes/Tab/preloadPolicyResolver.test.ts b/packages/kit/src/routes/Tab/preloadPolicyResolver.test.ts new file mode 100644 index 000000000000..fc3ee766f8b6 --- /dev/null +++ b/packages/kit/src/routes/Tab/preloadPolicyResolver.test.ts @@ -0,0 +1,78 @@ +import { + EDeviceCpuTier, + EDeviceMemoryClass, +} from '@onekeyhq/shared/src/performance/devicePerformanceTierTypes'; + +import { + ETabPreloadMode, + resolveTabPreloadDecision, +} from './preloadPolicyResolver'; + +describe('resolveTabPreloadDecision', () => { + it('disables preloading when memory is constrained regardless of CPU', () => { + expect( + resolveTabPreloadDecision({ + cpuTier: EDeviceCpuTier.high, + memoryClass: EDeviceMemoryClass.constrained, + }), + ).toEqual({ + mode: ETabPreloadMode.disabled, + reason: 'memory-constrained', + }); + }); + + it('disables preloading for a low-tier CPU with ample memory', () => { + expect( + resolveTabPreloadDecision({ + cpuTier: EDeviceCpuTier.low, + memoryClass: EDeviceMemoryClass.large, + }).mode, + ).toBe(ETabPreloadMode.disabled); + }); + + it('fully preloads for a high-tier CPU when memory is not constrained', () => { + expect( + resolveTabPreloadDecision({ + cpuTier: EDeviceCpuTier.high, + memoryClass: EDeviceMemoryClass.standard, + }).mode, + ).toBe(ETabPreloadMode.full); + }); + + it('uses light preloading when memory capacity is unknown', () => { + expect( + resolveTabPreloadDecision({ + cpuTier: EDeviceCpuTier.high, + memoryClass: EDeviceMemoryClass.unknown, + }), + ).toEqual({ + mode: ETabPreloadMode.light, + reason: 'memory-unknown', + }); + }); + + it('caps high-tier devices at light preloading on limited surfaces', () => { + expect( + resolveTabPreloadDecision({ + cpuTier: EDeviceCpuTier.high, + memoryClass: EDeviceMemoryClass.large, + allowFullPreload: false, + }), + ).toEqual({ + mode: ETabPreloadMode.light, + reason: 'surface-limited', + }); + }); + + it.each([EDeviceCpuTier.medium, EDeviceCpuTier.unknown])( + 'uses light preloading for a %s CPU when memory is not constrained', + (cpuTier) => { + expect( + resolveTabPreloadDecision({ + cpuTier, + memoryClass: EDeviceMemoryClass.large, + }).mode, + ).toBe(ETabPreloadMode.light); + }, + ); +}); diff --git a/packages/kit/src/routes/Tab/preloadPolicyResolver.ts b/packages/kit/src/routes/Tab/preloadPolicyResolver.ts new file mode 100644 index 000000000000..c37e1fdf090c --- /dev/null +++ b/packages/kit/src/routes/Tab/preloadPolicyResolver.ts @@ -0,0 +1,60 @@ +import { + EDeviceCpuTier, + EDeviceMemoryClass, +} from '@onekeyhq/shared/src/performance/devicePerformanceTierTypes'; + +export enum ETabPreloadMode { + full = 'full', + light = 'light', + disabled = 'disabled', +} + +export type ITabPreloadReason = + | 'cpu-high' + | 'cpu-low' + | 'cpu-medium' + | 'cpu-unknown' + | 'legacy-high' + | 'legacy-low' + | 'legacy-medium' + | 'memory-constrained' + | 'memory-unknown' + | 'surface-limited'; + +export interface ITabPreloadDecision { + mode: ETabPreloadMode; + reason: ITabPreloadReason; +} + +export function resolveTabPreloadDecision({ + cpuTier, + memoryClass, + allowFullPreload = true, +}: { + cpuTier: EDeviceCpuTier; + memoryClass: EDeviceMemoryClass; + allowFullPreload?: boolean; +}): ITabPreloadDecision { + if (memoryClass === EDeviceMemoryClass.constrained) { + return { + mode: ETabPreloadMode.disabled, + reason: 'memory-constrained', + }; + } + if (cpuTier === EDeviceCpuTier.low) { + return { mode: ETabPreloadMode.disabled, reason: 'cpu-low' }; + } + if (cpuTier === EDeviceCpuTier.high) { + if (memoryClass === EDeviceMemoryClass.unknown) { + return { mode: ETabPreloadMode.light, reason: 'memory-unknown' }; + } + if (!allowFullPreload) { + return { mode: ETabPreloadMode.light, reason: 'surface-limited' }; + } + return { mode: ETabPreloadMode.full, reason: 'cpu-high' }; + } + if (cpuTier === EDeviceCpuTier.medium) { + return { mode: ETabPreloadMode.light, reason: 'cpu-medium' }; + } + return { mode: ETabPreloadMode.light, reason: 'cpu-unknown' }; +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBar.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBar.tsx index 69f148235c11..b7838cfeff18 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBar.tsx +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBar.tsx @@ -24,7 +24,6 @@ import { useAccountSelectorStatusAtom, useSettingsPersistAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; -import { analytics } from '@onekeyhq/shared/src/analytics'; import { emptyArray } from '@onekeyhq/shared/src/consts'; import { BOT_WALLET_STATUS_DEACTIVATED } from '@onekeyhq/shared/src/consts/dbConsts'; import { @@ -44,7 +43,6 @@ import { AccountSelectorCreateWalletButton } from './AccountSelectorCreateWallet import { WalletListItem } from './WalletListItem'; import { buildGroupedAccountSelectorWallets, - computeHwVendorProfile, getWalletChildrenLength, } from './walletListUtils'; @@ -216,32 +214,6 @@ export function AccountSelectorWalletListSideBar({ walletsCount: wallets?.length ?? 0, }); - useEffect(() => { - const walletCount = wallets.reduce( - (count, wallet) => count + 1 + (wallet.botWallets?.length ?? 0), - 0, - ); - if (walletCount > 0) { - const hwWalletCount = wallets.filter( - (wallet) => wallet.type === 'hw', - ).length; - const keylessWalletCount = wallets.filter( - (wallet) => wallet.isKeyless, - ).length; - const appWalletCount = walletCount - hwWalletCount; - const { hwVendors, primaryHwVendor } = computeHwVendorProfile(wallets); - - analytics.updateUserProfile({ - walletCount, - hwWalletCount, - appWalletCount, - keylessWalletCount, - hwVendors, - primaryHwVendor, - }); - } - }, [wallets]); - useEffect(() => { if ( walletsResult?.wallets && diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/walletListUtils.test.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/walletListUtils.test.ts index 41a80bc0efff..d92f7924e4e2 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/walletListUtils.test.ts +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/walletListUtils.test.ts @@ -1,10 +1,8 @@ import type { IDBWallet } from '@onekeyhq/kit-bg/src/dbs/local/types'; import { BOT_WALLET_STATUS_DEACTIVATED } from '@onekeyhq/shared/src/consts/dbConsts'; -import { EHardwareVendor } from '@onekeyhq/shared/types/device'; import { buildGroupedAccountSelectorWallets, - computeHwVendorProfile, getWalletChildrenLength, } from './walletListUtils'; @@ -145,106 +143,3 @@ describe('walletListUtils', () => { ).toBe(3); }); }); - -function createHwWallet({ - id, - vendor, -}: { - id: string; - vendor?: EHardwareVendor; -}): IDBWallet { - return { - id, - name: id, - type: 'hw', - backuped: true, - accounts: [], - nextIds: {}, - walletNo: 1, - associatedDeviceInfo: vendor ? ({ vendor } as any) : undefined, - } as IDBWallet; -} - -describe('computeHwVendorProfile', () => { - it('returns empty profile when there are no hardware wallets', () => { - expect( - computeHwVendorProfile([ - { id: 'hd-1', name: 'HD', type: 'hd' } as IDBWallet, - ]), - ).toEqual({ hwVendors: [], primaryHwVendor: undefined }); - }); - - it('ignores non-hw wallets when aggregating vendors', () => { - // hd + keyless + imported wallets must not appear in vendor stats - const result = computeHwVendorProfile([ - { id: 'hd-1', type: 'hd' } as IDBWallet, - { id: 'imported-1', type: 'imported' } as IDBWallet, - createHwWallet({ id: 'hw-1', vendor: EHardwareVendor.onekey }), - ]); - expect(result).toEqual({ - hwVendors: ['onekey'], - primaryHwVendor: 'onekey', - }); - }); - - it('falls back to onekey for hw wallets with no vendor on device record', () => { - // Legacy hw wallets created before vendor was persisted should still be - // counted as OneKey, otherwise existing OneKey users would show up as - // having zero hw vendors on a fresh app launch. - expect( - computeHwVendorProfile([createHwWallet({ id: 'hw-legacy' })]), - ).toEqual({ - hwVendors: ['onekey'], - primaryHwVendor: 'onekey', - }); - }); - - it('reports ledger when a single Ledger wallet is present', () => { - expect( - computeHwVendorProfile([ - createHwWallet({ id: 'hw-ledger', vendor: EHardwareVendor.ledger }), - ]), - ).toEqual({ - hwVendors: ['ledger'], - primaryHwVendor: 'ledger', - }); - }); - - it('returns sorted unique vendor list and picks majority as primary', () => { - // Two OneKey wallets vs one Ledger — primary should be OneKey. - // hwVendors is sorted lexically so consumers get stable ordering. - const result = computeHwVendorProfile([ - createHwWallet({ id: 'hw-1', vendor: EHardwareVendor.onekey }), - createHwWallet({ id: 'hw-2', vendor: EHardwareVendor.onekey }), - createHwWallet({ id: 'hw-3', vendor: EHardwareVendor.ledger }), - ]); - expect(result.hwVendors).toEqual(['ledger', 'onekey']); - expect(result.primaryHwVendor).toBe('onekey'); - }); - - it('breaks vendor-count ties deterministically by lexical order', () => { - // 1 OneKey + 1 Ledger — both have count=1. - // The reducer scans sorted vendor keys ['ledger', 'onekey'] and only - // replaces the leader on strict-greater, so the first key ('ledger') wins. - // Document this so consumers don't depend on a different tiebreak. - const result = computeHwVendorProfile([ - createHwWallet({ id: 'hw-1', vendor: EHardwareVendor.onekey }), - createHwWallet({ id: 'hw-2', vendor: EHardwareVendor.ledger }), - ]); - expect(result.hwVendors).toEqual(['ledger', 'onekey']); - expect(result.primaryHwVendor).toBe('ledger'); - }); - - it('handles mixed legacy (no vendor) + Ledger as onekey + ledger', () => { - // Realistic 6.3.0 upgrade scenario: existing OneKey user (legacy device - // record without vendor field) adds a Ledger wallet. Profile must show - // both vendors and pick majority. - const result = computeHwVendorProfile([ - createHwWallet({ id: 'hw-onekey-legacy' }), - createHwWallet({ id: 'hw-onekey-legacy-2' }), - createHwWallet({ id: 'hw-ledger', vendor: EHardwareVendor.ledger }), - ]); - expect(result.hwVendors).toEqual(['ledger', 'onekey']); - expect(result.primaryHwVendor).toBe('onekey'); - }); -}); diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/walletListUtils.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/walletListUtils.ts index 676c5ecf65bd..8487846adee8 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/walletListUtils.ts +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/walletListUtils.ts @@ -2,10 +2,8 @@ import type { IDBWallet } from '@onekeyhq/kit-bg/src/dbs/local/types'; import { BOT_WALLET_STATUS_ACTIVE, BOT_WALLET_STATUS_DEACTIVATED, - WALLET_TYPE_HW, } from '@onekeyhq/shared/src/consts/dbConsts'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; -import { EHardwareVendor } from '@onekeyhq/shared/types/device'; import type { IAccountSelectorWalletInfo } from '../../../type'; @@ -123,33 +121,3 @@ export function buildGroupedAccountSelectorWallets( return [...topLevelWallets, ...orphanBotWallets]; } - -// Aggregate hardware-wallet vendor info for analytics user profile. -// Legacy hw wallets without `associatedDeviceInfo.vendor` are counted as -// 'onekey' (vendor is a runtime field; older device records may not have it -// populated until next connect). Returns sorted unique vendor list and the -// vendor with the most wallets (deterministic tiebreak via lexical sort). -type IHwVendorProfileWallet = Pick; - -export function computeHwVendorProfile( - wallets: readonly IHwVendorProfileWallet[], -): { - hwVendors: string[]; - primaryHwVendor: string | undefined; -} { - const hwWallets = wallets.filter((w) => w.type === WALLET_TYPE_HW); - if (hwWallets.length === 0) { - return { hwVendors: [], primaryHwVendor: undefined }; - } - const counts = hwWallets.reduce>((acc, wallet) => { - const vendor = - wallet.associatedDeviceInfo?.vendor ?? EHardwareVendor.onekey; - acc[vendor] = (acc[vendor] ?? 0) + 1; - return acc; - }, {}); - const hwVendors = Object.keys(counts).toSorted(); - const primaryHwVendor = hwVendors.reduce((leader, v) => - counts[v] > counts[leader] ? v : leader, - ); - return { hwVendors, primaryHwVendor }; -} diff --git a/packages/kit/src/views/Discovery/config/webviewAliveLimit.native.ts b/packages/kit/src/views/Discovery/config/webviewAliveLimit.native.ts index 69f7f5948c71..1565925d8646 100644 --- a/packages/kit/src/views/Discovery/config/webviewAliveLimit.native.ts +++ b/packages/kit/src/views/Discovery/config/webviewAliveLimit.native.ts @@ -2,35 +2,31 @@ import { getDeviceMemoryGBSync, isLowEndMemory, } from '@onekeyhq/shared/src/performance/deviceMemory'; +import { resolveMemoryClass } from '@onekeyhq/shared/src/performance/devicePerformanceTierResolver'; +import { EDeviceMemoryClass } from '@onekeyhq/shared/src/performance/devicePerformanceTierTypes'; // Native cap on simultaneously-mounted DApp WebViews, tiered by device RAM. // Each live WebView carries its own renderer process (WKWebView WebContent on // iOS, a sandboxed renderer on Android), so low-memory devices must keep fewer // alive to avoid jetsam/OOM kills. See ./webviewAliveLimit.ts for the rationale. // -// RAM and the low-end classification both come from the shared device-memory -// module so this cap can never disagree with the perf tier / cold-start guard -// on which devices are low-end. - -// Upper bound of the mid-range tier (GB). Devices above this are flagships. -const MID_MEM_THRESHOLD_GB = 6; +// RAM and the constrained-memory predicate come from shared capability code so +// this cap and the cold-start guard agree on low-memory devices. function resolveMaxAliveWebViewCount(): number { const memGB = getDeviceMemoryGBSync(); - if (memGB === null || memGB <= 0) { - // Unknown RAM → stay conservative. - return 3; - } - // Shares the jetsam low-end classification so a device the cold-start guard - // treats as low-end (e.g. iPhone 7 Plus ~3.14GB) keeps the fewest WebViews - // alive instead of being bumped into the mid tier. - if (isLowEndMemory(memGB)) { - return 3; // low-end + const memoryClass = resolveMemoryClass({ + memoryGB: memGB, + isMemoryConstrained: memGB !== null ? isLowEndMemory(memGB) : false, + }); + if (memoryClass === EDeviceMemoryClass.large) { + return 6; } - if (memGB <= MID_MEM_THRESHOLD_GB) { - return 5; // mid-range + if (memoryClass === EDeviceMemoryClass.standard) { + return 5; } - return 6; // high-end + // Unknown or constrained memory stays conservative. + return 3; } export const MAX_ALIVE_WEBVIEW_COUNT = resolveMaxAliveWebViewCount(); diff --git a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx index 56cf97b16b30..fec68dcc009e 100644 --- a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx +++ b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx @@ -949,11 +949,12 @@ const BaseDevSettingsSection = () => { typeof __BUNDLE_START_TIME__ !== 'undefined' ? __BUNDLE_START_TIME__ : 0; - const { getDevicePerformanceTier } = + const { getDevicePerformanceProfile } = await import('@onekeyhq/shared/src/performance/devicePerformanceTier'); Dialog.debugMessage({ debugMessage: { - devicePerformanceTier: getDevicePerformanceTier(), + devicePerformanceProfile: + getDevicePerformanceProfile(), startupTimeAt: await LaunchOptionsManager.getStartupTimeAt(), jsReadyTimeAt: diff --git a/packages/shared/src/analytics/index.test.ts b/packages/shared/src/analytics/index.test.ts new file mode 100644 index 000000000000..9d5ff1e7a178 --- /dev/null +++ b/packages/shared/src/analytics/index.test.ts @@ -0,0 +1,133 @@ +import { Analytics } from '.'; + +const mockPost = jest.fn(() => Promise.resolve()); +const mockGetDeviceCpuTier = jest.fn(() => 'high'); +const mockGetDeviceInfo = jest.fn(() => + Promise.resolve({ deviceId: 'device-id' }), +); + +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: () => ({ post: mockPost }), + }, +})); + +jest.mock('../appGlobals', () => ({ + __esModule: true, + default: {}, +})); + +jest.mock('../modules3rdParty/webEmebd/postMessage', () => ({ + EWebEmbedPostMessageType: { TrackEvent: 'TrackEvent' }, + postMessage: jest.fn(), +})); + +jest.mock('../performance/devicePerformanceTier', () => ({ + getDeviceCpuTier: mockGetDeviceCpuTier, +})); + +jest.mock('../platformEnv', () => ({ + __esModule: true, + default: { + appPlatform: 'web', + buildNumber: '1', + isDev: false, + isE2E: false, + isNative: false, + isWebEmbed: false, + version: '1.0.0', + }, +})); + +jest.mock('../request/InterceptorConsts', () => ({ + headerPlatform: 'web', +})); + +jest.mock('../utils/ipTableUtils', () => ({ + isSupportIpTablePlatform: () => false, +})); + +jest.mock('./deviceInfo', () => ({ + getDeviceInfo: () => mockGetDeviceInfo(), +})); + +async function waitForPostCount(expectedCount: number) { + for (let i = 0; i < 10; i += 1) { + if (mockPost.mock.calls.length >= expectedCount) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe('Analytics tier', () => { + beforeEach(() => { + mockPost.mockClear(); + mockGetDeviceCpuTier.mockClear(); + mockGetDeviceInfo.mockClear(); + }); + + it('adds the resolved tier to event and profile requests', async () => { + const analytics = new Analytics(); + analytics.init({ instanceId: 'instance-id', baseURL: 'https://utility' }); + + analytics.trackEvent('testEvent', { tier: 1 }); + await waitForPostCount(1); + + expect(mockPost).toHaveBeenNthCalledWith( + 1, + '/utility/v1/track/event', + expect.objectContaining({ + eventName: 'testEvent', + eventProps: expect.objectContaining({ tier: 3 }), + }), + ); + + analytics.updateUserProfile({ hwWalletCount: 2 }); + await waitForPostCount(2); + + expect(mockPost).toHaveBeenNthCalledWith( + 2, + '/utility/v1/track/attributes', + expect.objectContaining({ + attributes: expect.objectContaining({ tier: 3 }), + }), + ); + expect(mockGetDeviceCpuTier).toHaveBeenCalledTimes(1); + }); + + it('shares complete device info across concurrent first requests', async () => { + let resolveDeviceInfo: + | ((deviceInfo: { deviceId: string }) => void) + | undefined; + mockGetDeviceInfo.mockReturnValueOnce( + new Promise((resolve) => { + resolveDeviceInfo = resolve; + }), + ); + const analytics = new Analytics(); + analytics.init({ instanceId: 'instance-id', baseURL: 'https://utility' }); + + analytics.trackEvent('testEvent'); + analytics.updateUserProfile({ hwWalletCount: 2 }); + resolveDeviceInfo?.({ deviceId: 'device-id' }); + await waitForPostCount(2); + + expect(mockGetDeviceInfo).toHaveBeenCalledTimes(1); + expect(mockGetDeviceCpuTier).toHaveBeenCalledTimes(1); + expect(mockPost).toHaveBeenCalledTimes(2); + expect(mockPost).toHaveBeenCalledWith( + '/utility/v1/track/event', + expect.objectContaining({ + eventProps: expect.objectContaining({ tier: 3 }), + }), + ); + expect(mockPost).toHaveBeenCalledWith( + '/utility/v1/track/attributes', + expect.objectContaining({ + attributes: expect.objectContaining({ tier: 3 }), + }), + ); + }); +}); diff --git a/packages/shared/src/analytics/index.ts b/packages/shared/src/analytics/index.ts index c3a47cfaab7f..ebea57fa1ba8 100644 --- a/packages/shared/src/analytics/index.ts +++ b/packages/shared/src/analytics/index.ts @@ -10,7 +10,9 @@ import platformEnv from '../platformEnv'; import { headerPlatform } from '../request/InterceptorConsts'; import { getDeviceInfo } from './deviceInfo'; +import { type TAnalyticsTier, getAnalyticsTier } from './tier'; +import type { IAnalyticsUserProfile } from './type'; import type { AxiosInstance } from 'axios'; export const ANALYTICS_EVENT_PATH = '/utility/v1/track'; @@ -18,6 +20,10 @@ export const ANALYTICS_EVENT_PATH = '/utility/v1/track'; const TRACK_EVENT_PATH = `${ANALYTICS_EVENT_PATH}/event`; const TRACK_ATTRIBUTES_PATH = `${ANALYTICS_EVENT_PATH}/attributes`; +type IAnalyticsDeviceInfo = Record & { + tier: TAnalyticsTier; +}; + export class Analytics { private instanceId = ''; @@ -25,7 +31,7 @@ export class Analytics { private cacheEvents = [] as [string, Record | undefined][]; - private cacheUserProfile = [] as Record[]; + private cacheUserProfile = [] as IAnalyticsUserProfile[]; private request: AxiosInstance | null = null; @@ -33,7 +39,7 @@ export class Analytics { pageName: string; }; - private deviceInfo: Record | null = null; + private deviceInfoPromise: Promise | null = null; private enableAnalyticsInDev = false; @@ -115,15 +121,31 @@ export class Analytics { } } - private async lazyDeviceInfo() { - if (!this.deviceInfo) { - this.deviceInfo = await getDeviceInfo(); - this.deviceInfo.platform = headerPlatform; - this.deviceInfo.appBuildNumber = platformEnv.buildNumber; - this.deviceInfo.appVersion = platformEnv.version; + private async lazyDeviceInfo(): Promise { + let deviceInfoPromise = this.deviceInfoPromise; + if (!deviceInfoPromise) { + deviceInfoPromise = (async () => { + const deviceInfo = await getDeviceInfo(); + const { getDeviceCpuTier } = + await import('../performance/devicePerformanceTier'); + return { + ...deviceInfo, + tier: getAnalyticsTier(getDeviceCpuTier()), + platform: headerPlatform, + appBuildNumber: platformEnv.buildNumber, + appVersion: platformEnv.version, + }; + })().catch((error) => { + this.deviceInfoPromise = null; + throw error; + }); + this.deviceInfoPromise = deviceInfoPromise; } - this.deviceInfo.pageName = this.basicInfo.pageName; - return this.deviceInfo; + const deviceInfo = await deviceInfoPromise; + return { + ...deviceInfo, + pageName: this.basicInfo.pageName, + }; } private async requestEvent( @@ -140,8 +162,9 @@ export class Analytics { const event = { ...deviceInfo, ...eventProps, + tier: deviceInfo.tier, distinct_id: this.instanceId, - } as Record; + } as Record; if ( !platformEnv.isNative && // eslint-disable-next-line unicorn/prefer-global-this @@ -158,7 +181,7 @@ export class Analytics { }); } - private async requestUserProfile(attributes: Record) { + private async requestUserProfile(attributes: IAnalyticsUserProfile) { if ( (platformEnv.isDev || platformEnv.isE2E) && !this.enableAnalyticsInDev @@ -175,14 +198,7 @@ export class Analytics { }); } - public updateUserProfile(attributes: { - walletCount?: number; - appWalletCount?: number; - hwWalletCount?: number; - keylessWalletCount?: number; - hwVendors?: string[]; - primaryHwVendor?: string; - }) { + public updateUserProfile(attributes: IAnalyticsUserProfile) { if (this.instanceId && this.baseURL) { void this.requestUserProfile(attributes); } else { diff --git a/packages/shared/src/analytics/tier.test.ts b/packages/shared/src/analytics/tier.test.ts new file mode 100644 index 000000000000..8a0303cf5cb2 --- /dev/null +++ b/packages/shared/src/analytics/tier.test.ts @@ -0,0 +1,12 @@ +import { getAnalyticsTier } from './tier'; + +describe('getAnalyticsTier', () => { + it.each([ + ['low', 1], + ['medium', 2], + ['high', 3], + ['unknown', 2], + ] as const)('maps %s CPU tier to analytics tier %i', (tier, value) => { + expect(getAnalyticsTier(tier)).toBe(value); + }); +}); diff --git a/packages/shared/src/analytics/tier.ts b/packages/shared/src/analytics/tier.ts new file mode 100644 index 000000000000..80477a6fade9 --- /dev/null +++ b/packages/shared/src/analytics/tier.ts @@ -0,0 +1,15 @@ +import type { EDeviceCpuTier } from '../performance/devicePerformanceTierTypes'; + +export type TAnalyticsTier = 1 | 2 | 3; + +export function getAnalyticsTier(cpuTier: EDeviceCpuTier): TAnalyticsTier { + if (cpuTier === 'low') { + return 1; + } + if (cpuTier === 'high') { + return 3; + } + // Keep unknown devices on the legacy medium fallback so every request uses + // one of the three server-supported tiers. + return 2; +} diff --git a/packages/shared/src/analytics/type.ts b/packages/shared/src/analytics/type.ts index e391348efad6..450ccad37b72 100644 --- a/packages/shared/src/analytics/type.ts +++ b/packages/shared/src/analytics/type.ts @@ -10,3 +10,12 @@ export interface IDeviceInfo { } export type IGetDeviceInfo = () => Promise; + +export interface IAnalyticsUserProfile { + walletCount?: number; + appWalletCount?: number; + hwWalletCount?: number; + keylessWalletCount?: number; + hwVendors?: string[]; + primaryHwVendor?: string; +} diff --git a/packages/shared/src/logger/scopes/app/scenes/perf.ts b/packages/shared/src/logger/scopes/app/scenes/perf.ts index 9c8db9070cb2..3384757018b2 100644 --- a/packages/shared/src/logger/scopes/app/scenes/perf.ts +++ b/packages/shared/src/logger/scopes/app/scenes/perf.ts @@ -108,4 +108,17 @@ export class AppPerfScene extends BaseScene { }) { return params; } + + @LogToLocal() + public deviceCapabilityDetected(params: { + cpuTier: string; + cpuSource: string; + cpuConfidence: string; + memoryClass: string; + tabPreloadMode: string; + tabPreloadReason: string; + dataVersion: string; + }) { + return params; + } } diff --git a/packages/shared/src/performance/deviceCpuTier.android.ts b/packages/shared/src/performance/deviceCpuTier.android.ts new file mode 100644 index 000000000000..7fe019c2916d --- /dev/null +++ b/packages/shared/src/performance/deviceCpuTier.android.ts @@ -0,0 +1,27 @@ +import { manufacturer, modelName } from 'expo-device'; + +import { getAndroidDeviceCpuTier } from './deviceCpuTierData/android'; +import { normalizeDeviceCpuTierKeyPart } from './deviceCpuTierUtils'; +import { isKnownDeviceCpuTier } from './devicePerformanceTierTypes'; + +import type { IDeviceCpuTierMatch } from './devicePerformanceTierTypes'; + +export function getDeviceCpuTierMatch(): IDeviceCpuTierMatch | null { + const normalizedManufacturer = normalizeDeviceCpuTierKeyPart(manufacturer); + const normalizedModel = normalizeDeviceCpuTierKeyPart(modelName); + if (!normalizedManufacturer || !normalizedModel) { + return null; + } + const tier = getAndroidDeviceCpuTier({ + manufacturer: normalizedManufacturer, + model: normalizedModel, + }); + if (!isKnownDeviceCpuTier(tier)) { + return null; + } + return { + tier, + source: 'androidModel', + confidence: 'medium', + }; +} diff --git a/packages/shared/src/performance/deviceCpuTier.ios.ts b/packages/shared/src/performance/deviceCpuTier.ios.ts new file mode 100644 index 000000000000..5fb02699f201 --- /dev/null +++ b/packages/shared/src/performance/deviceCpuTier.ios.ts @@ -0,0 +1,22 @@ +import { modelId } from 'expo-device'; + +import { getIosDeviceCpuTier } from './deviceCpuTierData/ios'; +import { normalizeDeviceCpuTierKeyPart } from './deviceCpuTierUtils'; + +import type { IDeviceCpuTierMatch } from './devicePerformanceTierTypes'; + +export function getDeviceCpuTierMatch(): IDeviceCpuTierMatch | null { + const normalizedModelId = normalizeDeviceCpuTierKeyPart(modelId); + const modelIdTier = normalizedModelId + ? getIosDeviceCpuTier(normalizedModelId) + : undefined; + if (modelIdTier !== undefined) { + return { + tier: modelIdTier, + source: 'iosModelId', + confidence: 'high', + }; + } + + return null; +} diff --git a/packages/shared/src/performance/deviceCpuTier.ts b/packages/shared/src/performance/deviceCpuTier.ts new file mode 100644 index 000000000000..cbe4b248095f --- /dev/null +++ b/packages/shared/src/performance/deviceCpuTier.ts @@ -0,0 +1,19 @@ +import { resolveLogicalProcessorCpuTier } from './deviceCpuTierUtils'; + +import type { IDeviceCpuTierMatch } from './devicePerformanceTierTypes'; + +export function getDeviceCpuTierMatch(): IDeviceCpuTierMatch | null { + const tier = resolveLogicalProcessorCpuTier( + typeof navigator === 'undefined' + ? undefined + : navigator.hardwareConcurrency, + ); + if (tier === null) { + return null; + } + return { + tier, + source: 'browserHardwareConcurrency', + confidence: 'medium', + }; +} diff --git a/packages/shared/src/performance/deviceCpuTierData.test.ts b/packages/shared/src/performance/deviceCpuTierData.test.ts new file mode 100644 index 000000000000..94075fd0ab0b --- /dev/null +++ b/packages/shared/src/performance/deviceCpuTierData.test.ts @@ -0,0 +1,105 @@ +import { + ANDROID_DEVICE_CPU_TIER_BY_MANUFACTURER, + getAndroidDeviceCpuTier, +} from './deviceCpuTierData/android'; +import { getIosDeviceCpuTier } from './deviceCpuTierData/ios'; +import { normalizeDeviceCpuTierKeyPart } from './deviceCpuTierUtils'; +import { + EDeviceCpuTier, + isKnownDeviceCpuTier, +} from './devicePerformanceTierTypes'; + +const AUDITED_ANDROID_SINGLE_CORE_FIXTURES = [ + { + manufacturer: 'Motorola', + model: 'moto g power 5g - 2024', + singleCoreScore: 917, + }, + { + manufacturer: 'Motorola', + model: 'moto g power 5g - 2024', + singleCoreScore: 903, + }, + { + manufacturer: 'Motorola', + model: 'moto g64 5g', + singleCoreScore: 1022, + }, + { + manufacturer: 'Tecno', + model: 'tecno cl7', + singleCoreScore: 920, + }, +] as const; + +const getExpectedTierForSingleCoreScore = (singleCoreScore: number) => { + if (singleCoreScore < 1000) { + return EDeviceCpuTier.low; + } + if (singleCoreScore < 1800) { + return EDeviceCpuTier.medium; + } + return EDeviceCpuTier.high; +}; + +describe('deviceCpuTierData', () => { + it('contains the Motorola One 5G UW ace regression fixture', () => { + const manufacturer = normalizeDeviceCpuTierKeyPart('Motorola'); + const model = normalizeDeviceCpuTierKeyPart('motorola one 5G UW ace'); + + expect(ANDROID_DEVICE_CPU_TIER_BY_MANUFACTURER[manufacturer]?.[model]).toBe( + EDeviceCpuTier.low, + ); + }); + + it.each(AUDITED_ANDROID_SINGLE_CORE_FIXTURES)( + 'classifies $manufacturer $model from audited single-core score $singleCoreScore', + ({ manufacturer, model, singleCoreScore }) => { + expect( + getAndroidDeviceCpuTier({ + manufacturer: normalizeDeviceCpuTierKeyPart(manufacturer), + model: normalizeDeviceCpuTierKeyPart(model), + }), + ).toBe(getExpectedTierForSingleCoreScore(singleCoreScore)); + }, + ); + + it.each([ + ['iPhone18,1', EDeviceCpuTier.high], + ['iPad7,1', EDeviceCpuTier.low], + ])('contains the iOS model ID %s', (modelId, expectedTier) => { + const key = normalizeDeviceCpuTierKeyPart(modelId); + + expect(getIosDeviceCpuTier(key)).toBe(expectedTier); + }); + + it('contains only manufacturer and model identifiers', () => { + const directIdentifierPattern = + /@|https?:\/\/|www\.|(?:\d{1,3}\.){3}\d{1,3}|[0-9a-f]{8}-[0-9a-f-]{27,}/i; + + for (const [manufacturer, models] of Object.entries( + ANDROID_DEVICE_CPU_TIER_BY_MANUFACTURER, + )) { + expect(manufacturer).toBeTruthy(); + expect(manufacturer).not.toMatch(directIdentifierPattern); + for (const [model, tier] of Object.entries(models)) { + expect(model).toBeTruthy(); + expect(model).not.toMatch(directIdentifierPattern); + expect(isKnownDeviceCpuTier(tier)).toBe(true); + } + } + }); + + it('rejects prototype values as CPU tiers', () => { + expect(isKnownDeviceCpuTier(Object.prototype)).toBe(false); + expect(isKnownDeviceCpuTier(Object.prototype.constructor)).toBe(false); + expect( + getAndroidDeviceCpuTier({ + manufacturer: '__proto__', + model: 'constructor', + }), + ).toBeUndefined(); + expect(getIosDeviceCpuTier('__proto__')).toBeUndefined(); + expect(getIosDeviceCpuTier('constructor')).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/performance/deviceCpuTierData/android.ts b/packages/shared/src/performance/deviceCpuTierData/android.ts new file mode 100644 index 000000000000..eff316eadaee --- /dev/null +++ b/packages/shared/src/performance/deviceCpuTierData/android.ts @@ -0,0 +1,1558 @@ +/* cspell:disable -- device catalog identifiers */ +import type { TKnownDeviceCpuTier } from '../devicePerformanceTierTypes'; + +// Business tiers use Geekbench 6 single-core thresholds: low < 1000, +// medium 1000-1799, and high >= 1800. These are not Geekbench classifications. +export const ANDROID_DEVICE_CPU_TIER_BY_MANUFACTURER: Readonly< + Record>> +> = { + 'asus': { + 'asus_ai2202': 'medium', + 'asus_ai2205_c': 'high', + 'asus_ai2302': 'high', + 'asus_i003dd': 'medium', + }, + 'blackshark': { + 'skw-a0': 'low', + }, + 'blackview': { + 'bv9200': 'low', + 'bv9300': 'low', + }, + 'bullitt': { + 'motorola defy': 'low', + }, + 'casper': { + 'via_e30': 'low', + }, + 'cat': { + 's62 pro': 'low', + 's75': 'high', + }, + 'cloud mobile': { + 'mc8b654b': 'low', + }, + 'coosea': { + 'sl219a': 'low', + }, + 'coralphone': { + 'coral neural 3': 'low', + }, + 'cubot': { + 'kingkong ace 2': 'low', + }, + 'cyrus': { + 'cs45xa': 'low', + }, + 'doogee': { + 'n100': 'low', + 's99': 'low', + }, + 'fairphone': { + 'fp4': 'low', + }, + 'fcnt': { + 'f-52e': 'high', + }, + 'foxx development inc': { + 'foxxd hth c67': 'low', + }, + 'general mobile': { + 'g318': 'low', + 'g700': 'low', + }, + 'google': { + 'pixel 10': 'high', + 'pixel 10 pro': 'high', + 'pixel 10 pro fold': 'high', + 'pixel 10 pro xl': 'high', + 'pixel 10a': 'medium', + 'pixel 2': 'low', + 'pixel 2 xl': 'low', + 'pixel 3': 'low', + 'pixel 3 xl': 'low', + 'pixel 3a': 'low', + 'pixel 3a xl': 'low', + 'pixel 4': 'low', + 'pixel 4 xl': 'low', + 'pixel 4a': 'low', + 'pixel 4a (5g)': 'low', + 'pixel 5': 'low', + 'pixel 6': 'medium', + 'pixel 6 pro': 'medium', + 'pixel 6a': 'medium', + 'pixel 7': 'medium', + 'pixel 7 pro': 'medium', + 'pixel 7a': 'medium', + 'pixel 8': 'medium', + 'pixel 8 pro': 'medium', + 'pixel 8a': 'medium', + 'pixel 9': 'medium', + 'pixel 9 pro': 'high', + 'pixel 9 pro fold': 'medium', + 'pixel 9 pro xl': 'high', + 'pixel 9a': 'medium', + 'pixel fold': 'medium', + 'pixel tablet': 'medium', + }, + 'hmd global': { + 'n159v': 'low', + 'nokia 5.4': 'low', + 'nokia c210': 'low', + 'nokia c300': 'low', + 'nokia g60 5g': 'low', + 'nokia x30 5g': 'low', + 'ta-1052': 'low', + }, + 'honor': { + 'abr-lx3': 'low', + 'abr-nx1': 'high', + 'ali-an00': 'low', + 'alp-an00': 'low', + 'alt-lx2': 'low', + 'bkq-an10': 'high', + 'bkq-an90': 'high', + 'bkq-n49': 'high', + 'brc-an00': 'high', + 'brc-nx1': 'high', + 'bvl-an00': 'high', + 'bvl-an16': 'high', + 'bvl-an20': 'high', + 'bvl-n49': 'high', + 'clk-lx2': 'low', + 'clk-lx3': 'low', + 'dnp-an00': 'high', + 'dnp-nx9': 'high', + 'eln-l09': 'low', + 'eln-w09': 'low', + 'elp-an00': 'high', + 'elp-nx9': 'high', + 'elz-an20': 'high', + 'fcp-an10': 'high', + 'fcp-n49': 'high', + 'fne-an00': 'low', + 'fne-nx9': 'low', + 'fri-an00': 'medium', + 'hey-w09': 'low', + 'ldy-an00': 'high', + 'lge-an10': 'medium', + 'lge-nx9': 'medium', + 'lgn-lx2': 'low', + 'lgn-lx3': 'low', + 'lgn-nx1': 'low', + 'lly-lx1': 'low', + 'lly-lx3': 'low', + 'lya-al00': 'low', + 'maa-an10': 'high', + 'mag-an00': 'high', + 'mar-al00': 'low', + 'mbh-an10': 'high', + 'mbh-n49': 'high', + 'ndl-w09': 'low', + 'nth-an00': 'low', + 'nth-nx9': 'low', + 'pgt-an10': 'high', + 'pgt-n19': 'medium', + 'pnm-an10': 'high', + 'pnm-n49': 'high', + 'ppg-an00': 'high', + 'ptp-an00': 'high', + 'ptp-an10': 'high', + 'ptp-an60': 'high', + 'ptp-an70': 'high', + 'ptp-n29': 'high', + 'ptp-n49': 'high', + 'rmo-nx1': 'low', + 'rmo-nx3': 'low', + 'rod2-w09': 'high', + 'tfy-lx3': 'low', + 'vca-an00': 'low', + 'ver-an10': 'high', + 'ver-n49': 'high', + }, + 'htc': { + 'htc u20 5g': 'low', + }, + 'huawei': { + 'alp-al00': 'low', + 'alp-l29': 'low', + 'ana-an00': 'low', + 'bkl-al20': 'low', + 'bla-al00': 'low', + 'clt-al00': 'low', + 'col-al10': 'low', + 'dub-lx1': 'low', + 'ele-al00': 'low', + 'ele-l04': 'low', + 'ele-l29': 'low', + 'eml-al00': 'low', + 'evr-al00': 'low', + 'evr-an00': 'low', + 'evr-l29': 'low', + 'evr-tl00': 'low', + 'hlk-al00': 'low', + 'hlk-al00a': 'low', + 'hma-al00': 'low', + 'hma-l29': 'low', + 'hma-tl00': 'low', + 'hry-lx1': 'low', + 'ine-al00': 'low', + 'ine-lx1': 'low', + 'ine-lx2': 'low', + 'jkm-lx1': 'low', + 'jkm-lx2': 'low', + 'jny-lx1': 'low', + 'jsn-al00a': 'low', + 'jsn-l21': 'low', + 'lya-al00': 'low', + 'lya-al10': 'low', + 'lya-l29': 'low', + 'mar-lx1a': 'low', + 'mar-lx1m': 'low', + 'mar-lx2b': 'low', + 'mar-lx3a': 'low', + 'mha-al00': 'low', + 'neo-al00': 'low', + 'par-al00': 'low', + 'pct-al10': 'low', + 'pot-lx1t': 'low', + 'rvl-al09': 'low', + 'sne-lx1': 'low', + 'stk-l21': 'low', + 'stk-l22': 'low', + 'stk-lx1': 'low', + 'vce-al00': 'low', + 'vog-al00': 'low', + 'vog-al10': 'low', + 'vog-l04': 'low', + 'vog-l09': 'low', + 'vog-l29': 'low', + 'vog-tl00': 'low', + 'yal-al00': 'low', + 'yal-al10': 'low', + 'yal-al50': 'low', + }, + 'hxy': { + 'bison pro': 'low', + }, + 'infinix': { + 'infinix x6531': 'low', + 'infinix x6531b': 'low', + 'infinix x6731': 'medium', + 'infinix x6731b': 'low', + 'infinix x676c': 'low', + 'infinix x678b': 'low', + 'infinix x6835b': 'low', + 'infinix x6837': 'low', + 'infinix x6840': 'low', + 'infinix x6853': 'low', + 'infinix x6880': 'low', + }, + 'infinix mobility limited': { + 'infinix x663': 'low', + 'infinix x6811': 'low', + 'infinix x6812b': 'low', + 'infinix x682b': 'low', + 'infinix x682c': 'low', + 'infinix x683': 'low', + 'infinix x687': 'low', + 'infinix x687b': 'low', + 'infinix x688b': 'low', + 'infinix x689': 'low', + 'infinix x689b': 'low', + 'infinix x690b': 'low', + 'infinix x692': 'low', + 'infinix x693': 'low', + 'infinix x695': 'low', + 'infinix x695c': 'low', + }, + 'itel': { + 'itel s666ln': 'low', + }, + 'kyocera': { + 'kyg01': 'low', + }, + 'lenovo': { + 'tb311xu': 'low', + 'tb320fc': 'medium', + 'tb321fu': 'high', + 'tb371fc': 'low', + 'tb375fc': 'low', + }, + 'lge': { + 'lm-q720': 'low', + 'lm-v405': 'low', + }, + 'meizu': { + '16th': 'low', + 'meizu 18': 'medium', + 'meizu 18s pro': 'medium', + 'meizu 18x': 'medium', + 'meizu 20': 'high', + 'meizu 20 inf': 'high', + 'meizu 20 pro': 'high', + 'meizu 21': 'high', + 'meizu 21 note': 'high', + 'meizu 21 pro': 'high', + }, + 'motorola': { + 'moto e22': 'low', + 'moto g 5g': 'low', + 'moto g 5g (2022)': 'medium', + 'moto g 5g plus': 'low', + 'moto g play - 2024': 'low', + 'moto g power': 'low', + 'moto g power (2021)': 'low', + 'moto g power (2022)': 'low', + 'moto g power 5g - 2024': 'low', + 'moto g stylus 5g (2022)': 'low', + 'moto g(30)': 'low', + 'moto g(60)': 'low', + 'moto g(60)s': 'low', + 'moto g(7)': 'low', + 'moto g(7) power': 'low', + 'moto g(8) plus': 'low', + 'moto g(8) power': 'low', + 'moto g(8) power lite': 'low', + 'moto g(9) play': 'low', + 'moto g(9) plus': 'low', + 'moto g(9) power': 'low', + 'moto g22': 'low', + 'moto g24': 'low', + 'moto g31': 'low', + 'moto g32': 'low', + 'moto g34 5g': 'low', + 'moto g41': 'low', + 'moto g42': 'low', + 'moto g52': 'low', + 'moto g55 5g': 'high', + 'moto g56 5g': 'high', + 'moto g64 5g': 'medium', + 'moto g71 5g': 'low', + 'moto g72': 'low', + 'moto g82 5g': 'low', + 'moto g84 5g': 'low', + 'moto g85 5g': 'low', + 'motorola edge (2021)': 'low', + 'motorola edge 20 lite': 'low', + 'motorola edge 30': 'low', + 'motorola edge 30 neo': 'low', + 'motorola edge 30 ultra': 'medium', + 'motorola edge 40': 'medium', + 'motorola edge 50 ultra': 'high', + 'motorola one 5g uw ace': 'low', + 'motorola one fusion': 'low', + 'motorola one fusion+': 'low', + 'motorola razr 50 ultra': 'high', + 'motorola razr 60 ultra': 'high', + 'motorola razr plus 2023': 'medium', + 'motorola razr plus 2024': 'high', + 'thinkphone by motorola': 'medium', + 'xt2153-1': 'medium', + 'xt2175-2': 'medium', + }, + 'nio': { + 'n2301': 'high', + 'n2401': 'high', + }, + 'nothing': { + 'a063': 'medium', + 'a065': 'medium', + 'a142': 'medium', + 'a142p': 'medium', + 'ain065': 'medium', + }, + 'nubia': { + 'np05j': 'high', + 'nx629j': 'low', + 'nx711j': 'high', + 'nx729j': 'high', + 'nx733j': 'high', + 'nx769j': 'high', + 'nx789j': 'high', + 'nx809j': 'high', + }, + 'oneplus': { + 'ac2001': 'low', + 'be2015': 'low', + 'be2029': 'low', + 'cph2381': 'low', + 'cph2401': 'medium', + 'cph2411': 'medium', + 'cph2413': 'medium', + 'cph2415': 'medium', + 'cph2419': 'medium', + 'cph2423': 'medium', + 'cph2447': 'high', + 'cph2449': 'high', + 'cph2451': 'high', + 'cph2465': 'low', + 'cph2467': 'low', + 'cph2487': 'medium', + 'cph2551': 'high', + 'cph2573': 'high', + 'cph2581': 'high', + 'cph2583': 'high', + 'cph2585': 'medium', + 'cph2609': 'medium', + 'cph2611': 'medium', + 'cph2619': 'low', + 'cph2621': 'low', + 'cph2645': 'high', + 'cph2647': 'high', + 'cph2649': 'high', + 'cph2653': 'high', + 'cph2655': 'high', + 'cph2661': 'medium', + 'cph2663': 'medium', + 'cph2691': 'high', + 'cph2707': 'high', + 'cph2709': 'high', + 'cph2723': 'high', + 'cph2745': 'high', + 'cph2747': 'high', + 'cph2749': 'high', + 'de2117': 'low', + 'de2118': 'low', + 'dn2101': 'medium', + 'dn2103': 'medium', + 'eb2101': 'low', + 'gm1900': 'low', + 'gm1910': 'low', + 'gm1911': 'low', + 'gn2200': 'low', + 'hd1900': 'low', + 'hd1901': 'low', + 'hd1910': 'low', + 'hd1913': 'low', + 'in2010': 'medium', + 'in2011': 'medium', + 'in2013': 'medium', + 'in2015': 'medium', + 'in2020': 'medium', + 'in2021': 'medium', + 'iv2201': 'low', + 'kb2000': 'medium', + 'kb2001': 'medium', + 'kb2003': 'medium', + 'kb2005': 'medium', + 'kb2007': 'medium', + 'le2100': 'medium', + 'le2101': 'medium', + 'le2110': 'medium', + 'le2115': 'medium', + 'le2117': 'medium', + 'le2120': 'medium', + 'le2121': 'medium', + 'le2123': 'medium', + 'le2127': 'medium', + 'mt2110': 'medium', + 'mt2111': 'medium', + 'ne2210': 'medium', + 'ne2211': 'medium', + 'ne2213': 'medium', + 'ne2215': 'medium', + 'oneplus 7 pro': 'low', + 'oneplus a5000': 'low', + 'oneplus a5010': 'low', + 'oneplus a6000': 'low', + 'oneplus a6010': 'low', + 'oneplus a6013': 'low', + 'oneplus nord': 'low', + 'oneplus8pro': 'medium', + 'opd2413': 'high', + 'opd2415': 'high', + 'pgkm10': 'medium', + 'pgz110': 'medium', + 'pja110': 'high', + 'pjd110': 'high', + 'pje110': 'high', + 'pjf110': 'medium', + 'pjx110': 'high', + 'pjz110': 'high', + 'pkr110': 'high', + 'pkx110': 'high', + 'plk110': 'high', + 'plq110': 'medium', + 'sweet\\in': 'low', + }, + 'onn': { + '100110027': 'low', + }, + 'oppo': { + 'cph1803': 'low', + 'cph1825': 'low', + 'cph1907': 'low', + 'cph1909': 'low', + 'cph1917': 'low', + 'cph1919': 'low', + 'cph1931': 'low', + 'cph1933': 'low', + 'cph1937': 'low', + 'cph1979': 'low', + 'cph1989': 'low', + 'cph2013': 'low', + 'cph2021': 'low', + 'cph2025': 'medium', + 'cph2069': 'low', + 'cph2083': 'low', + 'cph2095': 'low', + 'cph2113': 'low', + 'cph2145': 'low', + 'cph2159': 'low', + 'cph2173': 'medium', + 'cph2239': 'low', + 'cph2269': 'low', + 'cph2293': 'medium', + 'cph2305': 'medium', + 'cph2325': 'low', + 'cph2349': 'low', + 'cph2359': 'medium', + 'cph2363': 'low', + 'cph2365': 'low', + 'cph2385': 'low', + 'cph2387': 'low', + 'cph2473': 'low', + 'cph2477': 'low', + 'cph2499': 'high', + 'cph2505': 'low', + 'cph2527': 'low', + 'cph2529': 'low', + 'cph2531': 'low', + 'cph2565': 'low', + 'cph2577': 'low', + 'cph2579': 'low', + 'cph2591': 'low', + 'cph2651': 'high', + 'cph2659': 'high', + 'cph2671': 'high', + 'cph2687': 'low', + 'cph2711': 'low', + 'cph2725': 'low', + 'cph2791': 'high', + 'cph2797': 'high', + 'cph2819': 'low', + 'opd2102': 'low', + 'opd2102a': 'low', + 'opd2409': 'high', + 'paam00': 'low', + 'pbdm00': 'low', + 'pbem00': 'low', + 'pbfm00': 'low', + 'pcam00': 'low', + 'pccm00': 'medium', + 'pcdm10': 'low', + 'pcdt10': 'low', + 'pchm10': 'low', + 'pchm30': 'low', + 'pckm00': 'low', + 'pclm10': 'low', + 'pcpm00': 'low', + 'pcrm00': 'low', + 'pdem10': 'medium', + 'pdem30': 'medium', + 'pdnm00': 'low', + 'pdpm00': 'low', + 'pdpt00': 'low', + 'pdrm00': 'medium', + 'pdsm00': 'medium', + 'pdst00': 'medium', + 'pedm00': 'medium', + 'peem00': 'medium', + 'pegt00': 'low', + 'pegt10': 'low', + 'penm00': 'medium', + 'peum00': 'medium', + 'pexm00': 'low', + 'peym00': 'medium', + 'pfdm00': 'medium', + 'pfem10': 'medium', + 'pffm10': 'medium', + 'pfum10': 'low', + 'pfzm10': 'medium', + 'pgam10': 'medium', + 'pgbm10': 'medium', + 'pgem10': 'medium', + 'pgfm10': 'medium', + 'pggm10': 'low', + 'pgjm10': 'medium', + 'pgx110': 'medium', + 'pha120': 'low', + 'phf110': 'low', + 'phj110': 'low', + 'phm110': 'low', + 'phn110': 'high', + 'phq110': 'low', + 'phs110': 'low', + 'phw110': 'low', + 'phy110': 'high', + 'phy120': 'high', + 'phz110': 'high', + 'pjc110': 'low', + 'pkb110': 'high', + 'pkc110': 'high', + 'pkh110': 'high', + 'pkh120': 'high', + 'pkj110': 'high', + 'pkt110': 'high', + 'pku110': 'high', + 'plb110': 'high', + 'plg110': 'high', + 'plg120': 'high', + 'plj110': 'high', + 'plp110': 'high', + 'plp120': 'high', + 'pma110': 'high', + 'pma120': 'high', + 'pme110': 'high', + }, + 'oukitel': { + 'wp7': 'low', + }, + 'realme': { + 'rmx1851': 'low', + 'rmx1931': 'low', + 'rmx1971': 'low', + 'rmx2001': 'low', + 'rmx2002': 'low', + 'rmx2027': 'low', + 'rmx2030': 'low', + 'rmx2050': 'low', + 'rmx2051': 'low', + 'rmx2061': 'low', + 'rmx2063': 'low', + 'rmx2111': 'low', + 'rmx2151': 'low', + 'rmx2155': 'low', + 'rmx2161': 'low', + 'rmx2170': 'low', + 'rmx2185': 'low', + 'rmx2189': 'low', + 'rmx2193': 'low', + 'rmx2202': 'medium', + 'rmx2205': 'low', + 'rmx3031': 'medium', + 'rmx3081': 'medium', + 'rmx3085': 'low', + 'rmx3125': 'low', + 'rmx3151': 'low', + 'rmx3161': 'low', + 'rmx3171': 'low', + 'rmx3195': 'low', + 'rmx3201': 'low', + 'rmx3300': 'medium', + 'rmx3301': 'medium', + 'rmx3350': 'medium', + 'rmx3363': 'low', + 'rmx3370': 'medium', + 'rmx3371': 'medium', + 'rmx3430': 'low', + 'rmx3461': 'low', + 'rmx3472': 'low', + 'rmx3491': 'low', + 'rmx3551': 'medium', + 'rmx3560': 'medium', + 'rmx3561': 'medium', + 'rmx3562': 'medium', + 'rmx3660': 'medium', + 'rmx3661': 'medium', + 'rmx3686': 'medium', + 'rmx3687': 'medium', + 'rmx3700': 'medium', + 'rmx3708': 'medium', + 'rmx3709': 'medium', + 'rmx3710': 'low', + 'rmx3771': 'medium', + 'rmx3820': 'high', + 'rmx3850': 'medium', + 'rmx3851': 'high', + 'rmx3852': 'high', + 'rmx3853': 'medium', + 'rmx3888': 'high', + 'rmx3890': 'low', + 'rmx3910': 'low', + 'rmx3941': 'low', + 'rmx3993': 'low', + 'rmx5010': 'high', + 'rmx5011': 'high', + 'rmx5020': 'low', + 'rmx5060': 'high', + 'rmx5061': 'high', + 'rmx5062': 'high', + 'rmx5090': 'high', + 'rmx5100': 'high', + 'rmx5200': 'high', + 'rmx5210': 'high', + 'rmx5566': 'low', + 'rmx6688': 'high', + 'rmx6699': 'high', + }, + 'redmi': { + '22127rk46c': 'medium', + }, + 'redtone': { + 'rtsp-a124ml': 'low', + }, + 'samsung': { + 'sc-01l': 'low', + 'sc-51b': 'medium', + 'sc-52b': 'medium', + 'sc-52d': 'high', + 'sc-53a': 'medium', + 'sc-53d': 'low', + 'scg06': 'medium', + 'scg09': 'medium', + 'scg18': 'low', + 'scg28': 'high', + 'sm-a037f': 'low', + 'sm-a037m': 'low', + 'sm-a037u': 'low', + 'sm-a042f': 'low', + 'sm-a042m': 'low', + 'sm-a045f': 'low', + 'sm-a045m': 'low', + 'sm-a047f': 'low', + 'sm-a047m': 'low', + 'sm-a057f': 'low', + 'sm-a057m': 'low', + 'sm-a065f': 'low', + 'sm-a075f': 'low', + 'sm-a075m': 'low', + 'sm-a125f': 'low', + 'sm-a125m': 'low', + 'sm-a125u': 'low', + 'sm-a127f': 'low', + 'sm-a127m': 'low', + 'sm-a155f': 'low', + 'sm-a155m': 'low', + 'sm-a155n': 'low', + 'sm-a1560': 'low', + 'sm-a156b': 'low', + 'sm-a156e': 'low', + 'sm-a156m': 'low', + 'sm-a156u': 'low', + 'sm-a156u1': 'low', + 'sm-a165f': 'low', + 'sm-a165m': 'low', + 'sm-a175f': 'low', + 'sm-a205f': 'low', + 'sm-a205g': 'low', + 'sm-a205u': 'low', + 'sm-a207f': 'low', + 'sm-a207m': 'low', + 'sm-a217f': 'low', + 'sm-a217m': 'low', + 'sm-a225f': 'low', + 'sm-a225m': 'low', + 'sm-a226b': 'low', + 'sm-a233c': 'low', + 'sm-a235f': 'low', + 'sm-a235m': 'low', + 'sm-a235n': 'low', + 'sm-a2360': 'low', + 'sm-a236b': 'low', + 'sm-a236e': 'low', + 'sm-a236m': 'low', + 'sm-a236u': 'low', + 'sm-a245f': 'low', + 'sm-a305f': 'low', + 'sm-a305gt': 'low', + 'sm-a307fn': 'low', + 'sm-a307gt': 'low', + 'sm-a315f': 'low', + 'sm-a315g': 'low', + 'sm-a315n': 'low', + 'sm-a325f': 'low', + 'sm-a325m': 'low', + 'sm-a326b': 'low', + 'sm-a326br': 'low', + 'sm-a326u': 'low', + 'sm-a336b': 'low', + 'sm-a336e': 'low', + 'sm-a336m': 'low', + 'sm-a3560': 'low', + 'sm-a356e': 'low', + 'sm-a356u': 'low', + 'sm-a405fn': 'low', + 'sm-a426b': 'low', + 'sm-a505f': 'low', + 'sm-a505fm': 'low', + 'sm-a505fn': 'low', + 'sm-a505gt': 'low', + 'sm-a505u': 'low', + 'sm-a505u1': 'low', + 'sm-a507fn': 'low', + 'sm-a515f': 'low', + 'sm-a5160': 'low', + 'sm-a520f': 'low', + 'sm-a525f': 'low', + 'sm-a525m': 'low', + 'sm-a5260': 'low', + 'sm-a526b': 'low', + 'sm-a526u': 'low', + 'sm-a528b': 'low', + 'sm-a530f': 'low', + 'sm-a530w': 'low', + 'sm-a5360': 'low', + 'sm-a536b': 'low', + 'sm-a536e': 'low', + 'sm-a536u': 'low', + 'sm-a536w': 'low', + 'sm-a546b': 'low', + 'sm-a546u': 'low', + 'sm-a546v': 'low', + 'sm-a606y': 'low', + 'sm-a7050': 'low', + 'sm-a705f': 'low', + 'sm-a705fn': 'low', + 'sm-a705gm': 'low', + 'sm-a705mn': 'low', + 'sm-a715f': 'low', + 'sm-a7160': 'low', + 'sm-a716u': 'low', + 'sm-a716u1': 'low', + 'sm-a720f': 'low', + 'sm-a725f': 'low', + 'sm-a725m': 'low', + 'sm-a736b': 'low', + 'sm-a805f': 'low', + 'sm-a9200': 'low', + 'sm-a920f': 'low', + 'sm-e426b': 'low', + 'sm-e5260': 'low', + 'sm-e625f': 'low', + 'sm-f127g': 'low', + 'sm-f415f': 'low', + 'sm-f711b': 'medium', + 'sm-f711n': 'medium', + 'sm-f711u': 'medium', + 'sm-f7210': 'medium', + 'sm-f721b': 'medium', + 'sm-f721n': 'medium', + 'sm-f721u1': 'medium', + 'sm-f7310': 'medium', + 'sm-f731b': 'medium', + 'sm-f731n': 'medium', + 'sm-f731u': 'medium', + 'sm-f731u1': 'medium', + 'sm-f731w': 'medium', + 'sm-f7410': 'high', + 'sm-f741b': 'high', + 'sm-f741n': 'high', + 'sm-f741u': 'high', + 'sm-f741u1': 'high', + 'sm-f741w': 'high', + 'sm-f9160': 'medium', + 'sm-f916b': 'medium', + 'sm-f916n': 'medium', + 'sm-f916u': 'medium', + 'sm-f926b': 'medium', + 'sm-f926n': 'medium', + 'sm-f926u': 'medium', + 'sm-f926u1': 'medium', + 'sm-f9360': 'medium', + 'sm-f936b': 'medium', + 'sm-f936n': 'medium', + 'sm-f936u': 'medium', + 'sm-f936u1': 'medium', + 'sm-f936w': 'medium', + 'sm-f9460': 'high', + 'sm-f946b': 'high', + 'sm-f946n': 'high', + 'sm-f946u': 'high', + 'sm-f946u1': 'high', + 'sm-f946w': 'high', + 'sm-f9560': 'high', + 'sm-f956b': 'high', + 'sm-f956n': 'high', + 'sm-f956q': 'high', + 'sm-f956u': 'high', + 'sm-f956u1': 'high', + 'sm-f956w': 'high', + 'sm-f9660': 'high', + 'sm-f966b': 'high', + 'sm-f966n': 'high', + 'sm-f966q': 'high', + 'sm-f966u': 'high', + 'sm-f966u1': 'high', + 'sm-f966w': 'high', + 'sm-g525f': 'low', + 'sm-g780g': 'medium', + 'sm-g7810': 'medium', + 'sm-g781u': 'medium', + 'sm-g781v': 'medium', + 'sm-g781w': 'medium', + 'sm-g8870': 'low', + 'sm-g9200': 'low', + 'sm-g9600': 'low', + 'sm-g960f': 'low', + 'sm-g960n': 'low', + 'sm-g960u': 'low', + 'sm-g960u1': 'low', + 'sm-g960w': 'low', + 'sm-g9650': 'low', + 'sm-g965f': 'low', + 'sm-g965u': 'low', + 'sm-g980f': 'medium', + 'sm-g990e': 'medium', + 'sm-g9910': 'medium', + 'sm-g991b': 'medium', + 'sm-g991u': 'medium', + 'sm-g991u1': 'medium', + 'sm-g996b': 'medium', + 'sm-g996u': 'medium', + 'sm-g9980': 'medium', + 'sm-g998b': 'medium', + 'sm-g998u': 'medium', + 'sm-g998u1': 'medium', + 'sm-m045f': 'low', + 'sm-m127f': 'low', + 'sm-m127g': 'low', + 'sm-m215f': 'low', + 'sm-m236b': 'low', + 'sm-m307f': 'low', + 'sm-m315f': 'low', + 'sm-m317f': 'low', + 'sm-m325fv': 'low', + 'sm-m346b': 'low', + 'sm-m515f': 'low', + 'sm-m526b': 'low', + 'sm-m546b': 'low', + 'sm-n770f': 'low', + 'sm-n9600': 'low', + 'sm-n960f': 'low', + 'sm-n960n': 'low', + 'sm-n960u': 'low', + 'sm-n960u1': 'low', + 'sm-n980f': 'low', + 'sm-n981n': 'medium', + 'sm-n981u': 'medium', + 'sm-n985f': 'low', + 'sm-n9860': 'medium', + 'sm-n986b': 'medium', + 'sm-n986n': 'medium', + 'sm-n986u': 'medium', + 'sm-n986u1': 'medium', + 'sm-p613': 'low', + 'sm-s134dl': 'low', + 'sm-s135dl': 'low', + 'sm-s156v': 'low', + 'sm-s236dl': 'low', + 'sm-s7110': 'medium', + 'sm-s711b': 'medium', + 'sm-s711n': 'medium', + 'sm-s711u': 'medium', + 'sm-s711u1': 'medium', + 'sm-s711w': 'medium', + 'sm-s721u': 'low', + 'sm-s9080': 'high', + 'sm-s9110': 'high', + 'sm-s911b': 'high', + 'sm-s911n': 'high', + 'sm-s911u': 'high', + 'sm-s911u1': 'high', + 'sm-s911w': 'high', + 'sm-s9160': 'high', + 'sm-s916b': 'high', + 'sm-s916n': 'high', + 'sm-s916u': 'high', + 'sm-s916u1': 'high', + 'sm-s9180': 'high', + 'sm-s918b': 'high', + 'sm-s918n': 'high', + 'sm-s918u': 'high', + 'sm-s918u1': 'high', + 'sm-s918w': 'high', + 'sm-s9280': 'high', + 'sm-s928b': 'high', + 'sm-s928n': 'high', + 'sm-s928u': 'high', + 'sm-s928u1': 'high', + 'sm-s928w': 'high', + 'sm-s9310': 'high', + 'sm-s931b': 'high', + 'sm-s931n': 'high', + 'sm-s931u': 'high', + 'sm-s931u1': 'high', + 'sm-s931w': 'high', + 'sm-s9360': 'high', + 'sm-s936b': 'high', + 'sm-s936n': 'high', + 'sm-s936u': 'high', + 'sm-s936u1': 'high', + 'sm-s9370': 'high', + 'sm-s937b': 'high', + 'sm-s937u': 'high', + 'sm-s937u1': 'high', + 'sm-s9380': 'high', + 'sm-s938b': 'high', + 'sm-s938n': 'high', + 'sm-s938q': 'high', + 'sm-s938u': 'high', + 'sm-s938u1': 'high', + 'sm-s938w': 'high', + 'sm-s9480': 'high', + 'sm-s948b': 'high', + 'sm-s948u': 'high', + 'sm-s948u1': 'high', + 'sm-t307u': 'low', + 'sm-t500': 'low', + 'sm-t505': 'low', + 'sm-t735': 'low', + 'sm-t738u': 'low', + 'sm-t865': 'low', + 'sm-t870': 'medium', + 'sm-t875': 'medium', + 'sm-w2021': 'medium', + 'sm-w2022': 'medium', + 'sm-w9023': 'medium', + 'sm-x216b': 'low', + 'sm-x218u': 'low', + 'sm-x510': 'low', + 'sm-x610': 'low', + 'sm-x700': 'medium', + 'sm-x710': 'high', + 'sm-x820': 'high', + }, + 'sharp': { + 'a202sh': 'medium', + 'a301sh': 'high', + 'a402sh': 'low', + 'a501sh': 'medium', + 'sh-51f': 'medium', + 'sh-52c': 'medium', + 'sh-m27': 'low', + }, + 'sigma mobile': { + 'x-treme pq57': 'low', + }, + 'sony': { + 'a101so': 'medium', + 'a201so': 'medium', + 'g8342': 'low', + 'g8441': 'low', + 'i4293': 'low', + 'j9110': 'low', + 'so-01l': 'low', + 'so-02k': 'low', + 'so-51b': 'medium', + 'so-51c': 'medium', + 'so-54c': 'medium', + 'sog01': 'medium', + 'sog02': 'medium', + 'sog03': 'medium', + 'sog09': 'medium', + 'sog10': 'high', + 'sog11': 'low', + 'xq-aq52': 'medium', + 'xq-as52': 'medium', + 'xq-at52': 'medium', + 'xq-au52': 'low', + 'xq-cc72': 'low', + 'xq-ct72': 'medium', + 'xq-dc54': 'low', + 'xq-dq54': 'high', + 'xq-dq72': 'high', + 'xq-ec54': 'high', + 'xq-fs72': 'high', + }, + 'tcl': { + 't767w': 'low', + 't776o': 'low', + 't779w': 'low', + 't810h': 'low', + }, + 'tcl technology': { + 'blackberry key2': 'low', + }, + 'tecno': { + 'tecno bg7': 'low', + 'tecno bg7n': 'low', + 'tecno ci8': 'low', + 'tecno cl6': 'low', + 'tecno cl6k': 'low', + 'tecno cl7': 'low', + 'tecno kg5p': 'low', + 'tecno ki5q': 'low', + 'tecno kj6': 'low', + 'tecno kj7': 'low', + 'tecno lh7n': 'low', + 'tecno li6': 'low', + 'tecno li7': 'low', + }, + 'tecno mobile limited': { + 'tecno ce7j': 'low', + 'tecno cg6': 'low', + 'tecno cg6j': 'low', + 'tecno cg7': 'low', + 'tecno ch6': 'low', + 'tecno kf6p': 'low', + 'tecno kf7j': 'low', + 'tecno le7': 'low', + }, + 'tianyi': { + 'lya-al00': 'low', + }, + 'ulefone': { + 'armor 24': 'low', + }, + 'vertu': { + 'vtl-202201': 'medium', + 'vtl-202301': 'high', + 'vtl-202403': 'high', + }, + 'vid': { + 'gq3115tf5': 'low', + }, + 'vivo': { + 'i2011': 'low', + 'i2126': 'low', + 'i2202': 'medium', + 'i2203': 'low', + 'i2216': 'low', + 'i2220': 'high', + 'i2221': 'high', + 'i2223': 'low', + 'i2301': 'medium', + 'i2302': 'medium', + 'i2304': 'high', + 'i2401': 'high', + 'i2502': 'low', + 'v1809a': 'low', + 'v1816a': 'low', + 'v1824a': 'low', + 'v1829a': 'low', + 'v1836a': 'low', + 'v1838a': 'low', + 'v1911a': 'low', + 'v1914a': 'low', + 'v1916a': 'low', + 'v1921a': 'low', + 'v1922a': 'low', + 'v1924a': 'low', + 'v1932a': 'low', + 'v1934a': 'low', + 'v1936a': 'low', + 'v1936al': 'low', + 'v1938ct': 'low', + 'v1941a': 'low', + 'v1945a': 'low', + 'v1950a': 'medium', + 'v1955a': 'medium', + 'v1962a': 'low', + 'v1963a': 'low', + 'v1965a': 'low', + 'v1981a': 'medium', + 'v1990a': 'low', + 'v2001a': 'low', + 'v2005a': 'low', + 'v2011a': 'medium', + 'v2012a': 'low', + 'v2020a': 'low', + 'v2022': 'low', + 'v2025': 'low', + 'v2025a': 'medium', + 'v2030': 'low', + 'v2036a': 'low', + 'v2046': 'medium', + 'v2049a': 'medium', + 'v2055a': 'medium', + 'v2056a': 'medium', + 'v2058': 'low', + 'v2059': 'low', + 'v2061': 'low', + 'v2066a': 'low', + 'v2072a': 'low', + 'v2073a': 'low', + 'v2099a': 'low', + 'v2109': 'low', + 'v2115a': 'low', + 'v2118a': 'medium', + 'v2121a': 'low', + 'v2132': 'medium', + 'v2133a': 'medium', + 'v2136a': 'medium', + 'v2141': 'low', + 'v2141a': 'medium', + 'v2145a': 'medium', + 'v2151': 'low', + 'v2152': 'low', + 'v2154a': 'medium', + 'v2157a': 'medium', + 'v2158': 'medium', + 'v2158a': 'low', + 'v2162a': 'low', + 'v2163a': 'medium', + 'v2165a': 'low', + 'v2171a': 'medium', + 'v2172a': 'medium', + 'v2183a': 'medium', + 'v2199a': 'medium', + 'v2203a': 'medium', + 'v2204': 'low', + 'v2205': 'low', + 'v2206': 'low', + 'v2207': 'low', + 'v2217a': 'medium', + 'v2220a': 'low', + 'v2222': 'low', + 'v2231': 'medium', + 'v2241a': 'high', + 'v2243a': 'medium', + 'v2246': 'medium', + 'v2247': 'low', + 'v2249': 'low', + 'v2250': 'low', + 'v2254a': 'high', + 'v2303': 'low', + 'v2308': 'high', + 'v2309': 'high', + 'v2309a': 'high', + 'v2310': 'low', + 'v2321': 'medium', + 'v2324a': 'high', + 'v2329a': 'medium', + 'v2333': 'low', + 'v2334': 'medium', + 'v2342': 'low', + 'v2352': 'low', + 'v2366ga': 'high', + 'v2366ha': 'high', + 'v2405a': 'high', + 'v2405da': 'high', + 'v2408a': 'high', + 'v2413': 'high', + 'v2415': 'high', + 'v2415a': 'high', + 'v2422': 'low', + 'v2429': 'high', + 'v2434': 'low', + 'v2441': 'low', + 'v2454a': 'high', + 'v2454da': 'high', + 'v2502a': 'high', + 'v2502da': 'high', + 'v2503': 'high', + 'v2505a': 'high', + 'v2509a': 'high', + 'v2514': 'high', + 'v2515': 'high', + 'v2562': 'high', + 'vivo 1804': 'low', + 'vivo 1907': 'low', + 'vivo 1909': 'low', + 'vivo 1915': 'low', + 'vivo 1917': 'low', + 'vivo 1919': 'low', + 'vivo 1920': 'low', + 'vivo 1933': 'low', + 'vivo 1935': 'low', + 'vivo 2018': 'low', + 'vivo 2019': 'low', + 'vivo nex a': 'low', + 'vivo x20a': 'low', + 'vivo x21a': 'low', + 'vivo y17': 'low', + }, + 'wheatek': { + 'f101 pro': 'low', + 'rt3 pro': 'low', + 'wp17': 'low', + }, + 'wiko': { + 'wiko t50': 'low', + }, + 'wingtech': { + 'celero5g': 'low', + 'wtcelero5g': 'low', + }, + 'xiaomi': { + '21051182c': 'low', + '21051182g': 'low', + '21061110ag': 'low', + '21061119ag': 'low', + '21061119bi': 'low', + '21061119dg': 'low', + '2106118c': 'medium', + '2107113sg': 'medium', + '2107113si': 'medium', + '2107113sr': 'medium', + '2107119dc': 'low', + '21081111rg': 'medium', + '2109119bc': 'low', + '2109119dg': 'low', + '21121119sg': 'low', + '21121210c': 'medium', + '21121210g': 'medium', + '2112123ac': 'low', + '2112123ag': 'low', + '22011119uy': 'low', + '2201116pg': 'low', + '2201116pi': 'low', + '2201117pg': 'low', + '2201117pi': 'low', + '2201117sg': 'low', + '2201117si': 'low', + '2201117sl': 'low', + '2201117sy': 'low', + '2201117tg': 'low', + '2201117ti': 'low', + '2201117tl': 'low', + '2201117ty': 'low', + '22011211c': 'medium', + '2201122c': 'medium', + '2201122g': 'medium', + '2201123c': 'medium', + '2201123g': 'medium', + '22021211rc': 'medium', + '22021211rg': 'medium', + '2203121c': 'medium', + '2203129g': 'low', + '220333qag': 'low', + '220333ql': 'low', + '220333qny': 'low', + '22041211ac': 'medium', + '22041216c': 'medium', + '22041216g': 'medium', + '22041216i': 'medium', + '22041216uc': 'medium', + '22061218c': 'medium', + '2206122sc': 'medium', + '2206123sc': 'medium', + '2207117bpg': 'low', + '22071219cg': 'low', + '22081212c': 'medium', + '22081212ug': 'medium', + '2209116ag': 'low', + '22101316c': 'low', + '22101316ucp': 'low', + '22101317c': 'low', + '22101320c': 'low', + '22101320g': 'low', + '22101320i': 'low', + '2210132c': 'medium', + '2210132g': 'medium', + '22111317g': 'low', + '22111317i': 'low', + '2211133c': 'medium', + '2211133g': 'medium', + '22120rn86g': 'low', + '22120rn86i': 'low', + '22126rn91y': 'low', + '22127pc95i': 'low', + '22127rk46c': 'medium', + '2212arnc4l': 'low', + '23013rk75c': 'medium', + '23021raa2y': 'low', + '23021raaeg': 'low', + '23027rad4i': 'low', + '23028ra60l': 'low', + '23030rac7y': 'low', + '2303cra44a': 'low', + '2303era42l': 'low', + '23043rp34c': 'medium', + '23043rp34g': 'medium', + '23046rp50c': 'medium', + '23049pcd8g': 'medium', + '23049rad8c': 'medium', + '2304fpn6dc': 'high', + '2304fpn6dg': 'high', + '23053rn02a': 'low', + '23053rn02i': 'low', + '23053rn02l': 'low', + '23053rn02y': 'low', + '23073rpbfg': 'low', + '23073rpbfl': 'low', + '23078pnd5g': 'medium', + '2308cpxd0c': 'high', + '23100rn82l': 'low', + '23106rn0da': 'low', + '23108rn04y': 'low', + '2310fpca4g': 'low', + '2310fpca4i': 'low', + '23113rkc6c': 'high', + '23113rkc6g': 'high', + '23116pn5bc': 'high', + '23117rk66c': 'high', + '2311bpn23c': 'high', + '2311drn14i': 'low', + '23124ra7eo': 'low', + '23127pn0cc': 'high', + '23127pn0cg': 'high', + '23129ra5fl': 'low', + '23129raa4g': 'low', + '24018rpacc': 'high', + '24018rpacg': 'high', + '24030pn60g': 'high', + '24031pn0dc': 'high', + '24040rn64y': 'low', + '24049rn28l': 'low', + '2404apc5fg': 'low', + '2404arn45a': 'low', + '24053py09i': 'high', + '2405cpx3dc': 'high', + '2405cpx3dg': 'high', + '24069pc21g': 'high', + '24069pc21i': 'high', + '24069ra21c': 'high', + '24072px77c': 'high', + '2407fpn8eg': 'high', + '2407frk8ec': 'high', + '24091rpadc': 'high', + '24091rpadg': 'high', + '24094rad4c': 'high', + '24094rad4g': 'high', + '24094rad4i': 'high', + '2409brn2ca': 'low', + '2409brn2cc': 'low', + '2409brn2cl': 'low', + '2409brn2cy': 'low', + '2409fpcc4g': 'high', + '2409fpcc4i': 'high', + '2410crp4cg': 'medium', + '2410dpn6cc': 'high', + '2410fpcc5g': 'low', + '24117rk2cc': 'high', + '24117rk2cg': 'high', + '24122rkc7c': 'high', + '24122rkc7g': 'high', + '24129pn74c': 'high', + '24129pn74g': 'high', + '25010pn30c': 'high', + '25010pn30g': 'high', + '25019pnf3c': 'high', + '25057rn09g': 'low', + '25057rn09i': 'low', + '25060rk16c': 'high', + '25062pc34g': 'low', + '25062rn2da': 'low', + '25062rn2dl': 'low', + '25062rn2dy': 'low', + '2506bpn68g': 'high', + '2506bpn68r': 'high', + '25078pc3eg': 'low', + '25078ra3ea': 'low', + '25078ra3el': 'low', + '25078ra3ey': 'low', + '25091rp04c': 'high', + '25098pn5ac': 'high', + '2509fpn0bc': 'high', + '25102pcbeg': 'high', + '25102rkbec': 'high', + '2510dpc44g': 'high', + '2510drk44c': 'high', + '25113pn0ec': 'high', + '25113pn0eg': 'high', + '2602bpc18g': 'high', + '2602brt18c': 'high', + '2602eptc0g': 'high', + '2605epn8ec': 'high', + 'm1908c3jgg': 'low', + 'm2002j9e': 'low', + 'm2003j15sc': 'low', + 'm2004j19c': 'low', + 'm2006j10c': 'low', + 'm2007j17c': 'low', + 'm2007j17g': 'low', + 'm2007j17i': 'low', + 'm2007j1sc': 'medium', + 'm2007j20cg': 'low', + 'm2007j3sc': 'medium', + 'm2007j3sg': 'medium', + 'm2010j19cg': 'low', + 'm2010j19ci': 'low', + 'm2010j19sg': 'low', + 'm2010j19sy': 'low', + 'm2011k2c': 'medium', + 'm2011k2g': 'medium', + 'm2012k10c': 'medium', + 'm2012k11ac': 'medium', + 'm2012k11ag': 'medium', + 'm2012k11c': 'medium', + 'm2101k6g': 'low', + 'm2101k6p': 'low', + 'm2101k7ag': 'low', + 'm2101k7ai': 'low', + 'm2101k7bg': 'low', + 'm2101k7bi': 'low', + 'm2101k7bl': 'low', + 'm2101k7bny': 'low', + 'm2101k9ag': 'low', + 'm2101k9c': 'low', + 'm2101k9g': 'low', + 'm2102j20sg': 'low', + 'm2102j20si': 'low', + 'm2102j2sc': 'medium', + 'm2102k1c': 'medium', + 'm2102k1g': 'medium', + 'm2103k19c': 'low', + 'm2104k10ac': 'low', + 'mi 10': 'medium', + 'mi 10 pro': 'medium', + 'mi 6': 'low', + 'mi 8': 'low', + 'mi 8 lite': 'low', + 'mi 8 pro': 'low', + 'mi 8 se': 'low', + 'mi 9': 'low', + 'mi 9 lite': 'low', + 'mi 9 se': 'low', + 'mi 9t': 'low', + 'mi a1': 'low', + 'mi a2': 'low', + 'mi a3': 'low', + 'mi max 3': 'medium', + 'mi mix 2s': 'low', + 'mi note 10': 'low', + 'mi note 10 lite': 'low', + 'mi9 pro 5g': 'low', + 'mix 2s': 'low', + 'poco f2 pro': 'medium', + 'pocophone f1': 'low', + 'redmi 7': 'low', + 'redmi 8': 'low', + 'redmi k20 pro': 'low', + 'redmi k20 pro premium edition': 'low', + 'redmi k30': 'low', + 'redmi k30 5g': 'low', + 'redmi k30 pro': 'medium', + 'redmi k30 pro zoom edition': 'medium', + 'redmi note 3': 'low', + 'redmi note 5': 'low', + 'redmi note 6 pro': 'low', + 'redmi note 7': 'low', + 'redmi note 7 pro': 'low', + 'redmi note 8': 'low', + 'redmi note 8 pro': 'low', + 'redmi note 8t': 'low', + 'redmi note 9 pro': 'low', + 'redmi note 9 pro max': 'low', + 'redmi note 9s': 'low', + 'xiaomi for arm64': 'high', + }, + 'zte': { + 'zte 9030': 'medium', + }, +}; + +export function getAndroidDeviceCpuTier({ + manufacturer, + model, +}: { + manufacturer: string; + model: string; +}): TKnownDeviceCpuTier | undefined { + if ( + !Object.prototype.hasOwnProperty.call( + ANDROID_DEVICE_CPU_TIER_BY_MANUFACTURER, + manufacturer, + ) + ) { + return undefined; + } + const models = ANDROID_DEVICE_CPU_TIER_BY_MANUFACTURER[manufacturer]; + if (!Object.prototype.hasOwnProperty.call(models, model)) { + return undefined; + } + return models[model]; +} diff --git a/packages/shared/src/performance/deviceCpuTierData/ios.ts b/packages/shared/src/performance/deviceCpuTierData/ios.ts new file mode 100644 index 000000000000..2d5448c2485e --- /dev/null +++ b/packages/shared/src/performance/deviceCpuTierData/ios.ts @@ -0,0 +1,121 @@ +/* cspell:disable -- device catalog identifiers */ +import { + type TKnownDeviceCpuTier, + isKnownDeviceCpuTier, +} from '../devicePerformanceTierTypes'; + +// Business tiers use Geekbench 6 single-core thresholds: low < 1000, +// medium 1000-1799, and high >= 1800. These are not Geekbench classifications. +export const IOS_DEVICE_CPU_TIER_BY_MODEL_ID: Readonly< + Record +> = { + 'ipad11,1': 'medium', + 'ipad11,2': 'medium', + 'ipad11,3': 'medium', + 'ipad11,4': 'medium', + 'ipad11,6': 'medium', + 'ipad11,7': 'medium', + 'ipad12,1': 'medium', + 'ipad12,2': 'medium', + 'ipad13,1': 'high', + 'ipad13,10': 'high', + 'ipad13,11': 'high', + 'ipad13,16': 'high', + 'ipad13,17': 'high', + 'ipad13,18': 'high', + 'ipad13,19': 'high', + 'ipad13,2': 'high', + 'ipad13,4': 'high', + 'ipad13,5': 'high', + 'ipad13,6': 'high', + 'ipad13,7': 'high', + 'ipad13,8': 'high', + 'ipad13,9': 'high', + 'ipad14,1': 'high', + 'ipad14,2': 'high', + 'ipad5,1': 'low', + 'ipad5,2': 'low', + 'ipad5,3': 'low', + 'ipad5,4': 'low', + 'ipad6,11': 'low', + 'ipad6,12': 'low', + 'ipad6,3': 'low', + 'ipad6,4': 'low', + 'ipad7,1': 'low', + 'ipad7,11': 'low', + 'ipad7,12': 'low', + 'ipad7,2': 'low', + 'ipad7,3': 'low', + 'ipad7,4': 'low', + 'ipad7,5': 'low', + 'ipad7,6': 'low', + 'ipad8,10': 'medium', + 'ipad8,11': 'medium', + 'ipad8,12': 'medium', + 'ipad8,5': 'medium', + 'ipad8,6': 'medium', + 'ipad8,7': 'medium', + 'ipad8,8': 'medium', + 'ipad8,9': 'medium', + 'iphone10,1': 'medium', + 'iphone10,2': 'medium', + 'iphone10,3': 'medium', + 'iphone10,4': 'medium', + 'iphone10,5': 'medium', + 'iphone10,6': 'medium', + 'iphone11,2': 'medium', + 'iphone11,4': 'medium', + 'iphone11,6': 'medium', + 'iphone11,8': 'medium', + 'iphone12,1': 'medium', + 'iphone12,3': 'medium', + 'iphone12,5': 'medium', + 'iphone12,8': 'medium', + 'iphone13,1': 'high', + 'iphone13,2': 'high', + 'iphone13,3': 'high', + 'iphone13,4': 'high', + 'iphone14,2': 'high', + 'iphone14,3': 'high', + 'iphone14,4': 'high', + 'iphone14,5': 'high', + 'iphone14,6': 'high', + 'iphone14,7': 'high', + 'iphone14,8': 'high', + 'iphone15,2': 'high', + 'iphone15,3': 'high', + 'iphone15,4': 'high', + 'iphone15,5': 'high', + 'iphone16,1': 'high', + 'iphone16,2': 'high', + 'iphone17,1': 'high', + 'iphone17,2': 'high', + 'iphone17,3': 'high', + 'iphone17,4': 'high', + 'iphone18,1': 'high', + 'iphone18,2': 'high', + 'iphone18,3': 'high', + 'iphone18,4': 'high', + 'iphone8,1': 'low', + 'iphone8,2': 'low', + 'iphone8,4': 'low', + 'iphone9,1': 'low', + 'iphone9,2': 'low', + 'iphone9,3': 'low', + 'iphone9,4': 'low', +}; + +export function getIosDeviceCpuTier( + modelId: string, +): TKnownDeviceCpuTier | undefined { + if ( + !Object.prototype.hasOwnProperty.call( + IOS_DEVICE_CPU_TIER_BY_MODEL_ID, + modelId, + ) + ) { + return undefined; + } + const tier = IOS_DEVICE_CPU_TIER_BY_MODEL_ID[modelId]; + return isKnownDeviceCpuTier(tier) ? tier : undefined; +} diff --git a/packages/shared/src/performance/deviceCpuTierUtils.test.ts b/packages/shared/src/performance/deviceCpuTierUtils.test.ts new file mode 100644 index 000000000000..1efcbca836d1 --- /dev/null +++ b/packages/shared/src/performance/deviceCpuTierUtils.test.ts @@ -0,0 +1,26 @@ +import { + HIGH_LOGICAL_PROCESSOR_MIN, + LOW_LOGICAL_PROCESSOR_MAX, + resolveLogicalProcessorCpuTier, +} from './deviceCpuTierUtils'; +import { EDeviceCpuTier } from './devicePerformanceTierTypes'; + +describe('resolveLogicalProcessorCpuTier', () => { + it.each([ + [1, EDeviceCpuTier.low], + [LOW_LOGICAL_PROCESSOR_MAX, EDeviceCpuTier.low], + [LOW_LOGICAL_PROCESSOR_MAX + 1, EDeviceCpuTier.medium], + [HIGH_LOGICAL_PROCESSOR_MIN - 1, EDeviceCpuTier.medium], + [HIGH_LOGICAL_PROCESSOR_MIN, EDeviceCpuTier.high], + [16, EDeviceCpuTier.high], + ])('classifies %s logical processors as %s', (count, expectedTier) => { + expect(resolveLogicalProcessorCpuTier(count)).toBe(expectedTier); + }); + + it.each([undefined, null, 0, -1, 4.5, Number.NaN, '8'])( + 'does not classify invalid logical processor count %s', + (count) => { + expect(resolveLogicalProcessorCpuTier(count)).toBeNull(); + }, + ); +}); diff --git a/packages/shared/src/performance/deviceCpuTierUtils.ts b/packages/shared/src/performance/deviceCpuTierUtils.ts new file mode 100644 index 000000000000..98536bde0a9c --- /dev/null +++ b/packages/shared/src/performance/deviceCpuTierUtils.ts @@ -0,0 +1,32 @@ +import { + EDeviceCpuTier, + type TKnownDeviceCpuTier, +} from './devicePerformanceTierTypes'; + +export const LOW_LOGICAL_PROCESSOR_MAX = 4; +export const HIGH_LOGICAL_PROCESSOR_MIN = 8; + +export function normalizeDeviceCpuTierKeyPart(value: string | null): string { + return (value ?? '').trim().replace(/\s+/g, ' ').toLowerCase(); +} + +export function resolveLogicalProcessorCpuTier( + logicalProcessorCount: unknown, +): TKnownDeviceCpuTier | null { + // This is a deterministic concurrency proxy, not a CPU benchmark. Browser + // values can be reduced for privacy, so callers retain medium confidence. + if ( + typeof logicalProcessorCount !== 'number' || + !Number.isInteger(logicalProcessorCount) || + logicalProcessorCount <= 0 + ) { + return null; + } + if (logicalProcessorCount <= LOW_LOGICAL_PROCESSOR_MAX) { + return EDeviceCpuTier.low; + } + if (logicalProcessorCount >= HIGH_LOGICAL_PROCESSOR_MIN) { + return EDeviceCpuTier.high; + } + return EDeviceCpuTier.medium; +} diff --git a/packages/shared/src/performance/deviceMemory/getMemorySync.ts b/packages/shared/src/performance/deviceMemory/getMemorySync.ts index 590db5cd7f71..95c9b267130d 100644 --- a/packages/shared/src/performance/deviceMemory/getMemorySync.ts +++ b/packages/shared/src/performance/deviceMemory/getMemorySync.ts @@ -1,4 +1,4 @@ -// Web / Desktop: navigator.deviceMemory (Chrome/Chromium only, returns GB) +// Web / Extension: navigator.deviceMemory when the browser exposes it. export function getDeviceMemoryGBSync(): number | null { if (typeof navigator !== 'undefined' && 'deviceMemory' in navigator) { diff --git a/packages/shared/src/performance/deviceMemory/index.ts b/packages/shared/src/performance/deviceMemory/index.ts index 03e6e409af97..386579eaad7d 100644 --- a/packages/shared/src/performance/deviceMemory/index.ts +++ b/packages/shared/src/performance/deviceMemory/index.ts @@ -2,13 +2,14 @@ // have" and "is it memory-constrained" across the app. // // getDeviceMemoryGBSync — platform-split primitive (expo-device.totalMemory on -// native, navigator.deviceMemory on web/desktop) +// native and navigator.deviceMemory on +// web/desktop/extension) // isLowEndMemory — the ONE low-memory predicate (<= LOW_END_THRESHOLD) // IS_LOW_END_DEVICE — that predicate evaluated once at module load // -// Consumers (device performance tier, DApp WebView alive-count cap, the iOS -// cold-start-lock defer guard) all classify memory through here so they can -// never disagree on which devices count as low-end. +// Consumers (capability profiles, DApp WebView retention, and the iOS +// cold-start-lock defer guard) all use this predicate so they agree on which +// devices are memory constrained. import { getDeviceMemoryGBSync } from './getMemorySync'; @@ -26,10 +27,9 @@ export function isLowEndMemory(memoryGB: number): boolean { // Low-end-device flag, computed once at module load from device RAM. // -// Intentionally NOT derived from `getDevicePerformanceTier()`: that returns -// `medium` until a post-launch calibration has run and persisted, but the -// cold-start-lock guard this flag powers must work on the very first boot after -// upgrade (no calibration yet). So it reads memory synchronously every launch. +// Intentionally derived directly from physical memory. The cold-start-lock +// guard this flag powers must work on the first boot after an upgrade without +// waiting for another capability or runtime signal. // // On web/desktop the value is inert: the only consumer additionally gates on // `platformEnv.isNative`. diff --git a/packages/shared/src/performance/devicePerformanceTier.native.test.ts b/packages/shared/src/performance/devicePerformanceTier.native.test.ts new file mode 100644 index 000000000000..0211e539f31c --- /dev/null +++ b/packages/shared/src/performance/devicePerformanceTier.native.test.ts @@ -0,0 +1,79 @@ +const storage = new Map(); +const getDeviceCpuTierMatchMock = jest.fn(); +const getDeviceMemoryGBSyncMock = jest.fn(); +const getStoredValueMock = jest.fn((key: string) => storage.get(key)); + +jest.mock('../storage/instance/syncStorageInstance', () => ({ + syncStorage: { + getString: getStoredValueMock, + set: (key: string, value: string) => storage.set(key, value), + delete: (key: string) => storage.delete(key), + }, +})); + +jest.mock('./deviceCpuTier', () => ({ + getDeviceCpuTierMatch: getDeviceCpuTierMatchMock, +})); + +jest.mock('./deviceMemory', () => ({ + getDeviceMemoryGBSync: getDeviceMemoryGBSyncMock, + isLowEndMemory: (memoryGB: number) => memoryGB > 0 && memoryGB <= 3.5, +})); + +describe('devicePerformanceTier.native', () => { + beforeEach(() => { + jest.resetModules(); + storage.clear(); + getDeviceCpuTierMatchMock.mockReset(); + getDeviceMemoryGBSyncMock.mockReset(); + getStoredValueMock.mockClear(); + getDeviceMemoryGBSyncMock.mockReturnValue(8); + }); + + it('caches native-backed capability reads within each JS runtime', () => { + getDeviceCpuTierMatchMock.mockReturnValue({ + tier: 'high', + source: 'iosModelId', + confidence: 'high', + }); + + const { getDevicePerformanceProfile } = + require('./devicePerformanceTier.native') as typeof import('./devicePerformanceTier.native'); + + const firstProfile = getDevicePerformanceProfile(); + const secondProfile = getDevicePerformanceProfile(); + + expect(secondProfile).toBe(firstProfile); + expect(getDeviceCpuTierMatchMock).toHaveBeenCalledTimes(1); + expect(getDeviceMemoryGBSyncMock).toHaveBeenCalledTimes(1); + expect(getStoredValueMock).toHaveBeenCalledTimes(1); + }); + + it('ignores the unversioned V1 tier', () => { + storage.set('onekey_device_performance_tier', 'medium'); + getDeviceCpuTierMatchMock.mockReturnValue({ + tier: 'high', + source: 'iosModelId', + confidence: 'high', + }); + + const { EDeviceCpuTier, getDeviceCpuTier } = + require('./devicePerformanceTier.native') as typeof import('./devicePerformanceTier.native'); + + expect(getDeviceCpuTier()).toBe(EDeviceCpuTier.high); + }); + + it('persists a V2 CPU developer override separately', () => { + getDeviceCpuTierMatchMock.mockReturnValue(null); + + const { EDeviceCpuTier, getDevicePerformanceProfile, setDeviceCpuTier } = + require('./devicePerformanceTier.native') as typeof import('./devicePerformanceTier.native'); + + setDeviceCpuTier(EDeviceCpuTier.low); + + expect(storage.get('onekey_device_cpu_tier_override_v2')).toBe( + EDeviceCpuTier.low, + ); + expect(getDevicePerformanceProfile().cpu.source).toBe('developerOverride'); + }); +}); diff --git a/packages/shared/src/performance/devicePerformanceTier.native.ts b/packages/shared/src/performance/devicePerformanceTier.native.ts new file mode 100644 index 000000000000..1dd4d40483d7 --- /dev/null +++ b/packages/shared/src/performance/devicePerformanceTier.native.ts @@ -0,0 +1,122 @@ +/** + * Native device performance classification. + * + * CPU benchmark mappings and physical memory are exposed as independent + * capabilities. Feature owners combine only the signals relevant to their + * policy. Startup timing is intentionally excluded because it is an + * application outcome rather than a stable hardware capability. + */ + +import { syncStorage } from '../storage/instance/syncStorageInstance'; +import { EAppSyncStorageKeys } from '../storage/syncStorageKeys'; + +import { getDeviceCpuTierMatch } from './deviceCpuTier'; +import { getDeviceMemoryGBSync, isLowEndMemory } from './deviceMemory'; +import { + NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + resolveDevicePerformanceProfile, +} from './devicePerformanceTierResolver'; +import { + EDeviceCpuTier, + EDeviceMemoryClass, + EDevicePerformanceTier, + type IDevicePerformanceProfile, + type TKnownDeviceCpuTier, +} from './devicePerformanceTierTypes'; + +export { EDeviceCpuTier, EDeviceMemoryClass, EDevicePerformanceTier }; +export type { IDevicePerformanceProfile }; + +// Native main and background runtimes have separate JS heaps, so each runtime +// keeps its own snapshot and reads native-backed capability inputs at most once. +let cachedProfile: IDevicePerformanceProfile | undefined; + +const CPU_TIER_BY_LEGACY_PERFORMANCE_TIER: Record< + EDevicePerformanceTier, + TKnownDeviceCpuTier +> = { + [EDevicePerformanceTier.high]: EDeviceCpuTier.high, + [EDevicePerformanceTier.medium]: EDeviceCpuTier.medium, + [EDevicePerformanceTier.low]: EDeviceCpuTier.low, +}; + +function getStoredCpuOverride(): TKnownDeviceCpuTier | undefined { + const stored = syncStorage.getString( + EAppSyncStorageKeys.onekey_device_cpu_tier_override_v2, + ); + if ( + stored === EDeviceCpuTier.high || + stored === EDeviceCpuTier.medium || + stored === EDeviceCpuTier.low + ) { + return stored; + } + return undefined; +} + +function createDevicePerformanceProfile(): IDevicePerformanceProfile { + const memoryGB = getDeviceMemoryGBSync(); + return resolveDevicePerformanceProfile({ + cpuTierMatch: getDeviceCpuTierMatch(), + memoryGB, + isMemoryConstrained: memoryGB !== null ? isLowEndMemory(memoryGB) : false, + overrideCpuTier: getStoredCpuOverride(), + dataVersion: NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + }); +} + +export function getDevicePerformanceProfile(): IDevicePerformanceProfile { + cachedProfile ??= createDevicePerformanceProfile(); + return cachedProfile; +} + +export function getDeviceCpuTier(): EDeviceCpuTier { + return getDevicePerformanceProfile().cpu.tier; +} + +function cpuTierToLegacyPerformanceTier( + tier: EDeviceCpuTier, +): EDevicePerformanceTier { + if (tier === EDeviceCpuTier.high) { + return EDevicePerformanceTier.high; + } + if (tier === EDeviceCpuTier.low) { + return EDevicePerformanceTier.low; + } + return EDevicePerformanceTier.medium; +} + +/** + * @deprecated Use getDevicePerformanceProfile and a feature-specific policy. + */ +export function getDevicePerformanceTier(): EDevicePerformanceTier { + return cpuTierToLegacyPerformanceTier(getDeviceCpuTier()); +} + +/** + * Kept for API compatibility with non-native targets. Native classification is + * deterministic and does not calibrate from startup timing. + */ +export async function calibrateDevicePerformanceTier(): Promise { + return getDevicePerformanceTier(); +} + +export function setDeviceCpuTier(tier: TKnownDeviceCpuTier): void { + syncStorage.set(EAppSyncStorageKeys.onekey_device_cpu_tier_override_v2, tier); + cachedProfile = createDevicePerformanceProfile(); +} + +export function resetDeviceCpuTier(): void { + syncStorage.delete(EAppSyncStorageKeys.onekey_device_cpu_tier_override_v2); + cachedProfile = undefined; +} + +/** @deprecated Use setDeviceCpuTier. */ +export function setDevicePerformanceTier(tier: EDevicePerformanceTier): void { + setDeviceCpuTier(CPU_TIER_BY_LEGACY_PERFORMANCE_TIER[tier]); +} + +/** @deprecated Use resetDeviceCpuTier. */ +export function resetDevicePerformanceTier(): void { + resetDeviceCpuTier(); +} diff --git a/packages/shared/src/performance/devicePerformanceTier.test.ts b/packages/shared/src/performance/devicePerformanceTier.test.ts new file mode 100644 index 000000000000..e2602207b3d4 --- /dev/null +++ b/packages/shared/src/performance/devicePerformanceTier.test.ts @@ -0,0 +1,87 @@ +const storage = new Map(); +const getDeviceCpuTierMatchMock = jest.fn(); +const getDeviceMemoryGBSyncMock = jest.fn(); +const getStoredValueMock = jest.fn((key: string) => storage.get(key)); + +jest.mock('../storage/instance/syncStorageInstance', () => ({ + syncStorage: { + getString: getStoredValueMock, + set: (key: string, value: string) => storage.set(key, value), + delete: (key: string) => storage.delete(key), + }, +})); + +jest.mock('./deviceCpuTier', () => ({ + getDeviceCpuTierMatch: getDeviceCpuTierMatchMock, +})); + +jest.mock('./deviceMemory', () => ({ + getDeviceMemoryGBSync: getDeviceMemoryGBSyncMock, + isLowEndMemory: (memoryGB: number) => memoryGB > 0 && memoryGB <= 3.5, +})); + +describe('devicePerformanceTier web capabilities', () => { + beforeEach(() => { + jest.resetModules(); + storage.clear(); + getDeviceCpuTierMatchMock.mockReset(); + getDeviceMemoryGBSyncMock.mockReset(); + getStoredValueMock.mockClear(); + getDeviceCpuTierMatchMock.mockReturnValue({ + tier: 'high', + source: 'browserHardwareConcurrency', + confidence: 'medium', + }); + getDeviceMemoryGBSyncMock.mockReturnValue(8); + }); + + it('caches capability and storage reads within each JS context', () => { + const { getDevicePerformanceProfile } = + require('./devicePerformanceTier') as typeof import('./devicePerformanceTier'); + + const firstProfile = getDevicePerformanceProfile(); + const secondProfile = getDevicePerformanceProfile(); + + expect(secondProfile).toBe(firstProfile); + expect(getDeviceCpuTierMatchMock).toHaveBeenCalledTimes(1); + expect(getDeviceMemoryGBSyncMock).toHaveBeenCalledTimes(1); + expect(getStoredValueMock).toHaveBeenCalledTimes(1); + }); + + it('builds independent CPU and memory capabilities without calibration', () => { + const { EDeviceCpuTier, EDeviceMemoryClass, getDevicePerformanceProfile } = + require('./devicePerformanceTier') as typeof import('./devicePerformanceTier'); + + const profile = getDevicePerformanceProfile(); + + expect(profile.cpu.tier).toBe(EDeviceCpuTier.high); + expect(profile.memory.class).toBe(EDeviceMemoryClass.large); + expect(profile.dataVersion).toBe('non-native-capabilities-v1'); + }); + + it('ignores the legacy runtime-calibrated tier', () => { + storage.set('onekey_device_performance_tier', 'low'); + + const { EDeviceCpuTier, getDeviceCpuTier } = + require('./devicePerformanceTier') as typeof import('./devicePerformanceTier'); + + expect(getDeviceCpuTier()).toBe(EDeviceCpuTier.high); + }); + + it('persists the CPU developer override independently from memory', () => { + const { + EDeviceCpuTier, + EDeviceMemoryClass, + getDevicePerformanceProfile, + setDeviceCpuTier, + } = + require('./devicePerformanceTier') as typeof import('./devicePerformanceTier'); + + setDeviceCpuTier(EDeviceCpuTier.low); + + const profile = getDevicePerformanceProfile(); + expect(storage.get('onekey_device_cpu_tier_override_v2')).toBe('low'); + expect(profile.cpu.source).toBe('developerOverride'); + expect(profile.memory.class).toBe(EDeviceMemoryClass.large); + }); +}); diff --git a/packages/shared/src/performance/devicePerformanceTier.ts b/packages/shared/src/performance/devicePerformanceTier.ts index 828d8e86a85d..9e01c6671a51 100644 --- a/packages/shared/src/performance/devicePerformanceTier.ts +++ b/packages/shared/src/performance/devicePerformanceTier.ts @@ -1,191 +1,119 @@ /** - * Device Performance Tier Detection + * Web, desktop, and extension device capability classification. * - * Classifies the device into high / medium / low tiers based on: - * A) Cached tier from previous launch (sync, instant) - * B) Runtime calibration via UI-visible time after first render (async) - * C) Device memory (>= 4GB high, 3.5–4GB medium, <= 3.5GB low; skipped if unavailable) - * - * Tier behavior (consumers decide how to use it): - * high — device is fast, can do more work eagerly - * medium — moderate device, be selective - * low — slow device, defer as much as possible - * - * Usage: - * import { getDevicePerformanceTier, calibrateDevicePerformanceTier } - * from '@onekeyhq/shared/src/performance/devicePerformanceTier'; - * - * // Synchronous — returns cached tier, or a memory-derived tier on first launch - * const tier = getDevicePerformanceTier(); - * - * // Async — call once after UI is visible to calibrate & persist - * await calibrateDevicePerformanceTier(); + * Logical processor count and memory are exposed as independent capabilities. + * Browser-provided values may be reduced or rounded for privacy, so consumers + * must keep conservative fallbacks for unknown devices. */ import { syncStorage } from '../storage/instance/syncStorageInstance'; import { EAppSyncStorageKeys } from '../storage/syncStorageKeys'; +import { getDeviceCpuTierMatch } from './deviceCpuTier'; +import { getDeviceMemoryGBSync, isLowEndMemory } from './deviceMemory'; import { - getDeviceMemoryGB, - getDeviceMemoryGBSync, - isLowEndMemory, -} from './deviceMemory'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export enum EDevicePerformanceTier { - high = 'high', - medium = 'medium', - low = 'low', -} - -// --------------------------------------------------------------------------- -// Thresholds (ms) — UIVisibleTime: startup → UI visible -// --------------------------------------------------------------------------- - -/** Devices rendering UI in under this are considered high-perf */ -const HIGH_PERF_THRESHOLD_MS = 1500; -/** Devices slower than this are considered low-perf */ -const LOW_PERF_THRESHOLD_MS = 3000; - -// --------------------------------------------------------------------------- -// Thresholds (GB) — Device total physical memory -// --------------------------------------------------------------------------- - -const HIGH_MEM_THRESHOLD_GB = 4; - -// --------------------------------------------------------------------------- -// Module-level cache (survives across calls within the same JS session) -// --------------------------------------------------------------------------- + NON_NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + resolveDevicePerformanceProfile, +} from './devicePerformanceTierResolver'; +import { + EDeviceCpuTier, + EDeviceMemoryClass, + EDevicePerformanceTier, + type IDevicePerformanceProfile, + type TKnownDeviceCpuTier, +} from './devicePerformanceTierTypes'; + +export { EDeviceCpuTier, EDeviceMemoryClass, EDevicePerformanceTier }; +export type { IDevicePerformanceProfile }; + +// Cache per JS context so repeated consumers do not reread browser capability +// inputs. Each web, desktop, or extension context keeps its own JS snapshot. +let cachedProfile: IDevicePerformanceProfile | undefined; + +const CPU_TIER_BY_LEGACY_PERFORMANCE_TIER: Record< + EDevicePerformanceTier, + TKnownDeviceCpuTier +> = { + [EDevicePerformanceTier.high]: EDeviceCpuTier.high, + [EDevicePerformanceTier.medium]: EDeviceCpuTier.medium, + [EDevicePerformanceTier.low]: EDeviceCpuTier.low, +}; -let cachedTier: EDevicePerformanceTier | undefined; +function getStoredCpuOverride(): TKnownDeviceCpuTier | undefined { + const stored = syncStorage.getString( + EAppSyncStorageKeys.onekey_device_cpu_tier_override_v2, + ); + if ( + stored === EDeviceCpuTier.high || + stored === EDeviceCpuTier.medium || + stored === EDeviceCpuTier.low + ) { + return stored; + } + return undefined; +} -// --------------------------------------------------------------------------- -// Tier ordering (for combining multiple signals) -// --------------------------------------------------------------------------- +function createDevicePerformanceProfile(): IDevicePerformanceProfile { + const memoryGB = getDeviceMemoryGBSync(); + return resolveDevicePerformanceProfile({ + cpuTierMatch: getDeviceCpuTierMatch(), + memoryGB, + isMemoryConstrained: memoryGB !== null ? isLowEndMemory(memoryGB) : false, + overrideCpuTier: getStoredCpuOverride(), + dataVersion: NON_NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + }); +} -const TIER_RANK: Record = { - [EDevicePerformanceTier.low]: 0, - [EDevicePerformanceTier.medium]: 1, - [EDevicePerformanceTier.high]: 2, -}; +export function getDevicePerformanceProfile(): IDevicePerformanceProfile { + cachedProfile ??= createDevicePerformanceProfile(); + return cachedProfile; +} -function lowerTier( - a: EDevicePerformanceTier, - b: EDevicePerformanceTier, -): EDevicePerformanceTier { - return TIER_RANK[a] <= TIER_RANK[b] ? a : b; +export function getDeviceCpuTier(): EDeviceCpuTier { + return getDevicePerformanceProfile().cpu.tier; } -function getMemoryTier(memoryGB: number): EDevicePerformanceTier { - if (memoryGB >= HIGH_MEM_THRESHOLD_GB) { +function cpuTierToLegacyPerformanceTier( + tier: EDeviceCpuTier, +): EDevicePerformanceTier { + if (tier === EDeviceCpuTier.high) { return EDevicePerformanceTier.high; } - // Shares the single low-memory predicate so a device the cold-start guard - // treats as low-end (e.g. iPhone 7 Plus ~3.14GB) also lands in the `low` tier - // and is NOT pushed through the heavier `medium` preload queue. - if (isLowEndMemory(memoryGB)) { + if (tier === EDeviceCpuTier.low) { return EDevicePerformanceTier.low; } return EDevicePerformanceTier.medium; } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Returns the device performance tier **synchronously**. - * - * - Reads from in-memory cache first (fastest) - * - Falls back to MMKV sync storage (persisted from previous launch) - * - On first-ever launch, derives the tier synchronously from device memory - * (falls back to `medium` only when memory is unavailable) - * - * Safe to call at any point, including during component render. - */ +/** @deprecated Use getDevicePerformanceProfile and a feature-specific policy. */ export function getDevicePerformanceTier(): EDevicePerformanceTier { - if (cachedTier) { - return cachedTier; - } - - const stored = syncStorage.getString( - EAppSyncStorageKeys.onekey_device_performance_tier, - ); - - if ( - stored === EDevicePerformanceTier.high || - stored === EDevicePerformanceTier.medium || - stored === EDevicePerformanceTier.low - ) { - cachedTier = stored; - return cachedTier; - } - - // First launch (fresh install / cleared cache) — no calibrated tier yet. - // Derive a tier synchronously from device memory so low-end devices are not - // forced through the heavier `medium` preload queue on their very first boot. - // The async runtime calibration still refines and persists this for next launch. - const memoryGB = getDeviceMemoryGBSync(); - cachedTier = - memoryGB !== null ? getMemoryTier(memoryGB) : EDevicePerformanceTier.medium; - return cachedTier; + return cpuTierToLegacyPerformanceTier(getDeviceCpuTier()); } /** - * Calibrate the tier using the actual UI-visible time from - * `LaunchOptionsManager`, then persist to storage for next launch. - * - * Call this **once** after the splash screen has dismissed (UI visible). - * The result takes effect on the *next* app launch. + * Kept for API compatibility. Web capabilities are available synchronously and + * are not calibrated from application startup timing. */ export async function calibrateDevicePerformanceTier(): Promise { - const { default: LaunchOptionsManager } = - await import('../modules/LaunchOptionsManager'); - - const uiVisibleTime = await LaunchOptionsManager.getUIVisibleTime(); - - // Tier from startup speed - let timeTier: EDevicePerformanceTier; - if (uiVisibleTime > 0 && uiVisibleTime < HIGH_PERF_THRESHOLD_MS) { - timeTier = EDevicePerformanceTier.high; - } else if (uiVisibleTime > 0 && uiVisibleTime > LOW_PERF_THRESHOLD_MS) { - timeTier = EDevicePerformanceTier.low; - } else { - timeTier = EDevicePerformanceTier.medium; - } - - // Tier from device memory (skip if unavailable) - const memoryGB = await getDeviceMemoryGB(); - let memoryTier: EDevicePerformanceTier | null = null; - if (memoryGB !== null) { - memoryTier = getMemoryTier(memoryGB); - } - - // Combine: take the lower (more conservative) of the two signals - const tier = memoryTier !== null ? lowerTier(timeTier, memoryTier) : timeTier; + return getDevicePerformanceTier(); +} - // Persist for next launch - cachedTier = tier; - syncStorage.set(EAppSyncStorageKeys.onekey_device_performance_tier, tier); +export function setDeviceCpuTier(tier: TKnownDeviceCpuTier): void { + syncStorage.set(EAppSyncStorageKeys.onekey_device_cpu_tier_override_v2, tier); + cachedProfile = createDevicePerformanceProfile(); +} - return tier; +export function resetDeviceCpuTier(): void { + syncStorage.delete(EAppSyncStorageKeys.onekey_device_cpu_tier_override_v2); + cachedProfile = undefined; } -/** - * Force-set the tier (useful for dev settings / testing). - */ +/** @deprecated Use setDeviceCpuTier. */ export function setDevicePerformanceTier(tier: EDevicePerformanceTier): void { - cachedTier = tier; - syncStorage.set(EAppSyncStorageKeys.onekey_device_performance_tier, tier); + setDeviceCpuTier(CPU_TIER_BY_LEGACY_PERFORMANCE_TIER[tier]); } -/** - * Reset cached tier (useful for testing or after app data clear). - */ +/** @deprecated Use resetDeviceCpuTier. */ export function resetDevicePerformanceTier(): void { - cachedTier = undefined; - syncStorage.delete(EAppSyncStorageKeys.onekey_device_performance_tier); + resetDeviceCpuTier(); } diff --git a/packages/shared/src/performance/devicePerformanceTierResolver.test.ts b/packages/shared/src/performance/devicePerformanceTierResolver.test.ts new file mode 100644 index 000000000000..c2f4dd727456 --- /dev/null +++ b/packages/shared/src/performance/devicePerformanceTierResolver.test.ts @@ -0,0 +1,97 @@ +import { + NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + resolveDevicePerformanceProfile, + resolveMemoryClass, +} from './devicePerformanceTierResolver'; +import { + EDeviceCpuTier, + EDeviceMemoryClass, +} from './devicePerformanceTierTypes'; + +describe('devicePerformanceTierResolver', () => { + it.each([ + [null, false, EDeviceMemoryClass.unknown], + [3.5, true, EDeviceMemoryClass.constrained], + [3.55, false, EDeviceMemoryClass.standard], + [6, false, EDeviceMemoryClass.standard], + [8, false, EDeviceMemoryClass.large], + ])('classifies %sGB memory', (memoryGB, isMemoryConstrained, expected) => { + expect(resolveMemoryClass({ memoryGB, isMemoryConstrained })).toBe( + expected, + ); + }); + + it('classifies Motorola One 5G UW ace as low despite 3.55GB RAM', () => { + const profile = resolveDevicePerformanceProfile({ + cpuTierMatch: { + tier: EDeviceCpuTier.low, + source: 'androidModel', + confidence: 'medium', + }, + memoryGB: 3.55, + isMemoryConstrained: false, + dataVersion: NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + }); + + expect(profile.cpu.tier).toBe(EDeviceCpuTier.low); + expect(profile.memory.class).toBe(EDeviceMemoryClass.standard); + }); + + it('keeps iPhone 17 Pro in the high tier without startup-time input', () => { + const profile = resolveDevicePerformanceProfile({ + cpuTierMatch: { + tier: EDeviceCpuTier.high, + source: 'iosModelId', + confidence: 'high', + }, + memoryGB: 8, + isMemoryConstrained: false, + dataVersion: NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + }); + + expect(profile.cpu.tier).toBe(EDeviceCpuTier.high); + expect(profile.memory.class).toBe(EDeviceMemoryClass.large); + }); + + it('never promotes an unknown high-memory device to high', () => { + const profile = resolveDevicePerformanceProfile({ + cpuTierMatch: null, + memoryGB: 12, + isMemoryConstrained: false, + dataVersion: NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + }); + + expect(profile.cpu.tier).toBe(EDeviceCpuTier.unknown); + expect(profile.memory.class).toBe(EDeviceMemoryClass.large); + }); + + it('keeps CPU and constrained memory as independent capabilities', () => { + const profile = resolveDevicePerformanceProfile({ + cpuTierMatch: { + tier: EDeviceCpuTier.high, + source: 'androidModel', + confidence: 'medium', + }, + memoryGB: 3.5, + isMemoryConstrained: true, + dataVersion: NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + }); + + expect(profile.cpu.tier).toBe(EDeviceCpuTier.high); + expect(profile.memory.class).toBe(EDeviceMemoryClass.constrained); + }); + + it('applies a developer override only to CPU capability', () => { + const profile = resolveDevicePerformanceProfile({ + cpuTierMatch: null, + memoryGB: null, + isMemoryConstrained: false, + overrideCpuTier: EDeviceCpuTier.high, + dataVersion: NATIVE_DEVICE_PERFORMANCE_DATA_VERSION, + }); + + expect(profile.cpu.tier).toBe(EDeviceCpuTier.high); + expect(profile.cpu.source).toBe('developerOverride'); + expect(profile.memory.class).toBe(EDeviceMemoryClass.unknown); + }); +}); diff --git a/packages/shared/src/performance/devicePerformanceTierResolver.ts b/packages/shared/src/performance/devicePerformanceTierResolver.ts new file mode 100644 index 000000000000..d0f6a9daae21 --- /dev/null +++ b/packages/shared/src/performance/devicePerformanceTierResolver.ts @@ -0,0 +1,65 @@ +import { + EDeviceCpuTier, + EDeviceMemoryClass, + type IDeviceCpuTierMatch, + type IDevicePerformanceProfile, + type TKnownDeviceCpuTier, +} from './devicePerformanceTierTypes'; + +export const NATIVE_DEVICE_PERFORMANCE_DATA_VERSION = 'native-cpu-v2'; +export const NON_NATIVE_DEVICE_PERFORMANCE_DATA_VERSION = + 'non-native-capabilities-v1'; +export const LARGE_DEVICE_MEMORY_THRESHOLD_GB = 6; + +export function resolveMemoryClass({ + memoryGB, + isMemoryConstrained, +}: { + memoryGB: number | null; + isMemoryConstrained: boolean; +}): EDeviceMemoryClass { + if (memoryGB === null || !Number.isFinite(memoryGB) || memoryGB <= 0) { + return EDeviceMemoryClass.unknown; + } + if (isMemoryConstrained) { + return EDeviceMemoryClass.constrained; + } + if (memoryGB > LARGE_DEVICE_MEMORY_THRESHOLD_GB) { + return EDeviceMemoryClass.large; + } + return EDeviceMemoryClass.standard; +} + +export function resolveDevicePerformanceProfile({ + cpuTierMatch, + memoryGB, + isMemoryConstrained, + overrideCpuTier, + dataVersion, +}: { + cpuTierMatch: IDeviceCpuTierMatch | null; + memoryGB: number | null; + isMemoryConstrained: boolean; + overrideCpuTier?: TKnownDeviceCpuTier; + dataVersion: string; +}): IDevicePerformanceProfile { + return { + cpu: { + tier: overrideCpuTier ?? cpuTierMatch?.tier ?? EDeviceCpuTier.unknown, + source: overrideCpuTier + ? 'developerOverride' + : (cpuTierMatch?.source ?? 'unknown'), + confidence: overrideCpuTier + ? 'high' + : (cpuTierMatch?.confidence ?? 'none'), + }, + memory: { + class: resolveMemoryClass({ + memoryGB, + isMemoryConstrained, + }), + totalGB: memoryGB, + }, + dataVersion, + }; +} diff --git a/packages/shared/src/performance/devicePerformanceTierTypes.ts b/packages/shared/src/performance/devicePerformanceTierTypes.ts new file mode 100644 index 000000000000..351265da6fc0 --- /dev/null +++ b/packages/shared/src/performance/devicePerformanceTierTypes.ts @@ -0,0 +1,70 @@ +export enum EDevicePerformanceTier { + high = 'high', + medium = 'medium', + low = 'low', +} + +export const EDeviceCpuTier = { + high: 'high', + medium: 'medium', + low: 'low', + unknown: 'unknown', +} as const; + +export type EDeviceCpuTier = + (typeof EDeviceCpuTier)[keyof typeof EDeviceCpuTier]; + +export type TKnownDeviceCpuTier = Exclude< + EDeviceCpuTier, + typeof EDeviceCpuTier.unknown +>; + +export function isKnownDeviceCpuTier( + value: unknown, +): value is TKnownDeviceCpuTier { + return ( + value === EDeviceCpuTier.high || + value === EDeviceCpuTier.medium || + value === EDeviceCpuTier.low + ); +} + +export enum EDeviceMemoryClass { + constrained = 'constrained', + standard = 'standard', + large = 'large', + unknown = 'unknown', +} + +export type IDevicePerformanceProfileSource = + | 'iosModelId' + | 'androidModel' + | 'browserHardwareConcurrency' + | 'desktopLogicalProcessorCount' + | 'developerOverride' + | 'unknown'; + +export type IDevicePerformanceProfileConfidence = 'high' | 'medium' | 'none'; + +export interface IDeviceCpuTierMatch { + tier: TKnownDeviceCpuTier; + source: IDevicePerformanceProfileSource; + confidence: IDevicePerformanceProfileConfidence; +} + +export interface IDeviceCpuCapability { + tier: EDeviceCpuTier; + source: IDevicePerformanceProfileSource; + confidence: IDevicePerformanceProfileConfidence; +} + +export interface IDeviceMemoryCapability { + class: EDeviceMemoryClass; + totalGB: number | null; +} + +export interface IDevicePerformanceProfile { + cpu: IDeviceCpuCapability; + memory: IDeviceMemoryCapability; + dataVersion: string; +} diff --git a/packages/shared/src/storage/syncStorageKeys.ts b/packages/shared/src/storage/syncStorageKeys.ts index 214268f0b8f6..6807e397cb47 100644 --- a/packages/shared/src/storage/syncStorageKeys.ts +++ b/packages/shared/src/storage/syncStorageKeys.ts @@ -19,6 +19,7 @@ export enum EAppSyncStorageKeys { onekey_account_selector_recent_selection = 'onekey_account_selector_recent_selection', onekey_swr_cache = 'onekey_swr_cache', onekey_device_performance_tier = 'onekey_device_performance_tier', + onekey_device_cpu_tier_override_v2 = 'onekey_device_cpu_tier_override_v2', // TokenList cells one-time cold-start cleanup version flag (spec §7). A // monotonically-increasing integer compared against // TOKEN_COLD_START_CLEANUP_VERSION so the OLD `::ctx:renderedTokenListCacheAtom`