UI: split the ModelsManagementViewModel from a unified ModelsViewModel due to huge complexity

This commit is contained in:
Han Yin 2025-08-30 17:58:05 -07:00
parent df16abe75e
commit a4881cb87b
11 changed files with 442 additions and 386 deletions

View File

@ -53,9 +53,10 @@ import com.example.llama.viewmodel.BenchmarkViewModel
import com.example.llama.viewmodel.ConversationViewModel
import com.example.llama.viewmodel.MainViewModel
import com.example.llama.viewmodel.ModelLoadingViewModel
import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsManagementViewModel
import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.SettingsViewModel
import com.example.llama.viewmodel.ModelScreenUiMode
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@ -85,10 +86,10 @@ fun AppContent(
settingsViewModel: SettingsViewModel,
mainViewModel: MainViewModel = hiltViewModel(),
modelsViewModel: ModelsViewModel = hiltViewModel(),
modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(),
benchmarkViewModel: BenchmarkViewModel = hiltViewModel(),
conversationViewModel: ConversationViewModel = hiltViewModel(),
// modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
) {
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
@ -191,19 +192,20 @@ fun AppContent(
// Model selection screen
currentRoute == AppDestinations.MODELS_ROUTE -> {
// Collect states for bottom bar
val filteredModels by modelsViewModel.filteredModels.collectAsState()
val sortOrder by modelsViewModel.sortOrder.collectAsState()
val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
val activeFilters by modelsViewModel.activeFilters.collectAsState()
val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState()
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
val selectedModelsToDelete by modelsViewModel.selectedModelsToDelete.collectAsState()
val showImportModelMenu by modelsViewModel.showImportModelMenu.collectAsState()
val selectedModelsToDelete by modelsManagementViewModel.selectedModelsToDelete.collectAsState()
val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
// Create file launcher for importing local models
val fileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { modelsViewModel.importLocalModelFileSelected(it) } }
) { uri -> uri?.let { modelsManagementViewModel.importLocalModelFileSelected(it) } }
ScaffoldConfig(
topBarConfig =
@ -231,6 +233,7 @@ fun AppContent(
TopBarConfig.ModelsDeleting(
title = "Deleting models",
navigationIcon = NavigationIcon.Quit {
modelsManagementViewModel.resetManagementState()
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
},
)
@ -309,14 +312,14 @@ fun AppContent(
),
importing = BottomBarConfig.Models.Management.ImportConfig(
isMenuVisible = showImportModelMenu,
toggleMenu = { show -> modelsViewModel.toggleImportMenu(show) },
toggleMenu = { show -> modelsManagementViewModel.toggleImportMenu(show) },
importFromLocal = {
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
modelsViewModel.toggleImportMenu(false)
modelsManagementViewModel.toggleImportMenu(false)
},
importFromHuggingFace = {
modelsViewModel.queryModelsFromHuggingFace()
modelsViewModel.toggleImportMenu(false)
modelsManagementViewModel.queryModelsFromHuggingFace()
modelsManagementViewModel.toggleImportMenu(false)
}
),
onToggleDeleting = {
@ -330,11 +333,16 @@ fun AppContent(
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
},
selectedModels = selectedModelsToDelete,
toggleAllSelection = { modelsViewModel.toggleAllSelectedModelsToDelete(it) },
selectAllFilteredModels = {
modelsManagementViewModel.selectAllFilteredModelsToDelete(filteredModels)
},
clearAllSelectedModels = {
modelsManagementViewModel.clearAllSelectedModelsToDelete()
},
deleteSelected = {
selectedModelsToDelete.let {
if (it.isNotEmpty()) {
modelsViewModel.batchDeletionClicked(it)
modelsManagementViewModel.batchDeletionClicked(it)
}
}
},
@ -464,7 +472,8 @@ fun AppContent(
}
},
onScaffoldEvent = handleScaffoldEvent,
viewModel = modelsViewModel
modelsViewModel = modelsViewModel,
managementViewModel = modelsManagementViewModel,
)
}

View File

@ -1,6 +1,7 @@
package com.example.llama.ui.scaffold
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
@ -77,6 +78,8 @@ fun AppScaffold(
is TopBarConfig.ModelsDeleting -> DefaultTopBar(
title = topBarconfig.title,
titleColor = MaterialTheme.colorScheme.error,
navigationIconTint = MaterialTheme.colorScheme.error,
onQuit = topBarconfig.navigationIcon.quitAction
)

View File

@ -78,7 +78,8 @@ sealed class BottomBarConfig {
data class Deleting(
val onQuitDeleting: () -> Unit,
val selectedModels: Map<String, ModelInfo>,
val toggleAllSelection: (Boolean) -> Unit,
val selectAllFilteredModels: () -> Unit,
val clearAllSelectedModels: () -> Unit,
val deleteSelected: () -> Unit
) : BottomBarConfig()

View File

@ -23,14 +23,14 @@ fun ModelsDeletingBottomBar(
) {
BottomAppBar(
actions = {
IconButton(onClick = { deleting.toggleAllSelection(false) }) {
IconButton(onClick = { deleting.clearAllSelectedModels() }) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
IconButton(onClick = { deleting.toggleAllSelection(true) }) {
IconButton(onClick = { deleting.selectAllFilteredModels() }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"

View File

@ -12,24 +12,30 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DefaultTopBar(
title: String,
titleColor: Color = Color.Unspecified,
navigationIconTint: Color = Color.Unspecified,
onNavigateBack: (() -> Unit)? = null,
onQuit: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null
) {
TopAppBar(
title = { Text(title) },
title = {
Text(text = title, color = titleColor)
},
navigationIcon = {
when {
onQuit != null -> {
IconButton(onClick = onQuit) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Quit"
contentDescription = "Quit",
tint = navigationIconTint
)
}
}
@ -38,7 +44,8 @@ fun DefaultTopBar(
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
contentDescription = "Back",
tint = navigationIconTint
)
}
}
@ -47,7 +54,8 @@ fun DefaultTopBar(
IconButton(onClick = onMenuOpen) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Menu"
contentDescription = "Menu",
tint = navigationIconTint
)
}
}

View File

@ -9,33 +9,23 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.llama.data.model.ModelInfo
import com.example.llama.ui.components.InfoAction
import com.example.llama.ui.components.InfoView
import com.example.llama.ui.components.ModelCardFullExpandable
import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.PreselectedModelToRun
@Composable
fun ModelsBrowsingScreen(
filteredModels: List<ModelInfo>,
activeFiltersCount: Int,
preselection: PreselectedModelToRun?,
onManageModelsClicked: () -> Unit,
viewModel: ModelsViewModel,
) {
// Data: models
val filteredModels by viewModel.filteredModels.collectAsState()
val preselection by viewModel.preselectedModelToRun.collectAsState()
// Filter states
val activeFilters by viewModel.activeFilters.collectAsState()
val activeFiltersCount by remember(activeFilters) {
derivedStateOf { activeFilters.count { it.value } }
}
if (filteredModels.isEmpty()) {
// Empty model prompt
EmptyModelsView(activeFiltersCount, onManageModelsClicked)
@ -63,7 +53,6 @@ fun ModelsBrowsingScreen(
}
}
@Composable
private fun EmptyModelsView(
activeFiltersCount: Int,

View File

@ -39,7 +39,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
@ -55,6 +54,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import com.example.llama.data.model.ModelFilter
import com.example.llama.data.model.ModelInfo
import com.example.llama.data.source.remote.HuggingFaceModel
import com.example.llama.ui.components.InfoAction
@ -68,6 +68,7 @@ import com.example.llama.viewmodel.ModelManagementState.Deletion
import com.example.llama.viewmodel.ModelManagementState.Download
import com.example.llama.viewmodel.ModelManagementState.Importation
import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsManagementViewModel
import com.example.llama.viewmodel.ModelsViewModel
import java.text.SimpleDateFormat
import java.util.Locale
@ -77,24 +78,18 @@ import java.util.Locale
*/
@Composable
fun ModelsManagementAndDeletingScreen(
filteredModels: List<ModelInfo>,
activeFiltersCount: Int,
isDeleting: Boolean,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
viewModel: ModelsViewModel,
modelsViewModel: ModelsViewModel,
managementViewModel: ModelsManagementViewModel,
) {
// Data: models
val filteredModels by viewModel.filteredModels.collectAsState()
// Selection state
val selectedModels by viewModel.selectedModelsToDelete.collectAsState()
// Filter state
val activeFilters by viewModel.activeFilters.collectAsState()
val activeFiltersCount by remember(activeFilters) {
derivedStateOf { activeFilters.count { it.value } }
}
val selectedModels by managementViewModel.selectedModelsToDelete.collectAsState()
// Model management state
val managementState by viewModel.managementState.collectAsState()
val managementState by managementViewModel.managementState.collectAsState()
// UI states
val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
@ -136,7 +131,7 @@ fun ModelsManagementAndDeletingScreen(
isSelected = isSelected,
onSelected = {
if (isDeleting) {
viewModel.toggleModelSelectionById(model.id)
managementViewModel.toggleModelSelectionById(filteredModels, model.id)
}
},
isExpanded = expandedModels.contains(model.id),
@ -161,11 +156,11 @@ fun ModelsManagementAndDeletingScreen(
isImporting = false,
progress = 0.0f,
onConfirm = {
viewModel.importLocalModelFileConfirmed(
managementViewModel.importLocalModelFileConfirmed(
state.uri, state.fileName, state.fileSize
)
},
onCancel = { viewModel.resetManagementState() }
onCancel = { managementViewModel.resetManagementState() }
)
}
@ -177,7 +172,7 @@ fun ModelsManagementAndDeletingScreen(
isCancelling = state.isCancelling,
progress = state.progress,
onConfirm = {},
onCancel = { viewModel.cancelOngoingLocalModelImport() },
onCancel = { managementViewModel.cancelOngoingLocalModelImport() },
)
}
@ -186,7 +181,7 @@ fun ModelsManagementAndDeletingScreen(
title = "Import Failed",
message = state.message,
learnMoreUrl = state.learnMoreUrl,
onDismiss = { viewModel.resetManagementState() }
onDismiss = { managementViewModel.resetManagementState() }
)
}
@ -198,21 +193,21 @@ fun ModelsManagementAndDeletingScreen(
)
)
viewModel.resetManagementState()
managementViewModel.resetManagementState()
}
}
is Download.Querying -> {
ImportFromHuggingFaceDialog(
onCancel = { viewModel.resetManagementState() }
onCancel = { managementViewModel.resetManagementState() }
)
}
is Download.Ready -> {
ImportFromHuggingFaceDialog(
models = state.models,
onConfirm = { viewModel.downloadHuggingFaceModelConfirmed(it) },
onCancel = { viewModel.resetManagementState() }
onConfirm = { managementViewModel.downloadHuggingFaceModelConfirmed(it) },
onCancel = { managementViewModel.resetManagementState() }
)
}
@ -225,7 +220,7 @@ fun ModelsManagementAndDeletingScreen(
)
)
viewModel.resetManagementState()
managementViewModel.resetManagementState()
}
}
@ -236,11 +231,11 @@ fun ModelsManagementAndDeletingScreen(
isImporting = false,
progress = 0.0f,
onConfirm = {
viewModel.importLocalModelFileConfirmed(
managementViewModel.importLocalModelFileConfirmed(
state.uri, state.fileName, state.fileSize
)
},
onCancel = { viewModel.resetManagementState() }
onCancel = { managementViewModel.resetManagementState() }
)
}
@ -248,15 +243,15 @@ fun ModelsManagementAndDeletingScreen(
ErrorDialog(
title = "Download Failed",
message = state.message,
onDismiss = { viewModel.resetManagementState() }
onDismiss = { managementViewModel.resetManagementState() }
)
}
is Deletion.Confirming -> {
BatchDeleteConfirmationDialog(
count = state.models.size,
onConfirm = { viewModel.deleteModels(state.models) },
onDismiss = { viewModel.resetManagementState() },
onConfirm = { managementViewModel.deleteModels(state.models) },
onDismiss = { managementViewModel.resetManagementState() },
isDeleting = false
)
}
@ -274,19 +269,20 @@ fun ModelsManagementAndDeletingScreen(
ErrorDialog(
title = "Deletion Failed",
message = state.message,
onDismiss = { viewModel.resetManagementState() }
onDismiss = { managementViewModel.resetManagementState() }
)
}
is Deletion.Success -> {
LaunchedEffect(state) {
viewModel.toggleMode(ModelScreenUiMode.MANAGING)
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
val count = state.models.size
onScaffoldEvent(
ScaffoldEvent.ShowSnackbar(
message = "Deleted $count ${if (count > 1) "models" else "model"}.",
duration = SnackbarDuration.Long
withDismissAction = true,
duration = SnackbarDuration.Long,
)
)
}

View File

@ -13,13 +13,16 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.example.llama.data.model.ModelInfo
import com.example.llama.ui.components.InfoView
import com.example.llama.ui.scaffold.ScaffoldEvent
import com.example.llama.util.formatFileByteSize
import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsManagementViewModel
import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
@ -29,31 +32,39 @@ fun ModelsScreen(
onManageModelsClicked: () -> Unit,
onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
viewModel: ModelsViewModel,
modelsViewModel: ModelsViewModel,
managementViewModel: ModelsManagementViewModel,
) {
// Data
val preselection by viewModel.preselectedModelToRun.collectAsState()
val filteredModels by modelsViewModel.filteredModels.collectAsState()
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
// UI states: Filter
val activeFilters by modelsViewModel.activeFilters.collectAsState()
val activeFiltersCount by remember(activeFilters) {
derivedStateOf { activeFilters.count { it.value } }
}
// UI states
val currentMode by viewModel.modelScreenUiMode.collectAsState()
val currentMode by modelsViewModel.modelScreenUiMode.collectAsState()
// Handle back button press
BackHandler {
when (currentMode) {
ModelScreenUiMode.BROWSING -> {
if (preselection != null) {
viewModel.resetPreselection()
modelsViewModel.resetPreselection()
}
}
ModelScreenUiMode.SEARCHING -> {
viewModel.toggleMode(ModelScreenUiMode.BROWSING)
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
}
ModelScreenUiMode.MANAGING -> {
viewModel.toggleMode(ModelScreenUiMode.BROWSING)
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
}
ModelScreenUiMode.DELETING -> {
viewModel.toggleAllSelectedModelsToDelete(false)
viewModel.toggleMode(ModelScreenUiMode.MANAGING)
managementViewModel.clearAllSelectedModelsToDelete()
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
}
}
}
@ -64,16 +75,25 @@ fun ModelsScreen(
when (currentMode) {
ModelScreenUiMode.BROWSING ->
ModelsBrowsingScreen(
filteredModels = filteredModels,
preselection = preselection,
onManageModelsClicked = { /* TODO-han.yin */ },
viewModel = viewModel
activeFiltersCount = activeFiltersCount,
viewModel = modelsViewModel,
)
ModelScreenUiMode.SEARCHING ->
ModelsSearchingScreen(viewModel = viewModel)
ModelsSearchingScreen(
preselection = preselection,
viewModel = modelsViewModel
)
ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING ->
ModelsManagementAndDeletingScreen(
filteredModels = filteredModels,
isDeleting = currentMode == ModelScreenUiMode.DELETING,
onScaffoldEvent = onScaffoldEvent,
viewModel = viewModel
activeFiltersCount = activeFiltersCount,
modelsViewModel = modelsViewModel,
managementViewModel = managementViewModel,
)
}
@ -83,7 +103,7 @@ fun ModelsScreen(
if (warning.showing) {
RamErrorDialog(
warning,
onDismiss = { viewModel.dismissRamWarning() },
onDismiss = { modelsViewModel.dismissRamWarning() },
onConfirm = { onConfirmSelection(it.modelInfo, warning) }
)
}

View File

@ -39,15 +39,14 @@ import androidx.compose.ui.unit.dp
import com.example.llama.ui.components.ModelCardFullExpandable
import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.PreselectedModelToRun
@ExperimentalMaterial3Api
@Composable
fun ModelsSearchingScreen(
preselection: PreselectedModelToRun?,
viewModel: ModelsViewModel,
) {
val preselection by viewModel.preselectedModelToRun.collectAsState()
// Query states
val textFieldState = viewModel.searchFieldState
val searchQuery by remember(textFieldState) {
@ -68,7 +67,7 @@ fun ModelsSearchingScreen(
}
}
// TODO-han.yin: remove after validation
// LaunchedEffect (isSearchActive) {
// if (isSearchActive) {
// toggleSearchFocusAndIme(true)

View File

@ -0,0 +1,335 @@
package com.example.llama.viewmodel
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.llama.cpp.gguf.InvalidFileFormatException
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.llama.data.model.ModelInfo
import com.example.llama.data.repo.InsufficientStorageException
import com.example.llama.data.repo.ModelRepository
import com.example.llama.data.source.remote.HuggingFaceDownloadInfo
import com.example.llama.data.source.remote.HuggingFaceModel
import com.example.llama.util.formatFileByteSize
import com.example.llama.util.getFileNameFromUri
import com.example.llama.util.getFileSizeFromUri
import com.example.llama.viewmodel.ModelManagementState.Deletion
import com.example.llama.viewmodel.ModelManagementState.Download
import com.example.llama.viewmodel.ModelManagementState.Importation
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@HiltViewModel
class ModelsManagementViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val modelRepository: ModelRepository,
) : ViewModel() {
// UI state: models selected to be batch-deleted
private val _selectedModelsToDelete = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
val selectedModelsToDelete: StateFlow<Map<String, ModelInfo>> = _selectedModelsToDelete.asStateFlow()
fun toggleModelSelectionById(filteredModels: List<ModelInfo>, modelId: String) {
val current = _selectedModelsToDelete.value.toMutableMap()
val model = filteredModels.find { it.id == modelId }
if (model != null) {
if (current.containsKey(modelId)) {
current.remove(modelId)
} else {
current[modelId] = model
}
_selectedModelsToDelete.value = current
}
}
fun selectAllFilteredModelsToDelete(filteredModels: List<ModelInfo>) {
_selectedModelsToDelete.value = filteredModels.associateBy { it.id }
}
fun clearAllSelectedModelsToDelete() {
_selectedModelsToDelete.value = emptyMap()
}
// UI state: import menu
private val _showImportModelMenu = MutableStateFlow(false)
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
fun toggleImportMenu(show: Boolean) {
_showImportModelMenu.value = show
}
// HuggingFace: ongoing query jobs
private var huggingFaceQueryJob: Job? = null
// HuggingFace: Ongoing download jobs
private val activeDownloads = mutableMapOf<Long, HuggingFaceModel>()
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1).let { id ->
if (id in activeDownloads) {
handleDownloadComplete(id)
}
}
}
}
// Internal state
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
fun resetManagementState() {
huggingFaceQueryJob?.let {
if (it.isActive) { it.cancel() }
}
clearAllSelectedModelsToDelete()
_managementState.value = ModelManagementState.Idle
}
init {
context.registerReceiver(
downloadReceiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
RECEIVER_EXPORTED
)
}
/**
* First show confirmation instead of starting import local file immediately
*/
fun importLocalModelFileSelected(uri: Uri) = viewModelScope.launch {
try {
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
_managementState.value = Importation.Confirming(uri, fileName, fileSize)
} catch (e: Exception) {
_managementState.value = Importation.Error(
message = e.message ?: "Unknown error preparing import"
)
}
}
/**
* Import a local model file from device storage while updating UI states with realtime progress
*/
fun importLocalModelFileConfirmed(uri: Uri, fileName: String, fileSize: Long) = viewModelScope.launch {
try {
_managementState.value = Importation.Importing(0f, fileName, fileSize)
val model = modelRepository.importModel(uri, fileName, fileSize) { progress ->
_managementState.value = Importation.Importing(progress, fileName, fileSize)
}
_managementState.value = Importation.Success(model)
} catch (_: InvalidFileFormatException) {
_managementState.value = Importation.Error(
message = "Not a valid GGUF model!",
learnMoreUrl = "https://huggingface.co/docs/hub/en/gguf",
)
} catch (e: InsufficientStorageException) {
_managementState.value = Importation.Error(
message = e.message ?: "Insufficient storage space to import $fileName",
learnMoreUrl = "https://support.google.com/android/answer/7431795?hl=en",
)
} catch (e: Exception) {
Log.e(TAG, "Unknown exception importing $fileName", e)
_managementState.value = Importation.Error(
message = e.message ?: "Unknown error importing $fileName",
)
}
}
fun cancelOngoingLocalModelImport() = viewModelScope.launch {
viewModelScope.launch {
// First update UI to show we're attempting to cancel
_managementState.update { current ->
if (current is Importation.Importing) {
current.copy(isCancelling = true)
} else {
current
}
}
// Attempt to cancel
when (modelRepository.cancelImport()) {
null, true -> { _managementState.value = ModelManagementState.Idle }
false -> {
_managementState.value = Importation.Error(
message = "Failed to cancel import. Try again later."
)
}
}
}
}
/**
* Query models on HuggingFace available for download even without signing in
*/
fun queryModelsFromHuggingFace() {
huggingFaceQueryJob = viewModelScope.launch {
_managementState.emit(Download.Querying)
try {
modelRepository.searchHuggingFaceModels().fold(
onSuccess = { models ->
Log.d(TAG, "Fetched ${models.size} models from HuggingFace:")
_managementState.emit(Download.Ready(models))
},
onFailure = { throw it }
)
} catch (_: CancellationException) {
// no-op
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (_: FileNotFoundException) {
_managementState.emit(Download.Error(message = "No eligible models"))
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (e: Exception) {
_managementState.emit(Download.Error(message = e.message ?: "Unknown error"))
}
}
}
/**
* Dispatch download request to [DownloadManager] and update UI
*/
fun downloadHuggingFaceModelConfirmed(model: HuggingFaceModel) = viewModelScope.launch {
try {
require(!model.gated) { "Model is gated!" }
require(!model.private) { "Model is private!" }
val downloadInfo = model.toDownloadInfo()
requireNotNull(downloadInfo) { "Download URL is missing!" }
modelRepository.getHuggingFaceModelFileSize(downloadInfo).fold(
onSuccess = { actualSize ->
Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}")
modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize)
.onSuccess { downloadId ->
activeDownloads[downloadId] = model
_managementState.value = Download.Dispatched(downloadInfo)
}
.onFailure { throw it }
},
onFailure = { throw it }
)
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (e: InsufficientStorageException) {
_managementState.value = Download.Error(
message = e.message ?: "Insufficient storage space to download ${model.modelId}",
)
} catch (e: Exception) {
_managementState.value = Download.Error(
message = e.message ?: "Unknown error downloading ${model.modelId}",
)
}
}
private fun handleDownloadComplete(downloadId: Long) = viewModelScope.launch {
val model = activeDownloads.remove(downloadId) ?: return@launch
(context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager)
.getUriForDownloadedFile(downloadId)?.let { uri ->
try {
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
_managementState.emit(Download.Completed(model, uri, fileName, fileSize))
} catch (e: Exception) {
_managementState.value = Download.Error(
message = e.message ?: "Unknown error downloading ${model.modelId}"
)
}
}
}
/**
* First show confirmation instead of starting deletion immediately
*/
fun batchDeletionClicked(models: Map<String, ModelInfo>) {
_managementState.value = Deletion.Confirming(models)
}
/**
* Delete multiple models one by one while updating UI states with realtime progress
*/
fun deleteModels(modelsToDelete: Map<String, ModelInfo>) = viewModelScope.launch {
val total = modelsToDelete.size
if (total == 0) return@launch
try {
_managementState.value = Deletion.Deleting(0f, modelsToDelete)
var deleted = 0
modelsToDelete.keys.toList().forEach {
modelRepository.deleteModel(it)
deleted++
_managementState.value = Deletion.Deleting(deleted.toFloat() / total, modelsToDelete)
}
_managementState.value = Deletion.Success(modelsToDelete.values.toList())
clearAllSelectedModelsToDelete()
// Reset state after a delay
delay(DELETE_SUCCESS_RESET_TIMEOUT_MS)
_managementState.value = ModelManagementState.Idle
} catch (e: Exception) {
_managementState.value = Deletion.Error(
message = e.message ?: "Error deleting $total models"
)
}
}
companion object {
private val TAG = ModelsManagementViewModel::class.java.simpleName
private const val DELETE_SUCCESS_RESET_TIMEOUT_MS = 1000L
}
}
sealed class ModelManagementState {
object Idle : ModelManagementState()
sealed class Importation : ModelManagementState() {
data class Confirming(val uri: Uri, val fileName: String, val fileSize: Long) : Importation()
data class Importing(val progress: Float = 0f, val fileName: String, val fileSize: Long, val isCancelling: Boolean = false) : Importation()
data class Success(val model: ModelInfo) : Importation()
data class Error(val message: String, val learnMoreUrl: String? = null) : Importation()
}
sealed class Download: ModelManagementState() {
object Querying : Download()
data class Ready(val models: List<HuggingFaceModel>) : Download()
data class Dispatched(val downloadInfo: HuggingFaceDownloadInfo) : Download()
data class Completed(val model: HuggingFaceModel, val uri: Uri, val fileName: String, val fileSize: Long) : Download()
data class Error(val message: String) : Download()
}
sealed class Deletion : ModelManagementState() {
data class Confirming(val models: Map<String, ModelInfo>): ModelManagementState()
data class Deleting(val progress: Float = 0f, val models: Map<String, ModelInfo>) : ModelManagementState()
data class Success(val models: List<ModelInfo>) : Deletion()
data class Error(val message: String) : Deletion()
}
}

View File

@ -1,14 +1,5 @@
package com.example.llama.viewmodel
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.llama.cpp.gguf.InvalidFileFormatException
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.snapshotFlow
@ -20,24 +11,12 @@ import com.example.llama.data.model.ModelSortOrder
import com.example.llama.data.model.filterBy
import com.example.llama.data.model.queryBy
import com.example.llama.data.model.sortByOrder
import com.example.llama.data.repo.InsufficientStorageException
import com.example.llama.data.repo.ModelRepository
import com.example.llama.data.source.remote.HuggingFaceDownloadInfo
import com.example.llama.data.source.remote.HuggingFaceModel
import com.example.llama.engine.InferenceService
import com.example.llama.monitoring.PerformanceMonitor
import com.example.llama.util.formatFileByteSize
import com.example.llama.util.getFileNameFromUri
import com.example.llama.util.getFileSizeFromUri
import com.example.llama.viewmodel.ModelManagementState.Deletion
import com.example.llama.viewmodel.ModelManagementState.Download
import com.example.llama.viewmodel.ModelManagementState.Importation
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -48,18 +27,12 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@OptIn(FlowPreview::class)
@HiltViewModel
class ModelsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val modelRepository: ModelRepository,
private val performanceMonitor: PerformanceMonitor,
private val inferenceService: InferenceService,
@ -185,65 +158,6 @@ class ModelsViewModel @Inject constructor(
initialValue = null
)
// UI state: models selected in deleting mode
private val _selectedModelsToDelete = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
val selectedModelsToDelete: StateFlow<Map<String, ModelInfo>> = _selectedModelsToDelete.asStateFlow()
fun toggleModelSelectionById(modelId: String) {
val current = _selectedModelsToDelete.value.toMutableMap()
val model = _filteredModels.value.find { it.id == modelId }
if (model != null) {
if (current.containsKey(modelId)) {
current.remove(modelId)
} else {
current[modelId] = model
}
_selectedModelsToDelete.value = current
}
}
fun toggleAllSelectedModelsToDelete(selectAll: Boolean) {
if (selectAll) {
_selectedModelsToDelete.value = _filteredModels.value.associateBy { it.id }
} else {
_selectedModelsToDelete.value = emptyMap()
}
}
// UI state: import menu
private val _showImportModelMenu = MutableStateFlow(false)
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
fun toggleImportMenu(show: Boolean) {
_showImportModelMenu.value = show
}
// HuggingFace: ongoing query jobs
private var huggingFaceQueryJob: Job? = null
// HuggingFace: Ongoing download jobs
private val activeDownloads = mutableMapOf<Long, HuggingFaceModel>()
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1).let { id ->
if (id in activeDownloads) {
handleDownloadComplete(id)
}
}
}
}
// Internal state
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
fun resetManagementState() {
huggingFaceQueryJob?.let {
if (it.isActive) { it.cancel() }
}
_managementState.value = ModelManagementState.Idle
}
init {
viewModelScope.launch {
@ -272,9 +186,6 @@ class ModelsViewModel @Inject constructor(
_queryResults.value = it
}
}
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
context.registerReceiver(downloadReceiver, filter, RECEIVER_EXPORTED)
}
/**
@ -335,193 +246,6 @@ class ModelsViewModel @Inject constructor(
false
}
/**
* First show confirmation instead of starting import local file immediately
*/
fun importLocalModelFileSelected(uri: Uri) = viewModelScope.launch {
try {
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
_managementState.value = Importation.Confirming(uri, fileName, fileSize)
} catch (e: Exception) {
_managementState.value = Importation.Error(
message = e.message ?: "Unknown error preparing import"
)
}
}
/**
* Import a local model file from device storage while updating UI states with realtime progress
*/
fun importLocalModelFileConfirmed(uri: Uri, fileName: String, fileSize: Long) = viewModelScope.launch {
try {
_managementState.value = Importation.Importing(0f, fileName, fileSize)
val model = modelRepository.importModel(uri, fileName, fileSize) { progress ->
_managementState.value = Importation.Importing(progress, fileName, fileSize)
}
_managementState.value = Importation.Success(model)
} catch (_: InvalidFileFormatException) {
_managementState.value = Importation.Error(
message = "Not a valid GGUF model!",
learnMoreUrl = "https://huggingface.co/docs/hub/en/gguf",
)
} catch (e: InsufficientStorageException) {
_managementState.value = Importation.Error(
message = e.message ?: "Insufficient storage space to import $fileName",
learnMoreUrl = "https://support.google.com/android/answer/7431795?hl=en",
)
} catch (e: Exception) {
Log.e(TAG, "Unknown exception importing $fileName", e)
_managementState.value = Importation.Error(
message = e.message ?: "Unknown error importing $fileName",
)
}
}
fun cancelOngoingLocalModelImport() = viewModelScope.launch {
viewModelScope.launch {
// First update UI to show we're attempting to cancel
_managementState.update { current ->
if (current is Importation.Importing) {
current.copy(isCancelling = true)
} else {
current
}
}
// Attempt to cancel
when (modelRepository.cancelImport()) {
null, true -> { _managementState.value = ModelManagementState.Idle }
false -> {
_managementState.value = Importation.Error(
message = "Failed to cancel import. Try again later."
)
}
}
}
}
/**
* Query models on HuggingFace available for download even without signing in
*/
fun queryModelsFromHuggingFace() {
huggingFaceQueryJob = viewModelScope.launch {
_managementState.emit(Download.Querying)
try {
modelRepository.searchHuggingFaceModels().fold(
onSuccess = { models ->
Log.d(TAG, "Fetched ${models.size} models from HuggingFace:")
_managementState.emit(Download.Ready(models))
},
onFailure = { throw it }
)
} catch (_: CancellationException) {
// no-op
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (_: FileNotFoundException) {
_managementState.emit(Download.Error(message = "No eligible models"))
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (e: Exception) {
_managementState.emit(Download.Error(message = e.message ?: "Unknown error"))
}
}
}
/**
* Dispatch download request to [DownloadManager] and update UI
*/
fun downloadHuggingFaceModelConfirmed(model: HuggingFaceModel) = viewModelScope.launch {
try {
require(!model.gated) { "Model is gated!" }
require(!model.private) { "Model is private!" }
val downloadInfo = model.toDownloadInfo()
requireNotNull(downloadInfo) { "Download URL is missing!" }
modelRepository.getHuggingFaceModelFileSize(downloadInfo).fold(
onSuccess = { actualSize ->
Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}")
modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize)
.onSuccess { downloadId ->
activeDownloads[downloadId] = model
_managementState.value = Download.Dispatched(downloadInfo)
}
.onFailure { throw it }
},
onFailure = { throw it }
)
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (e: InsufficientStorageException) {
_managementState.value = Download.Error(
message = e.message ?: "Insufficient storage space to download ${model.modelId}",
)
} catch (e: Exception) {
_managementState.value = Download.Error(
message = e.message ?: "Unknown error downloading ${model.modelId}",
)
}
}
private fun handleDownloadComplete(downloadId: Long) = viewModelScope.launch {
val model = activeDownloads.remove(downloadId) ?: return@launch
(context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager)
.getUriForDownloadedFile(downloadId)?.let { uri ->
try {
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
_managementState.emit(Download.Completed(model, uri, fileName, fileSize))
} catch (e: Exception) {
_managementState.value = Download.Error(
message = e.message ?: "Unknown error downloading ${model.modelId}"
)
}
}
}
/**
* First show confirmation instead of starting deletion immediately
*/
fun batchDeletionClicked(models: Map<String, ModelInfo>) {
_managementState.value = Deletion.Confirming(models)
}
/**
* Delete multiple models one by one while updating UI states with realtime progress
*/
fun deleteModels(models: Map<String, ModelInfo>) = viewModelScope.launch {
val total = models.size
if (total == 0) return@launch
try {
_managementState.value = Deletion.Deleting(0f, models)
var deleted = 0
models.keys.toList().forEach {
modelRepository.deleteModel(it)
deleted++
_managementState.value = Deletion.Deleting(deleted.toFloat() / total, models)
}
_managementState.value = Deletion.Success(models.values.toList())
toggleAllSelectedModelsToDelete(false)
// Reset state after a delay
delay(DELETE_SUCCESS_RESET_TIMEOUT_MS)
_managementState.value = ModelManagementState.Idle
} catch (e: Exception) {
_managementState.value = Deletion.Error(
message = e.message ?: "Error deleting $total models"
)
}
}
companion object {
private val TAG = ModelsViewModel::class.java.simpleName
@ -529,8 +253,6 @@ class ModelsViewModel @Inject constructor(
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
private const val DELETE_SUCCESS_RESET_TIMEOUT_MS = 1000L
private const val RAM_LOAD_MODEL_BUFFER_BYTES = 300 * 1024
}
}
@ -552,29 +274,3 @@ data class PreselectedModelToRun(
val showing: Boolean,
)
}
sealed class ModelManagementState {
object Idle : ModelManagementState()
sealed class Importation : ModelManagementState() {
data class Confirming(val uri: Uri, val fileName: String, val fileSize: Long) : Importation()
data class Importing(val progress: Float = 0f, val fileName: String, val fileSize: Long, val isCancelling: Boolean = false) : Importation()
data class Success(val model: ModelInfo) : Importation()
data class Error(val message: String, val learnMoreUrl: String? = null) : Importation()
}
sealed class Download: ModelManagementState() {
object Querying : Download()
data class Ready(val models: List<HuggingFaceModel>) : Download()
data class Dispatched(val downloadInfo: HuggingFaceDownloadInfo) : Download()
data class Completed(val model: HuggingFaceModel, val uri: Uri, val fileName: String, val fileSize: Long) : Download()
data class Error(val message: String) : Download()
}
sealed class Deletion : ModelManagementState() {
data class Confirming(val models: Map<String, ModelInfo>): ModelManagementState()
data class Deleting(val progress: Float = 0f, val models: Map<String, ModelInfo>) : ModelManagementState()
data class Success(val models: List<ModelInfo>) : Deletion()
data class Error(val message: String) : Deletion()
}
}