diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4c8c20b2e..69f1e4beb 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -40,6 +40,7 @@
+
= when (requested) {
- is Backend.NPU -> listOf(
- Backend.NPU(nativeLibraryDir = reactContext.applicationInfo.nativeLibraryDir),
- Backend.GPU(),
- Backend.CPU(),
- )
- is Backend.GPU -> listOf(Backend.GPU(), Backend.CPU())
- else -> listOf(Backend.CPU())
+ private fun buildBackendChain(requested: Backend): List {
+ // GPU init crashes on Pixel 10 — open LiteRT SDK bug.
+ val skipGpu = Build.MODEL?.lowercase()?.contains("pixel 10") == true
+ return when (requested) {
+ is Backend.NPU -> listOfNotNull(
+ Backend.NPU(nativeLibraryDir = reactContext.applicationInfo.nativeLibraryDir),
+ if (skipGpu) null else Backend.GPU(),
+ Backend.CPU(),
+ )
+ is Backend.GPU -> if (skipGpu) listOf(Backend.CPU()) else listOf(Backend.GPU(), Backend.CPU())
+ else -> listOf(Backend.CPU())
+ }
}
private suspend fun tryInitBackend(modelPath: String, backend: Backend, name: String, visionEnabled: Boolean): Boolean {
@@ -366,18 +371,17 @@ class LiteRTModule(private val reactContext: ReactApplicationContext) :
@ReactMethod
fun stopGeneration(promise: Promise) {
val safe = SafePromise(promise, TAG)
- Log.i(TAG, "stopGeneration — tearing down conversation")
-
- scope.launch {
- try {
- closeConversationSafely()
- Log.i(TAG, "stopGeneration — done")
- safe.resolve(null)
- } catch (e: Exception) {
- Log.w(TAG, "stopGeneration — error during teardown: ${e.message}")
- safe.resolve(null)
- }
+ Log.i(TAG, "stopGeneration — signalling stop")
+ // Cancel the coroutine job — CancellationException propagates into sendMessageAsync
+ currentJob?.cancel()
+ // Signal the native inference thread to stop — safe to call from any thread
+ try {
+ conversation?.cancelProcess()
+ } catch (e: Exception) {
+ Log.w(TAG, "stopGeneration — cancelProcess error: ${e.message}")
}
+ Log.i(TAG, "stopGeneration — done")
+ safe.resolve(null)
}
// -------------------------------------------------------------------------
@@ -417,6 +421,7 @@ class LiteRTModule(private val reactContext: ReactApplicationContext) :
// -------------------------------------------------------------------------
private suspend fun closeConversationSafely() {
+ // Cancel and wait for any running inference job to fully stop before closing
currentJob?.cancel()
currentJob?.join()
currentJob = null
@@ -427,13 +432,21 @@ class LiteRTModule(private val reactContext: ReactApplicationContext) :
}
pendingToolCalls.clear()
+ // Null first — any concurrent caller gets null and returns, preventing double-close
+ val conv = conversation ?: return
+ conversation = null
+
+ // Safety net: signal stop in case stopGeneration was not called before close
try {
- conversation?.close()
+ conv.cancelProcess()
+ } catch (e: Exception) {
+ Log.w(TAG, "closeConversationSafely — cancelProcess error: ${e.message}")
+ }
+ try {
+ conv.close()
Log.d(TAG, "closeConversationSafely — closed")
} catch (e: Exception) {
Log.w(TAG, "closeConversationSafely — error: ${e.message}")
- } finally {
- conversation = null
}
}
diff --git a/patches/whisper.rn+0.5.5.patch b/patches/whisper.rn+0.5.5.patch
index f7488dc62..ebb100cc7 100644
--- a/patches/whisper.rn+0.5.5.patch
+++ b/patches/whisper.rn+0.5.5.patch
@@ -7,10 +7,44 @@ index 0000000..1675490
+connection.project.dir=../../../android
+eclipse.preferences.version=1
diff --git a/node_modules/whisper.rn/android/src/main/java/com/rnwhisper/WhisperContext.java b/node_modules/whisper.rn/android/src/main/java/com/rnwhisper/WhisperContext.java
-index a056c22..f4781e9 100644
+index a056c22..346cd72 100644
--- a/node_modules/whisper.rn/android/src/main/java/com/rnwhisper/WhisperContext.java
+++ b/node_modules/whisper.rn/android/src/main/java/com/rnwhisper/WhisperContext.java
-@@ -465,6 +465,16 @@ public class WhisperContext {
+@@ -81,6 +81,11 @@ public class WhisperContext {
+ private boolean isTdrzEnable = false;
+ private Thread rootFullHandler = null;
+ private Thread fullHandler = null;
++ // Guards against finishRealtimeTranscribe running more than once per job. The
++ // buffer-full path can reach finishRealtimeTranscribe at line 185 and again at
++ // the end-of-loop check, which double-removes the native job and crashes in
++ // finishRealtimeTranscribeJob (SIGSEGV). Reset in rewind() per new job.
++ private boolean isRealtimeFinished = false;
+
+ public WhisperContext(int id, ReactApplicationContext reactContext, long context) {
+ this.id = id;
+@@ -102,6 +107,7 @@ public class WhisperContext {
+ isTdrzEnable = false;
+ rootFullHandler = null;
+ fullHandler = null;
++ isRealtimeFinished = false;
+ }
+
+ public int getId() {
+@@ -117,7 +123,12 @@ public class WhisperContext {
+ return vadSimple(jobId, sliceIndex, nSamples, n);
+ }
+
+- private void finishRealtimeTranscribe(WritableMap result) {
++ private synchronized void finishRealtimeTranscribe(WritableMap result) {
++ // Run at most once per job. Multiple code paths in the capture loop can reach
++ // here for the same job; a second call would remove an already-removed native
++ // job and SIGSEGV in finishRealtimeTranscribeJob.
++ if (isRealtimeFinished) return;
++ isRealtimeFinished = true;
+ emitTranscribeEvent("@RNWhisper_onRealtimeTranscribeEnd", Arguments.createMap());
+ finishRealtimeTranscribeJob(jobId, context, sliceNSamples.stream().mapToInt(i -> i).toArray());
+ }
+@@ -465,6 +476,16 @@ public class WhisperContext {
public void release() {
stopCurrentTranscribe();
@@ -27,3 +61,21 @@ index a056c22..f4781e9 100644
freeContext(id, context);
}
+diff --git a/node_modules/whisper.rn/android/src/main/jni.cpp b/node_modules/whisper.rn/android/src/main/jni.cpp
+index b1fa548..5f0b7f1 100644
+--- a/node_modules/whisper.rn/android/src/main/jni.cpp
++++ b/node_modules/whisper.rn/android/src/main/jni.cpp
+@@ -485,6 +485,13 @@ Java_com_rnwhisper_WhisperContext_finishRealtimeTranscribeJob(
+ UNUSED(context_ptr);
+
+ rnwhisper::job *job = rnwhisper::job_get(job_id);
++ // The job may already have been removed by fullWithJob completing/aborting,
++ // or by a duplicate finishRealtimeTranscribe call from the realtime thread.
++ // job_get returns nullptr in that case; dereferencing it here is a SIGSEGV.
++ if (job == nullptr) {
++ rnwhisper::job_remove(job_id);
++ return;
++ }
+ if (job->audio_output_path != nullptr) {
+ RNWHISPER_LOG_INFO("job->params.language: %s\n", job->params.language);
+ std::vector slice_n_samples_vec;
diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx
index 7523540b0..9439896a9 100644
--- a/src/screens/ModelsScreen/TextModelsTab.tsx
+++ b/src/screens/ModelsScreen/TextModelsTab.tsx
@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import { View, Text, FlatList, TextInput, ActivityIndicator, RefreshControl, TouchableOpacity, InteractionManager, Platform } from 'react-native';
+import DeviceInfo from 'react-native-device-info';
import Icon from 'react-native-vector-icons/Feather';
import { AttachStep, useSpotlightTour } from 'react-native-spotlight-tour';
import { Card, ModelCard } from '../../components';
@@ -244,6 +245,12 @@ const ModelDetailView: React.FC = ({
)}
+ {selectedModel.id === LITERT_PARENT_ID && Platform.OS === 'android' && DeviceInfo.getModel().toLowerCase().includes('pixel 10') && (
+
+
+ {'GPU acceleration is not yet supported on Pixel 10. Models will run on CPU. Support coming soon.'}
+
+ )}
Available Files
{selectedModel.id !== LITERT_PARENT_ID && (
@@ -378,7 +385,6 @@ export const TextModelsTab: React.FC = (props) => {
setTypeFilter, setSourceFilter, setSizeFilter, setQuantFilter, setSortOption,
isModelDownloaded, getDownloadedModel, isRepairingVisionModel,
} = props;
-
const hasNonSortActiveFilters = hasNonSortFilters(filterState);
const currentSort = SORT_OPTIONS.find(o => o.key === filterState.sort) ?? SORT_OPTIONS[0];
const isSortActive = filterState.sort !== 'recommended';
diff --git a/src/screens/ModelsScreen/styles.ts b/src/screens/ModelsScreen/styles.ts
index d5a846f73..df48f1ab7 100644
--- a/src/screens/ModelsScreen/styles.ts
+++ b/src/screens/ModelsScreen/styles.ts
@@ -112,15 +112,18 @@ const createBaseStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
loadingText: { ...TYPOGRAPHY.body, color: colors.textSecondary },
listContent: { paddingHorizontal: 16, paddingBottom: 32 },
deviceBanner: {
- backgroundColor: `${colors.primary}12`,
+ backgroundColor: `${colors.trending}15`,
borderRadius: 8,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
marginBottom: SPACING.lg,
borderWidth: 1,
- borderColor: `${colors.primary}30`,
+ borderColor: `${colors.trending}40`,
+ flexDirection: 'row' as const,
+ alignItems: 'center' as const,
+ gap: SPACING.xs,
},
- deviceBannerText: { ...TYPOGRAPHY.meta, color: colors.primary },
+ deviceBannerText: { ...TYPOGRAPHY.meta, color: colors.trending, flex: 1 },
deviceBannerWarning: { color: colors.error, marginTop: 2 },
emptyCard: { alignItems: 'center' as const, padding: 32 },
emptyText: { color: colors.textSecondary, textAlign: 'center' as const },