diff --git a/__tests__/unit/services/hardware.test.ts b/__tests__/unit/services/hardware.test.ts index 998ed8b5..8f84e4df 100644 --- a/__tests__/unit/services/hardware.test.ts +++ b/__tests__/unit/services/hardware.test.ts @@ -639,16 +639,16 @@ describe('HardwareService', () => { expect(soc.hasNPU).toBe(true); }); - it('assigns qnnVariant 8gen2 for 12GB+ Qualcomm', async () => { + it('assigns qnnVariant 8gen1 for 12GB+ Qualcomm (RAM fallback when SoC model unavailable)', async () => { await setupDevice({ totalGB: 12, platform: 'android', hardware: 'qcom', model: 'Test' }); const soc = await hardwareService.getSoCInfo(); - expect(soc.qnnVariant).toBe('8gen2'); + expect(soc.qnnVariant).toBe('8gen1'); }); - it('assigns qnnVariant 8gen1 for 8-12GB Qualcomm', async () => { + it('assigns qnnVariant min for <12GB Qualcomm (RAM fallback when SoC model unavailable)', async () => { await setupDevice({ totalGB: 8, platform: 'android', hardware: 'qcom', model: 'Test' }); const soc = await hardwareService.getSoCInfo(); - expect(soc.qnnVariant).toBe('8gen1'); + expect(soc.qnnVariant).toBe('min'); }); it('assigns qnnVariant min for <8GB Qualcomm', async () => { @@ -753,15 +753,15 @@ describe('HardwareService', () => { }); describe('Android Qualcomm recommendations', () => { - it('recommends QNN for Qualcomm devices', async () => { + it('recommends QNN for Qualcomm devices (RAM fallback)', async () => { await setupDevice({ totalGB: 12, platform: 'android', hardware: 'qcom', model: 'Test' }); const rec = await hardwareService.getImageModelRecommendation(); expect(rec.recommendedBackend).toBe('qnn'); - expect(rec.qnnVariant).toBe('8gen2'); + expect(rec.qnnVariant).toBe('8gen1'); expect(rec.compatibleBackends).toEqual(expect.arrayContaining(['qnn', 'mnn'])); }); - it('sets qnnVariant based on RAM tier', async () => { + it('sets qnnVariant min for lower RAM (RAM fallback)', async () => { await setupDevice({ totalGB: 6, platform: 'android', hardware: 'qcom', model: 'Test' }); const rec = await hardwareService.getImageModelRecommendation(); expect(rec.qnnVariant).toBe('min'); diff --git a/android/app/src/main/java/ai/offgridmobile/localdream/LocalDreamModule.kt b/android/app/src/main/java/ai/offgridmobile/localdream/LocalDreamModule.kt index d6af66cd..836d597c 100644 --- a/android/app/src/main/java/ai/offgridmobile/localdream/LocalDreamModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/localdream/LocalDreamModule.kt @@ -949,6 +949,19 @@ class LocalDreamModule(reactContext: ReactApplicationContext) : promise.resolve(isQualcomm) } + /** + * Return the SoC model string (e.g. "SM8550", "SM7635") for NPU variant selection. + */ + @ReactMethod + fun getSoCModel(promise: Promise) { + val soc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Build.SOC_MODEL + } else { + "" + } + promise.resolve(soc) + } + @ReactMethod fun addListener(eventName: String) { // Required for RN event emitter diff --git a/src/components/ModelCard.tsx b/src/components/ModelCard.tsx index 525e47f1..41fdfb47 100644 --- a/src/components/ModelCard.tsx +++ b/src/components/ModelCard.tsx @@ -28,6 +28,7 @@ interface ModelCardProps { downloadProgress?: number; isActive?: boolean; isCompatible?: boolean; + incompatibleReason?: string; testID?: string; onPress?: () => void; onDownload?: () => void; @@ -45,6 +46,7 @@ export const ModelCard: React.FC = ({ downloadProgress = 0, isActive, isCompatible = true, + incompatibleReason, testID, onPress, onDownload, @@ -246,7 +248,7 @@ export const ModelCard: React.FC = ({ )} {!isCompatible && ( - Too large + {incompatibleReason || 'Too large'} )} @@ -281,7 +283,7 @@ export const ModelCard: React.FC = ({ diff --git a/src/screens/ModelsScreen.tsx b/src/screens/ModelsScreen.tsx index 568ebe35..e1c5aafe 100644 --- a/src/screens/ModelsScreen.tsx +++ b/src/screens/ModelsScreen.tsx @@ -602,8 +602,8 @@ export const ModelsScreen: React.FC = () => { } }; - // Image model download/management - uses native background download service - const handleDownloadImageModel = async (modelInfo: ImageModelDescriptor) => { + // Proceed with image model download (after any compatibility checks) + const proceedWithImageModelDownload = async (modelInfo: ImageModelDescriptor) => { // Route to HuggingFace downloader if it's a HuggingFace model if (modelInfo.huggingFaceRepo && modelInfo.huggingFaceFiles) { await handleDownloadHuggingFaceModel(modelInfo); @@ -757,6 +757,36 @@ export const ModelsScreen: React.FC = () => { } }; + // Image model download entry point — checks compatibility before proceeding + const handleDownloadImageModel = async (modelInfo: ImageModelDescriptor) => { + // Guard: warn user before downloading NPU models on non-Qualcomm devices + if (modelInfo.backend === 'qnn' && Platform.OS === 'android') { + const socInfo = await hardwareService.getSoCInfo(); + if (!socInfo.hasNPU) { + setAlertState(showAlert( + 'Incompatible Model', + 'NPU models require a Qualcomm Snapdragon processor. ' + + 'Your device does not have a compatible NPU and this model will not work. ' + + 'Consider downloading a CPU model instead.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Download Anyway', + style: 'destructive', + onPress: () => { + setAlertState(hideAlert()); + proceedWithImageModelDownload(modelInfo); + }, + }, + ], + )); + return; + } + } + + await proceedWithImageModelDownload(modelInfo); + }; + // Fallback download method using RNFS (for iOS or when native module unavailable) const handleDownloadImageModelFallback = async (modelInfo: ImageModelDescriptor) => { addImageModelDownloading(modelInfo.id); @@ -1623,6 +1653,8 @@ export const ModelsScreen: React.FC = () => { {!hfModelsLoading && !hfModelsError && filteredHFModels.map((model, index) => { const recommended = isRecommendedModel(model); + const backendCompatible = !imageRec?.compatibleBackends || + imageRec.compatibleBackends.includes(model.backend as any); return ( {recommended && ( @@ -1640,6 +1672,8 @@ export const ModelsScreen: React.FC = () => { }} isDownloading={imageModelDownloading.includes(model.id)} downloadProgress={imageModelProgress[model.id] || 0} + isCompatible={backendCompatible} + incompatibleReason={!backendCompatible ? 'Incompatible' : undefined} testID={`image-model-card-${index}`} onDownload={ !imageModelDownloading.includes(model.id) diff --git a/src/services/activeModelService.ts b/src/services/activeModelService.ts index ebcde5f3..f8d105dc 100644 --- a/src/services/activeModelService.ts +++ b/src/services/activeModelService.ts @@ -316,6 +316,17 @@ class ActiveModelService { const model = store.downloadedImageModels.find(m => m.id === modelId); if (!model) throw new Error('Model not found'); + // Guard: prevent loading QNN/NPU models on non-Qualcomm devices + if (model.backend === 'qnn') { + const socInfo = await hardwareService.getSoCInfo(); + if (!socInfo.hasNPU) { + throw new Error( + 'NPU models require a Qualcomm Snapdragon processor. ' + + 'Your device does not have a compatible NPU. Please use a CPU model instead.', + ); + } + } + this.loadingState.image = true; this.notifyListeners(); diff --git a/src/services/hardware.ts b/src/services/hardware.ts index db7bdba5..5dfc66b6 100644 --- a/src/services/hardware.ts +++ b/src/services/hardware.ts @@ -1,8 +1,10 @@ -import { Platform } from 'react-native'; +import { Platform, NativeModules } from 'react-native'; import DeviceInfo from 'react-native-device-info'; import { DeviceInfo as DeviceInfoType, ModelRecommendation, SoCInfo, SoCVendor, ImageModelRecommendation } from '../types'; import { MODEL_RECOMMENDATIONS, RECOMMENDED_MODELS } from '../constants'; +const { LocalDreamModule } = NativeModules; + class HardwareService { private cachedDeviceInfo: DeviceInfoType | null = null; private cachedSoCInfo: SoCInfo | null = null; @@ -256,9 +258,7 @@ class HardwareService { let qnnVariant: SoCInfo['qnnVariant']; if (vendor === 'qualcomm') { - if (ramGB >= 12) qnnVariant = '8gen2'; - else if (ramGB >= 8) qnnVariant = '8gen1'; - else qnnVariant = 'min'; + qnnVariant = await this.getQnnVariantFromSoC(); } this.cachedSoCInfo = { @@ -269,6 +269,53 @@ class HardwareService { return this.cachedSoCInfo; } + /** + * Determine QNN variant from the actual SoC model number. + * Flagship chips (8 Gen 2/3/4+) use '8gen2' models, + * 8 Gen 1 uses '8gen1', and everything else uses 'min'. + */ + private async getQnnVariantFromSoC(): Promise<'8gen2' | '8gen1' | 'min'> { + let socModel = ''; + try { + if (LocalDreamModule?.getSoCModel) { + socModel = await LocalDreamModule.getSoCModel(); + } + } catch { + // Fall through to RAM-based fallback + } + + if (socModel) { + // Strip sub-variant suffixes (e.g. "SM8550-AB" → "SM8550") + const baseModel = socModel.split('-')[0].toUpperCase(); + + // Flagship chips: full 8 Gen 2, 8 Gen 3, 8 Elite + // These have the most capable NPU and run '8gen2' QNN models + const FLAGSHIP_SOCS = [ + 'SM8550', // Snapdragon 8 Gen 2 + 'SM8650', // Snapdragon 8 Gen 3 + 'SM8750', // Snapdragon 8 Elite (Gen 4) + ]; + + // High-tier: 8 Gen 1 / 8+ Gen 1 + const GEN1_SOCS = [ + 'SM8450', // Snapdragon 8 Gen 1 + 'SM8475', // Snapdragon 8+ Gen 1 + ]; + + if (FLAGSHIP_SOCS.includes(baseModel)) return '8gen2'; + if (GEN1_SOCS.includes(baseModel)) return '8gen1'; + + // Everything else (SM8635 = 8s Gen 3, SM7xxx, SM6xxx, etc.) → non-flagship + return 'min'; + } + + // Fallback: RAM-based heuristic (only if SoC model unavailable) + // Conservative: never recommend flagship since we can't confirm the chip + const ramGB = this.getTotalMemoryGB(); + if (ramGB >= 12) return '8gen1'; + return 'min'; + } + async getImageModelRecommendation(): Promise { if (this.cachedImageRecommendation) { return this.cachedImageRecommendation; @@ -306,26 +353,25 @@ class HardwareService { }; } } else if (socInfo.vendor === 'qualcomm') { - const variantLabel = socInfo.qnnVariant === '8gen2' - ? 'flagship' : socInfo.qnnVariant === '8gen1' - ? '' : 'lightweight '; - - const bannerSuffix = socInfo.qnnVariant === '8gen2' - ? 'NPU models for fastest inference' - : socInfo.qnnVariant === '8gen1' - ? 'NPU models supported' - : 'lightweight NPU models recommended'; + let bannerText: string; + if (socInfo.qnnVariant === '8gen2') { + bannerText = 'Snapdragon flagship \u2014 NPU models for fastest generation (~15s)'; + } else if (socInfo.qnnVariant === '8gen1') { + bannerText = 'Snapdragon NPU supported \u2014 use NPU models for fast generation'; + } else { + bannerText = 'Snapdragon NPU supported \u2014 use non-flagship NPU models for fast generation'; + } rec = { recommendedBackend: 'qnn', qnnVariant: socInfo.qnnVariant, - bannerText: `Snapdragon ${variantLabel}\u2014 ${bannerSuffix}`, + bannerText, compatibleBackends: ['qnn', 'mnn'], }; } else { rec = { recommendedBackend: 'mnn', - bannerText: 'CPU models recommended \u2014 NPU requires Snapdragon', + bannerText: 'CPU models available \u2014 generation takes ~2 min per image', compatibleBackends: ['mnn'], }; }