diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt index 051bf77a73..fa262ede9b 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt @@ -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 + fun getStorageMetrics(): Flow + fun getModels(): Flow> 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 { - // In a real implementation, this would load from database - return ModelInfo.getSampleModels() + override fun getStorageMetrics(): Flow = flow { + while (true) { + emit( + StorageMetrics( + usedGB = modelsSizeBytes / BYTES_IN_GB, + totalGB = availableSpaceBytes / BYTES_IN_GB + ) + ) + delay(STORAGE_METRICS_UPDATE_INTERVAL) + } } + override fun getModels(): Flow> = + 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) { - // 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 -} +) diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt index fc32f2ca18..e91bee69a7 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt @@ -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() } + // 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() } 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 diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt index 1e5b0ea0be..af108e5c69 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt @@ -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,52 +22,41 @@ class ModelsManagementViewModel @Inject constructor( private val modelRepository: ModelRepository ) : ViewModel() { - // Sort order state + val storageMetrics: StateFlow = modelRepository.getStorageMetrics() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS), + initialValue = null + ) + + private val _availableModels: StateFlow> = modelRepository.getModels() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS), + initialValue = emptyList() + ) + private val _sortOrder = MutableStateFlow(ModelSortOrder.NAME_ASC) val sortOrder: StateFlow = _sortOrder.asStateFlow() - // Available models - private val _availableModels = MutableStateFlow>(emptyList()) - val availableModels: StateFlow> = _availableModels.asStateFlow() - - // Storage metrics - private val _storageMetrics = MutableStateFlow(StorageMetrics(0f, 0f)) - val storageMetrics: StateFlow = _storageMetrics.asStateFlow() + private val _sortedModels = MutableStateFlow>(emptyList()) + val sortedModels: StateFlow> = _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 } + private fun sortModels(models: List, 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 } } - _availableModels.value = sorted - } fun setSortOrder(order: ModelSortOrder) { _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 } - 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) { - val modelIds = models.keys - _availableModels.value = _availableModels.value.filter { !modelIds.contains(it.id) } + fun deleteModels(models: Map) = 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 } }