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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions __tests__/unit/services/hardware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/components/ModelCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface ModelCardProps {
downloadProgress?: number;
isActive?: boolean;
isCompatible?: boolean;
incompatibleReason?: string;
testID?: string;
onPress?: () => void;
onDownload?: () => void;
Expand All @@ -45,6 +46,7 @@ export const ModelCard: React.FC<ModelCardProps> = ({
downloadProgress = 0,
isActive,
isCompatible = true,
incompatibleReason,
testID,
onPress,
onDownload,
Expand Down Expand Up @@ -246,7 +248,7 @@ export const ModelCard: React.FC<ModelCardProps> = ({
)}
{!isCompatible && (
<View style={styles.warningBadge}>
<Text style={styles.warningText}>Too large</Text>
<Text style={styles.warningText}>{incompatibleReason || 'Too large'}</Text>
</View>
)}
</View>
Expand Down Expand Up @@ -281,7 +283,7 @@ export const ModelCard: React.FC<ModelCardProps> = ({
<TouchableOpacity
style={styles.iconButton}
onPress={onDownload}
disabled={!isCompatible}
disabled={!isCompatible && !incompatibleReason}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
testID={testID ? `${testID}-download` : undefined}
>
Expand Down
38 changes: 36 additions & 2 deletions src/screens/ModelsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<View key={model.id}>
{recommended && (
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions src/services/activeModelService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
76 changes: 61 additions & 15 deletions src/services/hardware.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -241,7 +243,7 @@
const hardware = await DeviceInfo.getHardware();
const model = DeviceInfo.getModel();
const hardwareLower = hardware.toLowerCase();
const ramGB = this.getTotalMemoryGB();

Check failure on line 246 in src/services/hardware.ts

View workflow job for this annotation

GitHub Actions / lint

'ramGB' is assigned a value but never used. Allowed unused vars must match /^_/u

let vendor: SoCVendor = 'unknown';
if (hardwareLower.includes('qcom')) {
Expand All @@ -256,9 +258,7 @@

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 = {
Expand All @@ -269,6 +269,53 @@
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<ImageModelRecommendation> {
if (this.cachedImageRecommendation) {
return this.cachedImageRecommendation;
Expand Down Expand Up @@ -306,26 +353,25 @@
};
}
} 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'],
};
}
Expand Down
Loading