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.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 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.Singleton
/**
* Repository for managing available models.
* Repository for managing available models on local device.
*/
interface ModelRepository {
// TODO-han.yin: change to Flow for getters
suspend fun getStorageMetrics(): StorageMetrics
suspend fun getModels(): List<ModelInfo>
fun getStorageMetrics(): Flow<StorageMetrics>
fun getModels(): Flow<List<ModelInfo>>
suspend fun importModel(uri: Uri): ModelInfo
suspend fun deleteModel(modelId: String)
@ -23,40 +35,163 @@ interface ModelRepository {
@Singleton
class ModelRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
// TODO-han.yin: Add model DAO
private val modelDao: ModelDao,
) : ModelRepository {
override suspend fun getStorageMetrics(): StorageMetrics {
// Stub - would calculate from actual storage
return StorageMetrics(14.6f, 32.0f)
private val modelsDir = File(context.filesDir, INTERNAL_STORAGE_PATH)
init {
if (!modelsDir.exists()) { modelsDir.mkdirs() }
}
override suspend fun getModels(): List<ModelInfo> {
// In a real implementation, this would load from database
return ModelInfo.getSampleModels()
override fun getStorageMetrics(): Flow<StorageMetrics> = flow {
while (true) {
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 {
// Stub - would copy file and extract metadata
return ModelInfo.getSampleModels().first()
// Obtain the local model's file via provided URI
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) {
// 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>) {
// 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(
val usedGB: 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,
viewModel: ModelsManagementViewModel = hiltViewModel()
) {
// For demo purposes, we'll use sample models
val models by viewModel.availableModels.collectAsState()
val sortedModels by viewModel.sortedModels.collectAsState()
val storageMetrics by viewModel.storageMetrics.collectAsState()
// UI states
var isMultiSelectionMode by remember { mutableStateOf(false) }
val selectedModels = remember { mutableStateMapOf<String, ModelInfo>() }
// UI: menu states
var showSortMenu 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 = {
isMultiSelectionMode = false
selectedModels.clear()
@ -80,8 +80,8 @@ fun ModelsManagementScreen(
StorageAppScaffold(
title = "Models Management",
storageUsed = storageMetrics.usedGB,
storageTotal = storageMetrics.totalGB,
storageUsed = storageMetrics?.usedGB ?: 0f,
storageTotal = storageMetrics?.totalGB ?: 0f,
onNavigateBack = onBackPressed,
bottomBar = {
BottomAppBar(
@ -90,7 +90,7 @@ fun ModelsManagementScreen(
// Multi-selection mode actions
IconButton(onClick = {
// Select all
selectedModels.putAll(models.map { it.id to it })
selectedModels.putAll(sortedModels.map { it.id to it })
}) {
Icon(
imageVector = Icons.Default.SelectAll,
@ -226,7 +226,8 @@ fun ModelsManagementScreen(
)
},
onClick = {
viewModel.importLocalModel()
// TODO-han.yin: uncomment once file picker done
// viewModel.importLocalModel()
showAddModelMenu = false
}
)
@ -252,7 +253,7 @@ fun ModelsManagementScreen(
) { paddingValues ->
// Main content
ModelList(
models = models,
models = sortedModels,
isMultiSelectionMode = isMultiSelectionMode,
selectedModels = selectedModels,
onModelClick = { modelId ->
@ -261,7 +262,7 @@ fun ModelsManagementScreen(
if (selectedModels.contains(modelId)) {
selectedModels.remove(modelId)
} else {
selectedModels.put(modelId, models.first { it.id == modelId } )
selectedModels.put(modelId, sortedModels.first { it.id == modelId } )
}
} else {
// View model details

View File

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