Skip to content

Commit c136605

Browse files
authored
android: routine maintenance - Dec 2025 (ggml-org#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.
1 parent 2a85f72 commit c136605

7 files changed

Lines changed: 67 additions & 38 deletions

File tree

examples/llama.android/app/build.gradle.kts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,8 @@ android {
4141
}
4242
}
4343
compileOptions {
44-
sourceCompatibility = JavaVersion.VERSION_1_8
45-
targetCompatibility = JavaVersion.VERSION_1_8
46-
}
47-
kotlinOptions {
48-
jvmTarget = "1.8"
44+
sourceCompatibility = JavaVersion.VERSION_17
45+
targetCompatibility = JavaVersion.VERSION_17
4946
}
5047
}
5148

examples/llama.android/app/src/main/java/com/example/llama/MainActivity.kt

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.util.Log
66
import android.widget.EditText
77
import android.widget.TextView
88
import android.widget.Toast
9+
import androidx.activity.addCallback
910
import androidx.activity.enableEdgeToEdge
1011
import androidx.activity.result.contract.ActivityResultContracts
1112
import androidx.appcompat.app.AppCompatActivity
@@ -18,6 +19,7 @@ import com.arm.aichat.gguf.GgufMetadata
1819
import com.arm.aichat.gguf.GgufMetadataReader
1920
import com.google.android.material.floatingactionbutton.FloatingActionButton
2021
import kotlinx.coroutines.Dispatchers
22+
import kotlinx.coroutines.Job
2123
import kotlinx.coroutines.flow.onCompletion
2224
import kotlinx.coroutines.launch
2325
import kotlinx.coroutines.withContext
@@ -36,6 +38,7 @@ class MainActivity : AppCompatActivity() {
3638

3739
// Arm AI Chat inference engine
3840
private lateinit var engine: InferenceEngine
41+
private var generationJob: Job? = null
3942

4043
// Conversation states
4144
private var isModelReady = false
@@ -47,11 +50,13 @@ class MainActivity : AppCompatActivity() {
4750
super.onCreate(savedInstanceState)
4851
enableEdgeToEdge()
4952
setContentView(R.layout.activity_main)
53+
// View model boilerplate and state management is out of this basic sample's scope
54+
onBackPressedDispatcher.addCallback { Log.w(TAG, "Ignore back press for simplicity") }
5055

5156
// Find views
5257
ggufTv = findViewById(R.id.gguf)
5358
messagesRv = findViewById(R.id.messages)
54-
messagesRv.layoutManager = LinearLayoutManager(this)
59+
messagesRv.layoutManager = LinearLayoutManager(this).apply { stackFromEnd = true }
5560
messagesRv.adapter = messageAdapter
5661
userInputEt = findViewById(R.id.user_input)
5762
userActionFab = findViewById(R.id.fab)
@@ -157,33 +162,35 @@ class MainActivity : AppCompatActivity() {
157162
* Validate and send the user message into [InferenceEngine]
158163
*/
159164
private fun handleUserInput() {
160-
userInputEt.text.toString().also { userSsg ->
161-
if (userSsg.isEmpty()) {
165+
userInputEt.text.toString().also { userMsg ->
166+
if (userMsg.isEmpty()) {
162167
Toast.makeText(this, "Input message is empty!", Toast.LENGTH_SHORT).show()
163168
} else {
164169
userInputEt.text = null
170+
userInputEt.isEnabled = false
165171
userActionFab.isEnabled = false
166172

167173
// Update message states
168-
messages.add(Message(UUID.randomUUID().toString(), userSsg, true))
174+
messages.add(Message(UUID.randomUUID().toString(), userMsg, true))
169175
lastAssistantMsg.clear()
170176
messages.add(Message(UUID.randomUUID().toString(), lastAssistantMsg.toString(), false))
171177

172-
lifecycleScope.launch(Dispatchers.Default) {
173-
engine.sendUserPrompt(userSsg)
178+
generationJob = lifecycleScope.launch(Dispatchers.Default) {
179+
engine.sendUserPrompt(userMsg)
174180
.onCompletion {
175181
withContext(Dispatchers.Main) {
182+
userInputEt.isEnabled = true
176183
userActionFab.isEnabled = true
177184
}
178185
}.collect { token ->
179-
val messageCount = messages.size
180-
check(messageCount > 0 && !messages[messageCount - 1].isUser)
186+
withContext(Dispatchers.Main) {
187+
val messageCount = messages.size
188+
check(messageCount > 0 && !messages[messageCount - 1].isUser)
181189

182-
messages.removeAt(messageCount - 1).copy(
183-
content = lastAssistantMsg.append(token).toString()
184-
).let { messages.add(it) }
190+
messages.removeAt(messageCount - 1).copy(
191+
content = lastAssistantMsg.append(token).toString()
192+
).let { messages.add(it) }
185193

186-
withContext(Dispatchers.Main) {
187194
messageAdapter.notifyItemChanged(messages.size - 1)
188195
}
189196
}
@@ -195,6 +202,7 @@ class MainActivity : AppCompatActivity() {
195202
/**
196203
* Run a benchmark with the model file
197204
*/
205+
@Deprecated("This benchmark doesn't accurately indicate GUI performance expected by app developers")
198206
private suspend fun runBenchmark(modelName: String, modelFile: File) =
199207
withContext(Dispatchers.Default) {
200208
Log.i(TAG, "Starts benchmarking $modelName")
@@ -223,6 +231,16 @@ class MainActivity : AppCompatActivity() {
223231
if (!it.exists()) { it.mkdir() }
224232
}
225233

234+
override fun onStop() {
235+
generationJob?.cancel()
236+
super.onStop()
237+
}
238+
239+
override fun onDestroy() {
240+
engine.destroy()
241+
super.onDestroy()
242+
}
243+
226244
companion object {
227245
private val TAG = MainActivity::class.java.simpleName
228246

examples/llama.android/app/src/main/res/layout/activity_main.xml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
android:id="@+id/gguf"
2525
android:layout_width="match_parent"
2626
android:layout_height="wrap_content"
27-
android:layout_margin="16dp"
27+
android:padding="16dp"
2828
android:text="Selected GGUF model's metadata will show here."
2929
style="@style/TextAppearance.MaterialComponents.Body2" />
3030

@@ -33,8 +33,7 @@
3333
<com.google.android.material.divider.MaterialDivider
3434
android:layout_width="match_parent"
3535
android:layout_height="2dp"
36-
android:layout_marginHorizontal="16dp"
37-
android:layout_marginVertical="8dp" />
36+
android:layout_marginHorizontal="16dp" />
3837

3938
<androidx.recyclerview.widget.RecyclerView
4039
android:id="@+id/messages"

examples/llama.android/gradle/libs.versions.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
[versions]
22

33
# Plugins
4-
agp = "8.13.0"
5-
kotlin = "2.2.20"
4+
agp = "8.13.2"
5+
kotlin = "2.3.0"
66

77
# AndroidX
8-
activity = "1.11.0"
8+
activity = "1.12.2"
99
appcompat = "1.7.1"
1010
core-ktx = "1.17.0"
1111
constraint-layout = "2.2.1"
12-
datastore-preferences = "1.1.7"
12+
datastore-preferences = "1.2.0"
1313

1414
# Material
1515
material = "1.13.0"

examples/llama.android/lib/src/main/cpp/ai_chat.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,6 @@ Java_com_arm_aichat_internal_InferenceEngineImpl_unload(JNIEnv * /*unused*/, job
560560

561561
extern "C"
562562
JNIEXPORT void JNICALL
563-
Java_com_arm_aichat_internal_InferenceEngineImpl_shutdown(JNIEnv *env, jobject /*unused*/) {
563+
Java_com_arm_aichat_internal_InferenceEngineImpl_shutdown(JNIEnv *, jobject /*unused*/) {
564564
llama_backend_free();
565565
}

examples/llama.android/lib/src/main/java/com/arm/aichat/InferenceEngine.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ interface InferenceEngine {
3838
/**
3939
* Unloads the currently loaded model.
4040
*/
41-
suspend fun cleanUp()
41+
fun cleanUp()
4242

4343
/**
4444
* Cleans up resources when the engine is no longer needed.

examples/llama.android/lib/src/main/java/com/arm/aichat/internal/InferenceEngineImpl.kt

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import kotlinx.coroutines.cancel
1515
import kotlinx.coroutines.flow.Flow
1616
import kotlinx.coroutines.flow.MutableStateFlow
1717
import kotlinx.coroutines.flow.StateFlow
18+
import kotlinx.coroutines.flow.asStateFlow
1819
import kotlinx.coroutines.flow.flow
1920
import kotlinx.coroutines.flow.flowOn
2021
import kotlinx.coroutines.launch
22+
import kotlinx.coroutines.runBlocking
2123
import kotlinx.coroutines.withContext
2224
import java.io.File
2325
import java.io.IOException
@@ -109,9 +111,11 @@ internal class InferenceEngineImpl private constructor(
109111

110112
private val _state =
111113
MutableStateFlow<InferenceEngine.State>(InferenceEngine.State.Uninitialized)
112-
override val state: StateFlow<InferenceEngine.State> = _state
114+
override val state: StateFlow<InferenceEngine.State> = _state.asStateFlow()
113115

114116
private var _readyForSystemPrompt = false
117+
@Volatile
118+
private var _cancelGeneration = false
115119

116120
/**
117121
* Single-threaded coroutine dispatcher & scope for LLama asynchronous operations
@@ -169,6 +173,8 @@ internal class InferenceEngineImpl private constructor(
169173
}
170174
Log.i(TAG, "Model loaded!")
171175
_readyForSystemPrompt = true
176+
177+
_cancelGeneration = false
172178
_state.value = InferenceEngine.State.ModelReady
173179
} catch (e: Exception) {
174180
Log.e(TAG, (e.message ?: "Error loading model") + "\n" + pathToModel, e)
@@ -231,15 +237,19 @@ internal class InferenceEngineImpl private constructor(
231237

232238
Log.i(TAG, "User prompt processed. Generating assistant prompt...")
233239
_state.value = InferenceEngine.State.Generating
234-
while (true) {
240+
while (!_cancelGeneration) {
235241
generateNextToken()?.let { utf8token ->
236242
if (utf8token.isNotEmpty()) emit(utf8token)
237243
} ?: break
238244
}
239-
Log.i(TAG, "Assistant generation complete. Awaiting user prompt...")
245+
if (_cancelGeneration) {
246+
Log.i(TAG, "Assistant generation aborted per requested.")
247+
} else {
248+
Log.i(TAG, "Assistant generation complete. Awaiting user prompt...")
249+
}
240250
_state.value = InferenceEngine.State.ModelReady
241251
} catch (e: CancellationException) {
242-
Log.i(TAG, "Generation cancelled by user.")
252+
Log.i(TAG, "Assistant generation's flow collection cancelled.")
243253
_state.value = InferenceEngine.State.ModelReady
244254
throw e
245255
} catch (e: Exception) {
@@ -268,8 +278,9 @@ internal class InferenceEngineImpl private constructor(
268278
/**
269279
* Unloads the model and frees resources, or reset error states
270280
*/
271-
override suspend fun cleanUp() =
272-
withContext(llamaDispatcher) {
281+
override fun cleanUp() {
282+
_cancelGeneration = true
283+
runBlocking(llamaDispatcher) {
273284
when (val state = _state.value) {
274285
is InferenceEngine.State.ModelReady -> {
275286
Log.i(TAG, "Unloading model and free resources...")
@@ -293,17 +304,21 @@ internal class InferenceEngineImpl private constructor(
293304
else -> throw IllegalStateException("Cannot unload model in ${state.javaClass.simpleName}")
294305
}
295306
}
307+
}
296308

297309
/**
298310
* Cancel all ongoing coroutines and free GGML backends
299311
*/
300312
override fun destroy() {
301-
_readyForSystemPrompt = false
302-
llamaScope.cancel()
303-
when(_state.value) {
304-
is InferenceEngine.State.Uninitialized -> {}
305-
is InferenceEngine.State.Initialized -> shutdown()
306-
else -> { unload(); shutdown() }
313+
_cancelGeneration = true
314+
runBlocking(llamaDispatcher) {
315+
_readyForSystemPrompt = false
316+
when(_state.value) {
317+
is InferenceEngine.State.Uninitialized -> {}
318+
is InferenceEngine.State.Initialized -> shutdown()
319+
else -> { unload(); shutdown() }
320+
}
307321
}
322+
llamaScope.cancel()
308323
}
309324
}

0 commit comments

Comments
 (0)