Skip to content

Commit a4a14ec

Browse files
committed
fix: Android app bug fixes - race conditions, ANR, pixel corruption, scroll, and memory safety
- VoiceAssistantViewModel: replace runBlocking with GlobalScope.launch in onCleared to prevent ANR - VoiceAssistantViewModel: add synchronized audioBufferLock for thread-safe ByteArrayOutputStream access - VoiceAssistantViewModel: scan WAV data chunk instead of hardcoding 44-byte header offset - ConversationStore: use MutableStateFlow.update {} for atomic compare-and-set on all mutations - ToolSettingsViewModel: clear static singleton in onCleared to prevent stale references - VLMViewModel: advance rgbIdx by 3 in else branch to prevent pixel corruption on out-of-bounds skip - ChatViewModel: use CopyOnWriteArrayList for tokensPerSecondHistory thread safety - VoiceAssistantParticleView: remove wasted transparent drawPoints call - RunAnywhereApplication: capture volatile initializationError to local val before null check - VLMScreen: add verticalScroll to description panel for long text overflow - ResponsiveUtils: add designWidth <= 0 guard to prevent division by zero in rDp/rSp
1 parent 7c3625e commit a4a14ec

9 files changed

Lines changed: 92 additions & 61 deletions

File tree

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/RunAnywhereApplication.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,12 @@ class RunAnywhereApplication : Application() {
208208
isSDKInitialized = RunAnywhere.isInitialized
209209

210210
// Update observable state for Compose UI
211+
val error = initializationError
211212
if (isSDKInitialized) {
212213
_initializationState.value = SDKInitializationState.Ready
213214
Timber.i("🎉 App is ready to use!")
214-
} else if (initializationError != null) {
215-
_initializationState.value = SDKInitializationState.Error(initializationError!!)
215+
} else if (error != null) {
216+
_initializationState.value = SDKInitializationState.Error(error)
216217
} else {
217218
// SDK reported not initialized but no error - treat as ready for offline mode
218219
_initializationState.value = SDKInitializationState.Ready

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/ConversationStore.kt

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob
1212
import kotlinx.coroutines.flow.MutableStateFlow
1313
import kotlinx.coroutines.flow.StateFlow
1414
import kotlinx.coroutines.flow.asStateFlow
15+
import kotlinx.coroutines.flow.update
1516
import kotlinx.coroutines.launch
1617
import kotlinx.serialization.decodeFromString
1718
import kotlinx.serialization.encodeToString
@@ -79,9 +80,7 @@ class ConversationStore private constructor(context: Context) {
7980
performanceSummary = null,
8081
)
8182

82-
val updated = _conversations.value.toMutableList()
83-
updated.add(0, conversation)
84-
_conversations.value = updated
83+
_conversations.update { list -> listOf(conversation) + list }
8584
_currentConversation.value = conversation
8685

8786
saveConversation(conversation)
@@ -93,31 +92,36 @@ class ConversationStore private constructor(context: Context) {
9392
* If not present (by id), adds it at the front so it appears in history.
9493
*/
9594
fun ensureConversationInList(conversation: Conversation) {
96-
val index = _conversations.value.indexOfFirst { it.id == conversation.id }
97-
if (index == -1) {
98-
val list = _conversations.value.toMutableList()
99-
list.add(0, conversation)
100-
_conversations.value = list
101-
saveConversation(conversation)
95+
var wasAdded = false
96+
_conversations.update { list ->
97+
if (list.any { it.id == conversation.id }) {
98+
list
99+
} else {
100+
wasAdded = true
101+
listOf(conversation) + list
102+
}
102103
}
104+
if (wasAdded) saveConversation(conversation)
103105
}
104106

105107
/**
106108
* Update an existing conversation
107109
*/
108110
fun updateConversation(conversation: Conversation) {
109111
val updated = conversation.copy(updatedAt = System.currentTimeMillis())
110-
111-
val index = _conversations.value.indexOfFirst { it.id == conversation.id }
112-
if (index != -1) {
113-
val list = _conversations.value.toMutableList()
114-
list[index] = updated
115-
_conversations.value = list
116-
112+
var found = false
113+
_conversations.update { list ->
114+
list.map {
115+
if (it.id == conversation.id) {
116+
found = true
117+
updated
118+
} else it
119+
}
120+
}
121+
if (found) {
117122
if (_currentConversation.value?.id == conversation.id) {
118123
_currentConversation.value = updated
119124
}
120-
121125
saveConversation(updated)
122126
}
123127
}
@@ -126,7 +130,7 @@ class ConversationStore private constructor(context: Context) {
126130
* Delete a conversation
127131
*/
128132
fun deleteConversation(conversation: Conversation) {
129-
_conversations.value = _conversations.value.filter { it.id != conversation.id }
133+
_conversations.update { list -> list.filter { it.id != conversation.id } }
130134

131135
if (_currentConversation.value?.id == conversation.id) {
132136
_currentConversation.value = _conversations.value.firstOrNull()
@@ -183,9 +187,7 @@ class ConversationStore private constructor(context: Context) {
183187
try {
184188
val jsonString = file.readText()
185189
val loaded = json.decodeFromString<Conversation>(jsonString)
186-
val list = _conversations.value.toMutableList()
187-
list.add(loaded)
188-
_conversations.value = list
190+
_conversations.update { list -> list + loaded }
189191
_currentConversation.value = loaded
190192
return loaded
191193
} catch (e: Exception) {

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/ChatViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ data class ChatUiState(
7979
class ChatViewModel(application: Application) : AndroidViewModel(application) {
8080
private val app = application as RunAnywhereApplication
8181
private val conversationStore = ConversationStore.getInstance(application)
82-
private val tokensPerSecondHistory = mutableListOf<Double>()
82+
private val tokensPerSecondHistory = java.util.concurrent.CopyOnWriteArrayList<Double>()
8383

8484
private val _uiState = MutableStateFlow(ChatUiState())
8585
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/ToolSettingsViewModel.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,4 +433,13 @@ class ToolSettingsViewModel private constructor(application: Application) : Andr
433433
else -> token.toDoubleOrNull() ?: 0.0
434434
}
435435
}
436+
437+
override fun onCleared() {
438+
super.onCleared()
439+
synchronized(Companion) {
440+
if (instance === this) {
441+
instance = null
442+
}
443+
}
444+
}
436445
}

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMScreen.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
2222
import androidx.compose.foundation.layout.height
2323
import androidx.compose.foundation.layout.padding
2424
import androidx.compose.foundation.layout.size
25+
import androidx.compose.foundation.rememberScrollState
2526
import androidx.compose.foundation.shape.CircleShape
2627
import androidx.compose.foundation.shape.RoundedCornerShape
28+
import androidx.compose.foundation.verticalScroll
2729
import androidx.compose.material.icons.Icons
2830
import androidx.compose.material.icons.automirrored.filled.ArrowBack
2931
import androidx.compose.material.icons.filled.AutoAwesome
@@ -415,7 +417,8 @@ private fun DescriptionPanel(
415417
// Description text — mirrors iOS ScrollView
416418
Column(
417419
modifier = Modifier
418-
.weight(1f),
420+
.weight(1f)
421+
.verticalScroll(rememberScrollState()),
419422
) {
420423
when {
421424
error != null -> {

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMViewModel.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
192192
rgb[rgbIdx++] = buffer[srcIdx] // R
193193
rgb[rgbIdx++] = buffer[srcIdx + 1] // G
194194
rgb[rgbIdx++] = buffer[srcIdx + 2] // B
195+
} else {
196+
rgbIdx += 3 // skip pixel but keep alignment
195197
}
196198
}
197199
}

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantParticleView.kt

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -388,19 +388,8 @@ private fun DrawScope.drawParticlesBatched(
388388
}
389389
}
390390

391-
// Batch draw all small particles in one call
391+
// Batch draw all small particles grouped by quantized color
392392
if (batchPoints.isNotEmpty()) {
393-
// drawPoints with StrokeCap.Round renders small circles efficiently
394-
drawPoints(
395-
points = batchPoints,
396-
pointMode = PointMode.Points,
397-
color = Color.Transparent, // overridden per-point below
398-
strokeWidth = 4f,
399-
cap = StrokeCap.Round,
400-
)
401-
// Unfortunately drawPoints doesn't support per-point color,
402-
// so we draw in color buckets for efficiency.
403-
// Group by quantized color to reduce draw calls.
404393
drawBatchedByColor(batchPoints, batchColors)
405394
}
406395
}

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/voice/VoiceAssistantViewModel.kt

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,9 @@ class VoiceAssistantViewModel(
123123
// Audio capture service for microphone input
124124
private var audioCaptureService: AudioCaptureService? = null
125125

126-
// Audio buffer for accumulating audio data
126+
// Audio buffer for accumulating audio data (guarded by audioBufferLock)
127127
private val audioBuffer = ByteArrayOutputStream()
128+
private val audioBufferLock = Any()
128129

129130
// Voice session flow
130131
private var voiceSessionFlow: Flow<VoiceSessionEvent>? = null
@@ -257,13 +258,13 @@ class VoiceAssistantViewModel(
257258
_uiState.update { it.copy(isSpeechDetected = false) }
258259

259260
// Check if we have enough audio to process
260-
val audioSize = audioBuffer.size()
261+
val audioSize = synchronized(audioBufferLock) { audioBuffer.size() }
261262
if (audioSize >= minAudioBytes) {
262263
Timber.i("🚀 Auto-triggering voice pipeline (audio: $audioSize bytes)")
263264
processCurrentAudio()
264265
} else {
265266
Timber.d("Audio too short to process ($audioSize bytes), resetting buffer")
266-
audioBuffer.reset()
267+
synchronized(audioBufferLock) { audioBuffer.reset() }
267268
}
268269
}
269270
}
@@ -285,8 +286,11 @@ class VoiceAssistantViewModel(
285286
isProcessingTurn = true
286287

287288
// Get the buffered audio and reset
288-
val audioData = audioBuffer.toByteArray()
289-
audioBuffer.reset()
289+
val audioData: ByteArray
290+
synchronized(audioBufferLock) {
291+
audioData = audioBuffer.toByteArray()
292+
audioBuffer.reset()
293+
}
290294

291295
processingJob = viewModelScope.launch {
292296
try {
@@ -376,7 +380,7 @@ class VoiceAssistantViewModel(
376380
isProcessingTurn = false
377381
isSpeechActive = false
378382
lastSpeechTime = 0L
379-
audioBuffer.reset()
383+
synchronized(audioBufferLock) { audioBuffer.reset() }
380384

381385
_uiState.update {
382386
it.copy(
@@ -395,7 +399,7 @@ class VoiceAssistantViewModel(
395399
audioCapture.startCapture().collect { audioData ->
396400
if (isProcessingTurn) return@collect
397401

398-
withContext(Dispatchers.IO) {
402+
synchronized(audioBufferLock) {
399403
audioBuffer.write(audioData)
400404
}
401405

@@ -448,18 +452,32 @@ class VoiceAssistantViewModel(
448452
val channelConfig = AudioFormat.CHANNEL_OUT_MONO
449453
val audioFormat = AudioFormat.ENCODING_PCM_16BIT
450454

451-
// Skip WAV header (44 bytes) if present
452-
val headerSize =
453-
if (audioData.size > 44 &&
454-
audioData[0] == 'R'.code.toByte() &&
455-
audioData[1] == 'I'.code.toByte() &&
456-
audioData[2] == 'F'.code.toByte() &&
457-
audioData[3] == 'F'.code.toByte()
458-
) {
459-
44
460-
} else {
461-
0
455+
// Scan for WAV "data" chunk to find PCM offset
456+
val isWav = audioData.size > 44 &&
457+
audioData[0] == 'R'.code.toByte() &&
458+
audioData[1] == 'I'.code.toByte() &&
459+
audioData[2] == 'F'.code.toByte() &&
460+
audioData[3] == 'F'.code.toByte()
461+
462+
val headerSize = if (isWav) {
463+
var offset = 12 // skip RIFF header (12 bytes)
464+
var dataStart = -1
465+
while (offset + 8 <= audioData.size) {
466+
val chunkId = String(audioData, offset, 4, Charsets.US_ASCII)
467+
val chunkSize = (audioData[offset + 4].toInt() and 0xFF) or
468+
((audioData[offset + 5].toInt() and 0xFF) shl 8) or
469+
((audioData[offset + 6].toInt() and 0xFF) shl 16) or
470+
((audioData[offset + 7].toInt() and 0xFF) shl 24)
471+
if (chunkId == "data") {
472+
dataStart = offset + 8
473+
break
474+
}
475+
offset += 8 + chunkSize
462476
}
477+
if (dataStart > 0) dataStart else 44 // fallback for malformed files
478+
} else {
479+
0
480+
}
463481

464482
val pcmData = audioData.copyOfRange(headerSize, audioData.size)
465483
Timber.d("PCM data size: ${pcmData.size} bytes (skipped $headerSize byte header)")
@@ -786,7 +804,7 @@ class VoiceAssistantViewModel(
786804
}
787805

788806
// Reset audio buffer
789-
audioBuffer.reset()
807+
synchronized(audioBufferLock) { audioBuffer.reset() }
790808

791809
// Update state to listening
792810
_uiState.update {
@@ -974,9 +992,13 @@ class VoiceAssistantViewModel(
974992
audioCaptureService?.stopCapture()
975993

976994
// Get the buffered audio before resetting
977-
val audioData = audioBuffer.toByteArray()
978-
val audioSize = audioData.size
979-
audioBuffer.reset()
995+
val audioData: ByteArray
996+
val audioSize: Int
997+
synchronized(audioBufferLock) {
998+
audioData = audioBuffer.toByteArray()
999+
audioSize = audioData.size
1000+
audioBuffer.reset()
1001+
}
9801002

9811003
Timber.i("Captured audio: $audioSize bytes")
9821004

@@ -1168,8 +1190,9 @@ class VoiceAssistantViewModel(
11681190
stopAudioPlayback()
11691191
audioCaptureService?.release()
11701192
audioCaptureService = null
1171-
// Run synchronously — viewModelScope is dead after super.onCleared()
1172-
kotlinx.coroutines.runBlocking {
1193+
// Fire-and-forget on IO — viewModelScope is dead after super.onCleared()
1194+
@Suppress("OPT_IN_USAGE")
1195+
kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) {
11731196
try {
11741197
RunAnywhere.stopVoiceSession()
11751198
} catch (_: Exception) { /* best-effort cleanup */ }

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/ui/theme/ResponsiveUtils.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.sp
1717
@SuppressLint("ConfigurationScreenWidthHeight")
1818
@Composable
1919
fun rDp(baseDp: Dp, designWidth: Float = 360f): Dp {
20+
if (designWidth <= 0f || !designWidth.isFinite()) return baseDp
2021
val screenWidthDp = LocalConfiguration.current.screenWidthDp.toFloat()
2122
if (screenWidthDp <= 0f) return baseDp
2223
return (baseDp.value * (screenWidthDp / designWidth)).dp
@@ -30,6 +31,7 @@ fun rDp(baseDp: Dp, designWidth: Float = 360f): Dp {
3031
@Composable
3132
fun rSp(baseSp: TextUnit, designWidth: Float = 360f): TextUnit {
3233
if (baseSp.type != TextUnitType.Sp) return baseSp
34+
if (designWidth <= 0f || !designWidth.isFinite()) return baseSp
3335
val screenWidthDp = LocalConfiguration.current.screenWidthDp.toFloat()
3436
if (screenWidthDp <= 0f) return baseSp
3537
return (baseSp.value * (screenWidthDp / designWidth)).sp

0 commit comments

Comments
 (0)