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:
parent
2a85f720b8
commit
c1366056f6
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue