UI: replace Models Management screen's stubbing with instrumentation

This commit is contained in:
Han Yin 2025-04-14 15:20:06 -07:00
parent bc93c384a7
commit 760d66c97d
3 changed files with 215 additions and 94 deletions

View File

@ -2,18 +2,30 @@ package com.example.llama.revamp.data.repository
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.StatFs
import android.provider.OpenableColumns
import com.example.llama.revamp.data.local.ModelDao
import com.example.llama.revamp.data.local.ModelEntity
import com.example.llama.revamp.data.model.ModelInfo import com.example.llama.revamp.data.model.ModelInfo
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Repository for managing available models. * Repository for managing available models on local device.
*/ */
interface ModelRepository { interface ModelRepository {
// TODO-han.yin: change to Flow for getters fun getStorageMetrics(): Flow<StorageMetrics>
suspend fun getStorageMetrics(): StorageMetrics fun getModels(): Flow<List<ModelInfo>>
suspend fun getModels(): List<ModelInfo>
suspend fun importModel(uri: Uri): ModelInfo suspend fun importModel(uri: Uri): ModelInfo
suspend fun deleteModel(modelId: String) suspend fun deleteModel(modelId: String)
@ -23,40 +35,163 @@ interface ModelRepository {
@Singleton @Singleton
class ModelRepositoryImpl @Inject constructor( class ModelRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
// TODO-han.yin: Add model DAO private val modelDao: ModelDao,
) : ModelRepository { ) : ModelRepository {
override suspend fun getStorageMetrics(): StorageMetrics { private val modelsDir = File(context.filesDir, INTERNAL_STORAGE_PATH)
// Stub - would calculate from actual storage
return StorageMetrics(14.6f, 32.0f) init {
if (!modelsDir.exists()) { modelsDir.mkdirs() }
} }
override suspend fun getModels(): List<ModelInfo> { override fun getStorageMetrics(): Flow<StorageMetrics> = flow {
// In a real implementation, this would load from database while (true) {
return ModelInfo.getSampleModels() emit(
StorageMetrics(
usedGB = modelsSizeBytes / BYTES_IN_GB,
totalGB = availableSpaceBytes / BYTES_IN_GB
)
)
delay(STORAGE_METRICS_UPDATE_INTERVAL)
}
} }
override fun getModels(): Flow<List<ModelInfo>> =
modelDao.getAllModels()
.map { entities ->
entities.filter {
val file = File(it.path)
file.exists() && file.isFile
}.map {
it.toModelInfo()
}
}
override suspend fun importModel(uri: Uri): ModelInfo { override suspend fun importModel(uri: Uri): ModelInfo {
// Stub - would copy file and extract metadata // Obtain the local model's file via provided URI
return ModelInfo.getSampleModels().first() val fileName = getFileNameFromUri(uri)
val modelFile = File(modelsDir, fileName)
// Copy file to app's internal storage
context.contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(modelFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: throw IOException("Failed to open input stream")
// Extract model parameters from filename
val modelType = extractModelTypeFromFilename(fileName) ?: "unknown"
val parameters = extractParametersFromFilename(fileName) ?: "unknown"
val quantization = extractQuantizationFromFilename(fileName) ?: "unknown"
// Create model entity and save via DAO
val modelEntity = ModelEntity(
id = UUID.randomUUID().toString(),
name = fileName.substringBeforeLast('.'),
path = modelFile.absolutePath,
sizeInBytes = modelFile.length(),
parameters = parameters,
quantization = quantization,
type = modelType,
contextLength = DEFAULT_CONTEXT_SIZE,
lastUsed = null,
dateAdded = System.currentTimeMillis()
)
modelDao.insertModel(modelEntity)
return modelEntity.toModelInfo()
} }
override suspend fun deleteModel(modelId: String) { override suspend fun deleteModel(modelId: String) {
// Stub - would delete from filesystem and database modelDao.getModelById(modelId)?.let { model ->
File(model.path).let {
if (it.exists()) { it.delete() }
}
modelDao.deleteModel(model)
}
} }
override suspend fun deleteModels(modelIds: Collection<String>) { override suspend fun deleteModels(modelIds: Collection<String>) {
// Stub - would delete from filesystem and database modelDao.getModelsByIds(modelIds).let { models ->
models.forEach { model ->
File(model.path).let {
if (it.exists()) { it.delete() }
}
}
modelDao.deleteModels(models)
}
}
val modelsSizeBytes: Long
get() = modelsDir.listFiles()?.fold(0L) { totalSize, file ->
totalSize + if (file.isFile) file.length() else 0
} ?: 0L
val availableSpaceBytes: Long
get() = StatFs(context.filesDir.path).availableBytes
val totalSpaceBytes: Long
get() = StatFs(context.filesDir.path).totalBytes
private fun getFileNameFromUri(uri: Uri): String =
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { nameIndex ->
if (nameIndex != -1) cursor.getString(nameIndex) else null
}
} else {
null
}
} ?: uri.lastPathSegment ?: "unknown_model.gguf"
/**
* Try to extract parameters by looking for patterns like 7B, 13B, etc.
*
* TODO-han.yin: Enhance and move into a utility object for unit testing
*/
private fun extractParametersFromFilename(filename: String): String? =
Regex("([0-9]+(\\.[0-9]+)?)[bB]").find(filename)?.value?.uppercase()
/**
* Try to extract quantization by looking for patterns like Q4_0, Q5_K_M, etc.
*/
private fun extractQuantizationFromFilename(filename: String) =
listOf(
Regex("[qQ][0-9]_[0-9]"),
Regex("[qQ][0-9]_[kK]_[mM]"),
Regex("[qQ][0-9]_[kK]"),
Regex("[qQ][0-9][fF](16|32)")
).firstNotNullOfOrNull {
it.find(filename)?.value?.uppercase()
}
/**
* Try to extract model type (Llama, Mistral, etc.)
*
* TODO-han.yin: Replace with GGUF parsing, also to be moved into the util object
*/
private fun extractModelTypeFromFilename(filename: String): String? {
val lowerFilename = filename.lowercase()
return listOf("llama", "mistral", "phi", "qwen", "falcon", "mpt")
.firstNotNullOfOrNull { type ->
if (lowerFilename.contains(type)) {
type.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString()
}
} else { null }
}
}
companion object {
private const val INTERNAL_STORAGE_PATH = "models"
private const val BYTES_IN_GB = 1024f * 1024f * 1024f
private const val STORAGE_METRICS_UPDATE_INTERVAL = 5_000L
private const val DEFAULT_CONTEXT_SIZE = 8192
} }
} }
data class StorageMetrics( data class StorageMetrics(
val usedGB: Float, val usedGB: Float,
val totalGB: Float val totalGB: Float
) { )
val percentUsed: Float
get() = if (totalGB > 0) usedGB / totalGB else 0f
val freeGB: Float
get() = totalGB - usedGB
}

