android: routine maintenance - Dec 2025 (#18338)

* Fix `msg` typo

* Fix thread safety in destroy() to support generation abortion in lifecycle callbacks.

* UI polish: stack new message change from below; fix GGUF margin not in view port

* Bug fixes: rare racing condition when main thread updating view and and default thread updating messages at the same time; user input not disabled during generation.

* Bump dependencies' versions; Deprecated outdated dsl usage.
This commit is contained in:
Naco Siren 2025-12-29 05:51:13 -08:00 committed by GitHub
parent 2a85f720b8
commit c1366056f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 68 additions and 39 deletions

View File

@ -41,11 +41,8 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "1.8"
} }
} }

View File

@ -6,6 +6,7 @@ import android.util.Log
import android.widget.EditText import android.widget.EditText
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.addCallback
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -18,6 +19,7 @@ import com.arm.aichat.gguf.GgufMetadata
import com.arm.aichat.gguf.GgufMetadataReader import com.arm.aichat.gguf.GgufMetadataReader
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -36,6 +38,7 @@ class MainActivity : AppCompatActivity() {
// Arm AI Chat inference engine // Arm AI Chat inference engine
private lateinit var engine: InferenceEngine private lateinit var engine: InferenceEngine
private var generationJob: Job? = null
// Conversation states // Conversation states
private var isModelReady = false private var isModelReady = false
@ -47,11 +50,13 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
// View model boilerplate and state management is out of this basic sample's scope
onBackPressedDispatcher.addCallback { Log.w(TAG, "Ignore back press for simplicity") }
// Find views // Find views
ggufTv = findViewById(R.id.gguf) ggufTv = findViewById(R.id.gguf)
messagesRv = findViewById(R.id.messages) messagesRv = findViewById(R.id.messages)
messagesRv.layoutManager = LinearLayoutManager(this) messagesRv.layoutManager = LinearLayoutManager(this).apply { stackFromEnd = true }
messagesRv.adapter = messageAdapter messagesRv.adapter = messageAdapter
userInputEt = findViewById(R.id.user_input) userInputEt = findViewById(R.id.user_input)
userActionFab = findViewById(R.id.fab) userActionFab = findViewById(R.id.fab)
@ -157,33 +162,35 @@ class MainActivity : AppCompatActivity() {
* Validate and send the user message into [InferenceEngine] * Validate and send the user message into [InferenceEngine]
*/ */
private fun handleUserInput() { private fun handleUserInput() {
userInputEt.text.toString().also { userSsg -> userInputEt.text.toString().also { userMsg ->
if (userSsg.isEmpty()) { if (userMsg.isEmpty()) {
Toast.makeText(this, "Input message is empty!", Toast.LENGTH_SHORT).show() Toast.makeText(this, "Input message is empty!", Toast.LENGTH_SHORT).show()
} else { } else {
userInputEt.text = null userInputEt.text = null
userInputEt.isEnabled = false
userActionFab.isEnabled = false userActionFab.isEnabled = false
// Update message states // Update message states
messages.add(Message(UUID.randomUUID().toString(), userSsg, true)) messages.add(Message(UUID.randomUUID().toString(), userMsg, true))
lastAssistantMsg.clear() lastAssistantMsg.clear()
messages.add(Message(UUID.randomUUID().toString(), lastAssistantMsg.toString(), false)) messages.add(Message(UUID.randomUUID().toString(), lastAssistantMsg.toString(), false))
lifecycleScope.launch(Dispatchers.Default) { generationJob = lifecycleScope.launch(Dispatchers.Default) {
engine.sendUserPrompt(userSsg) engine.sendUserPrompt(userMsg)
.onCompletion { .onCompletion {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
userInputEt.isEnabled = true
userActionFab.isEnabled = true userActionFab.isEnabled = true
} }
}.collect { token -> }.collect { token ->
val messageCount = messages.size
check(messageCount > 0 && !messages[messageCount - 1].isUser)
messages.removeAt(messageCount - 1).copy(
content = lastAssistantMsg.append(token).toString()
).let { messages.add(it) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val messageCount = messages.size
check(messageCount > 0 && !messages[messageCount - 1].isUser)
messages.removeAt(messageCount - 1).copy(
content = lastAssistantMsg.append(token).toString()
).let { messages.add(it) }
messageAdapter.notifyItemChanged(messages.size - 1) messageAdapter.notifyItemChanged(messages.size - 1)
} }
} }
@ -195,6 +202,7 @@ class MainActivity : AppCompatActivity() {
/** /**
* Run a benchmark with the model file * Run a benchmark with the model file
*/ */
@Deprecated("This benchmark doesn't accurately indicate GUI performance expected by app developers")
private suspend fun runBenchmark(modelName: String, modelFile: File) = private suspend fun runBenchmark(modelName: String, modelFile: File) =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
Log.i(TAG, "Starts benchmarking $modelName") Log.i(TAG, "Starts benchmarking $modelName")
@ -223,6 +231,16 @@ class MainActivity : AppCompatActivity() {
if (!it.exists()) { it.mkdir() } if (!it.exists()) { it.mkdir() }
} }
override fun onStop() {
generationJob?.cancel()
super.onStop()
}
override fun onDestroy() {
engine.destroy()
super.onDestroy()
}
companion object { companion object {
private val TAG = MainActivity::class.java.simpleName private val TAG = MainActivity::class.java.simpleName

View File

@ -24,7 +24,7 @@
android:id="@+id/gguf" android:id="@+id/gguf"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="16dp" android:padding="16dp"
android:text="Selected GGUF model's metadata will show here." android:text="Selected GGUF model's metadata will show here."
style="@style/TextAppearance.MaterialComponents.Body2" /> style="@style/TextAppearance.MaterialComponents.Body2" />
@ -33,8 +33,7 @@
<com.google.android.material.divider.MaterialDivider <com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="2dp" android:layout_height="2dp"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp" />
android:layout_marginVertical="8dp" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/messages" android:id="@+id/messages"

View File

@ -1,15 +1,15 @@
[versions] [versions]
# Plugins # Plugins
agp = "8.13.0" agp = "8.13.2"
kotlin = "2.2.20" kotlin = "2.3.0"
# AndroidX # AndroidX
activity = "1.11.0" activity = "1.12.2"
appcompat = "1.7.1" appcompat = "1.7.1"
core-ktx = "1.17.0" core-ktx = "1.17.0"
constraint-layout = "2.2.1" constraint-layout = "2.2.1"
datastore-preferences = "1.1.7" datastore-preferences = "1.2.0"
# Material # Material
material = "1.13.0" material = "1.13.0"

View File

@ -560,6 +560,6 @@ Java_com_arm_aichat_internal_InferenceEngineImpl_unload(JNIEnv * /*unused*/, job
extern "C" extern "C"
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_com_arm_aichat_internal_InferenceEngineImpl_shutdown(JNIEnv *env, jobject /*unused*/) { Java_com_arm_aichat_internal_InferenceEngineImpl_shutdown(JNIEnv *, jobject /*unused*/) {
llama_backend_free(); llama_backend_free();
} }

View File

@ -38,7 +38,7 @@ interface InferenceEngine {
/** /**
* Unloads the currently loaded model. * Unloads the currently loaded model.
*/ */
suspend fun cleanUp() fun cleanUp()
/** /**
* Cleans up resources when the engine is no longer needed. * Cleans up resources when the engine is no longer needed.

View File

@ -15,9 +15,11 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -109,9 +111,11 @@ internal class InferenceEngineImpl private constructor(
private val _state = private val _state =
MutableStateFlow<InferenceEngine.State>(InferenceEngine.State.Uninitialized) MutableStateFlow<InferenceEngine.State>(InferenceEngine.State.Uninitialized)
override val state: StateFlow<InferenceEngine.State> = _state override val state: StateFlow<InferenceEngine.State> = _state.asStateFlow()
private var _readyForSystemPrompt = false private var _readyForSystemPrompt = false
@Volatile
private var _cancelGeneration = false
/** /**
* Single-threaded coroutine dispatcher & scope for LLama asynchronous operations * Single-threaded coroutine dispatcher & scope for LLama asynchronous operations
@ -169,6 +173,8 @@ internal class InferenceEngineImpl private constructor(
} }
Log.i(TAG, "Model loaded!") Log.i(TAG, "Model loaded!")
_readyForSystemPrompt = true _readyForSystemPrompt = true
_cancelGeneration = false
_state.value = InferenceEngine.State.ModelReady _state.value = InferenceEngine.State.ModelReady
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, (e.message ?: "Error loading model") + "\n" + pathToModel, e) Log.e(TAG, (e.message ?: "Error loading model") + "\n" + pathToModel, e)
@ -231,15 +237,19 @@ internal class InferenceEngineImpl private constructor(
Log.i(TAG, "User prompt processed. Generating assistant prompt...") Log.i(TAG, "User prompt processed. Generating assistant prompt...")
_state.value = InferenceEngine.State.Generating _state.value = InferenceEngine.State.Generating
while (true) { while (!_cancelGeneration) {
generateNextToken()?.let { utf8token -> generateNextToken()?.let { utf8token ->
if (utf8token.isNotEmpty()) emit(utf8token) if (utf8token.isNotEmpty()) emit(utf8token)
} ?: break } ?: break
} }
Log.i(TAG, "Assistant generation complete. Awaiting user prompt...") if (_cancelGeneration) {
Log.i(TAG, "Assistant generation aborted per requested.")
} else {
Log.i(TAG, "Assistant generation complete. Awaiting user prompt...")
}
_state.value = InferenceEngine.State.ModelReady _state.value = InferenceEngine.State.ModelReady
} catch (e: CancellationException) { } catch (e: CancellationException) {
Log.i(TAG, "Generation cancelled by user.") Log.i(TAG, "Assistant generation's flow collection cancelled.")
_state.value = InferenceEngine.State.ModelReady _state.value = InferenceEngine.State.ModelReady
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@ -268,8 +278,9 @@ internal class InferenceEngineImpl private constructor(
/** /**
* Unloads the model and frees resources, or reset error states * Unloads the model and frees resources, or reset error states
*/ */
override suspend fun cleanUp() = override fun cleanUp() {
withContext(llamaDispatcher) { _cancelGeneration = true
runBlocking(llamaDispatcher) {
when (val state = _state.value) { when (val state = _state.value) {
is InferenceEngine.State.ModelReady -> { is InferenceEngine.State.ModelReady -> {
Log.i(TAG, "Unloading model and free resources...") Log.i(TAG, "Unloading model and free resources...")
@ -293,17 +304,21 @@ internal class InferenceEngineImpl private constructor(
else -> throw IllegalStateException("Cannot unload model in ${state.javaClass.simpleName}") else -> throw IllegalStateException("Cannot unload model in ${state.javaClass.simpleName}")
} }
} }
}
/** /**
* Cancel all ongoing coroutines and free GGML backends * Cancel all ongoing coroutines and free GGML backends
*/ */
override fun destroy() { override fun destroy() {
_readyForSystemPrompt = false _cancelGeneration = true
llamaScope.cancel() runBlocking(llamaDispatcher) {
when(_state.value) { _readyForSystemPrompt = false
is InferenceEngine.State.Uninitialized -> {} when(_state.value) {
is InferenceEngine.State.Initialized -> shutdown() is InferenceEngine.State.Uninitialized -> {}
else -> { unload(); shutdown() } is InferenceEngine.State.Initialized -> shutdown()
else -> { unload(); shutdown() }
}
} }
llamaScope.cancel()
} }
} }