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 },