View File

@ -63,16 +63,16 @@ fun ModelsManagementScreen(
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
viewModel: ModelsManagementViewModel = hiltViewModel() viewModel: ModelsManagementViewModel = hiltViewModel()
) { ) {
// For demo purposes, we'll use sample models val sortedModels by viewModel.sortedModels.collectAsState()
val models by viewModel.availableModels.collectAsState()
val storageMetrics by viewModel.storageMetrics.collectAsState() val storageMetrics by viewModel.storageMetrics.collectAsState()
// UI states // UI: menu states
var isMultiSelectionMode by remember { mutableStateOf(false) }
val selectedModels = remember { mutableStateMapOf<String, ModelInfo>() }
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
var showAddModelMenu by remember { mutableStateOf(false) } var showAddModelMenu by remember { mutableStateOf(false) }
// UI: multi-selection states
var isMultiSelectionMode by remember { mutableStateOf(false) }
val selectedModels = remember { mutableStateMapOf<String, ModelInfo>() }
val exitSelectionMode = { val exitSelectionMode = {
isMultiSelectionMode = false isMultiSelectionMode = false
selectedModels.clear() selectedModels.clear()
@ -80,8 +80,8 @@ fun ModelsManagementScreen(
StorageAppScaffold( StorageAppScaffold(
title = "Models Management", title = "Models Management",
storageUsed = storageMetrics.usedGB, storageUsed = storageMetrics?.usedGB ?: 0f,
storageTotal = storageMetrics.totalGB, storageTotal = storageMetrics?.totalGB ?: 0f,
onNavigateBack = onBackPressed, onNavigateBack = onBackPressed,
bottomBar = { bottomBar = {
BottomAppBar( BottomAppBar(
@ -90,7 +90,7 @@ fun ModelsManagementScreen(
// Multi-selection mode actions // Multi-selection mode actions
IconButton(onClick = { IconButton(onClick = {
// Select all // Select all
selectedModels.putAll(models.map { it.id to it }) selectedModels.putAll(sortedModels.map { it.id to it })
}) { }) {
Icon( Icon(
imageVector = Icons.Default.SelectAll, imageVector = Icons.Default.SelectAll,
@ -226,7 +226,8 @@ fun ModelsManagementScreen(
) )
}, },
onClick = { onClick = {
viewModel.importLocalModel() // TODO-han.yin: uncomment once file picker done
// viewModel.importLocalModel()
showAddModelMenu = false showAddModelMenu = false
} }
) )
@ -252,7 +253,7 @@ fun ModelsManagementScreen(
) { paddingValues -> ) { paddingValues ->
// Main content // Main content
ModelList( ModelList(
models = models, models = sortedModels,
isMultiSelectionMode = isMultiSelectionMode, isMultiSelectionMode = isMultiSelectionMode,
selectedModels = selectedModels, selectedModels = selectedModels,
onModelClick = { modelId -> onModelClick = { modelId ->
@ -261,7 +262,7 @@ fun ModelsManagementScreen(
if (selectedModels.contains(modelId)) { if (selectedModels.contains(modelId)) {
selectedModels.remove(modelId) selectedModels.remove(modelId)
} else { } else {
selectedModels.put(modelId, models.first { it.id == modelId } ) selectedModels.put(modelId, sortedModels.first { it.id == modelId } )
} }
} else { } else {
// View model details // View model details

View File

@ -1,5 +1,7 @@
package com.example.llama.revamp.viewmodel package com.example.llama.revamp.viewmodel
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.llama.revamp.data.model.ModelInfo import com.example.llama.revamp.data.model.ModelInfo
@ -7,8 +9,11 @@ import com.example.llama.revamp.data.repository.ModelRepository
import com.example.llama.revamp.data.repository.StorageMetrics import com.example.llama.revamp.data.repository.StorageMetrics
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -17,52 +22,41 @@ class ModelsManagementViewModel @Inject constructor(
private val modelRepository: ModelRepository private val modelRepository: ModelRepository
) : ViewModel() { ) : ViewModel() {
// Sort order state val storageMetrics: StateFlow<StorageMetrics?> = modelRepository.getStorageMetrics()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = null
)
private val _availableModels: StateFlow<List<ModelInfo>> = modelRepository.getModels()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = emptyList()
)
private val _sortOrder = MutableStateFlow(ModelSortOrder.NAME_ASC) private val _sortOrder = MutableStateFlow(ModelSortOrder.NAME_ASC)
val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow() val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow()
// Available models private val _sortedModels = MutableStateFlow<List<ModelInfo>>(emptyList())
private val _availableModels = MutableStateFlow<List<ModelInfo>>(emptyList()) val sortedModels: StateFlow<List<ModelInfo>> = _sortedModels.asStateFlow()
val availableModels: StateFlow<List<ModelInfo>> = _availableModels.asStateFlow()
// Storage metrics
private val _storageMetrics = MutableStateFlow(StorageMetrics(0f, 0f))
val storageMetrics: StateFlow<StorageMetrics> = _storageMetrics.asStateFlow()
init { init {
// Initial data load
viewModelScope.launch { viewModelScope.launch {
loadModels() combine(_availableModels, _sortOrder, ::sortModels)
loadStorageMetrics() .collect { _sortedModels.value = it }
}
// Observe sort order changes and apply sorting
viewModelScope.launch {
sortOrder.collect { order -> sortModels(order) }
} }
} }
private fun loadModels() { private fun sortModels(models: List<ModelInfo>, order: ModelSortOrder) =
// TODO-han.yin: Stub for now. Would load from the repository when (order) {
_availableModels.value = ModelInfo.getSampleModels() ModelSortOrder.NAME_ASC -> models.sortedBy { it.name }
sortModels(_sortOrder.value) ModelSortOrder.NAME_DESC -> models.sortedByDescending { it.name }
} ModelSortOrder.SIZE_ASC -> models.sortedBy { it.sizeInBytes }
ModelSortOrder.SIZE_DESC -> models.sortedByDescending { it.sizeInBytes }
private fun loadStorageMetrics() { ModelSortOrder.LAST_USED -> models.sortedByDescending { it.lastUsed ?: 0 }
// TODO-han.yin: Stub for now. Would load from storage
_storageMetrics.value = StorageMetrics(14.6f, 32.0f)
}
private fun sortModels(order: ModelSortOrder) {
val sorted = when (order) {
ModelSortOrder.NAME_ASC -> _availableModels.value.sortedBy { it.name }
ModelSortOrder.NAME_DESC -> _availableModels.value.sortedByDescending { it.name }
ModelSortOrder.SIZE_ASC -> _availableModels.value.sortedBy { it.sizeInBytes }
ModelSortOrder.SIZE_DESC -> _availableModels.value.sortedByDescending { it.sizeInBytes }
ModelSortOrder.LAST_USED -> _availableModels.value.sortedByDescending { it.lastUsed ?: 0 }
} }
_availableModels.value = sorted
}
fun setSortOrder(order: ModelSortOrder) { fun setSortOrder(order: ModelSortOrder) {
_sortOrder.value = order _sortOrder.value = order
@ -72,43 +66,34 @@ class ModelsManagementViewModel @Inject constructor(
// TODO-han.yin: Stub for now. Would navigate to model details screen or show dialog // TODO-han.yin: Stub for now. Would navigate to model details screen or show dialog
} }
fun deleteModel(modelId: String) { fun deleteModel(modelId: String) =
// Remove model from list
_availableModels.value = _availableModels.value.filter { it.id != modelId }
viewModelScope.launch { viewModelScope.launch {
// TODO-han.yin: Stub for now this would delete from storage
modelRepository.deleteModel(modelId) modelRepository.deleteModel(modelId)
updateStorageMetrics()
} }
}
fun deleteModels(models: Map<String, ModelInfo>) {
val modelIds = models.keys
_availableModels.value = _availableModels.value.filter { !modelIds.contains(it.id) }
fun deleteModels(models: Map<String, ModelInfo>) =
viewModelScope.launch { viewModelScope.launch {
modelRepository.deleteModels(modelIds) modelRepository.deleteModels(models.keys)
updateStorageMetrics()
} }
}
fun importLocalModel() { fun importLocalModel(uri: Uri) =
// TODO-han.yin: Stub for now. Would open file picker and import model viewModelScope.launch {
} try {
modelRepository.importModel(uri)
} catch (e: Exception) {
// TODO-han.yin: add UI to prompt user about import failure!
Log.e(TAG, "Failed to import model from: $uri", e)
}
}
fun importFromHuggingFace() { fun importFromHuggingFace() {
// TODO-han.yin: Stub for now. Would need to investigate HuggingFace APIs // TODO-han.yin: Stub for now. Would need to investigate HuggingFace APIs
} }
private fun updateStorageMetrics() { companion object {
// Recalculate storage metrics after model changes private val TAG = ModelsManagementViewModel::class.java.simpleName
// TODO-han.yin: Stub for now. Would query actual storage
val totalSize = _availableModels.value.sumOf { it.sizeInBytes } private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
_storageMetrics.value = StorageMetrics(
(totalSize / 1_000_000_000.0).toFloat(),
32.0f
)
} }
} }