package sh.sar.isodroid.viewmodel import android.app.Application import android.os.Environment import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import sh.sar.isodroid.data.IsoFile import sh.sar.isodroid.data.MountOptions import sh.sar.isodroid.data.MountStatus import sh.sar.isodroid.isodrive.CreateImgEvent import sh.sar.isodroid.isodrive.CreateImgEventBus import sh.sar.isodroid.isodrive.IsoDriveManager import sh.sar.isodroid.isodrive.MountEvent import sh.sar.isodroid.isodrive.MountEventBus import sh.sar.isodroid.isodrive.SupportStatus import sh.sar.isodroid.notification.NotificationHelper import sh.sar.isodroid.root.RootManager import sh.sar.isodroid.ui.components.CreateImgOptions import sh.sar.isodroid.ui.components.CreateImgProgress import java.io.File private val Application.dataStore: DataStore by preferencesDataStore(name = "settings") class MainViewModel(application: Application) : AndroidViewModel(application) { private val isoDriveManager = IsoDriveManager.getInstance(application) private val notificationHelper = NotificationHelper.getInstance(application) private val dataStore = application.dataStore private val _uiState = MutableStateFlow(MainUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var navigationStack = mutableListOf() private var currentCreateImgFileName = "" companion object { private val KEY_ISO_DIRECTORY = stringPreferencesKey("iso_directory") private val DEFAULT_ISO_DIRECTORY = "${Environment.getExternalStorageDirectory().absolutePath}/isodrive" } private var initialized = false init { // Observe mount events from notification actions viewModelScope.launch { MountEventBus.events.collect { event -> when (event) { is MountEvent.Unmounted -> { checkMountStatus() _uiState.update { it.copy(successMessage = "Unmounted successfully") } } is MountEvent.Mounted -> { checkMountStatus() } } } } // Observe create img events viewModelScope.launch { CreateImgEventBus.events.collect { event -> when (event) { is CreateImgEvent.Progress -> { val progress = event.bytesWritten.toFloat() / event.totalBytes.toFloat() _uiState.update { it.copy( createImgProgress = CreateImgProgress( isCreating = true, fileName = currentCreateImgFileName, progress = progress, bytesWritten = event.bytesWritten, totalBytes = event.totalBytes ) ) } notificationHelper.showCreateProgressNotification( currentCreateImgFileName, (progress * 100).toInt(), event.bytesWritten, event.totalBytes ) } is CreateImgEvent.Complete -> { _uiState.update { it.copy( createImgProgress = CreateImgProgress(), successMessage = if (event.success) "Image created successfully" else "Failed to create image" ) } notificationHelper.showCreateCompleteNotification( event.filePath?.substringAfterLast("/") ?: "image.img", event.success ) if (event.success) { loadFiles() } } is CreateImgEvent.Cancelled -> { _uiState.update { it.copy( createImgProgress = CreateImgProgress(), successMessage = "Image creation cancelled" ) } notificationHelper.hideProgressNotification() } } } } } fun initialize() { if (initialized) return initialized = true viewModelScope.launch { doInitialize() } } private suspend fun doInitialize() { _uiState.update { it.copy(isLoading = true) } // Load saved ISO directory val savedDirectory = dataStore.data.map { preferences -> preferences[KEY_ISO_DIRECTORY] ?: DEFAULT_ISO_DIRECTORY }.first() _uiState.update { it.copy(isoDirectory = savedDirectory, currentPath = savedDirectory) } navigationStack.add(savedDirectory) // First check cached root status (no popup) - user may have granted in Magisk settings val cachedRootStatus = RootManager.isRootGrantedCached() val hasRoot = when { // If cached status says granted, root is available cachedRootStatus == true -> true // If cached status says denied, check if user wants to retry cachedRootStatus == false -> false // If unknown, check preference from wizard else -> { val prefs = getApplication() .getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) val rootGrantedInWizard = prefs.getBoolean("root_granted", false) if (rootGrantedInWizard) { RootManager.hasRoot() } else { false } } } _uiState.update { it.copy(hasRoot = hasRoot) } if (hasRoot) { // Initialize isodrive manager isoDriveManager.initialize() // Check device support val supportStatus = isoDriveManager.isSupported() val isSupported = supportStatus == SupportStatus.CONFIGFS_SUPPORTED || supportStatus == SupportStatus.SYSFS_SUPPORTED _uiState.update { it.copy(isSupported = isSupported) } if (isSupported) { // Load files and check mount status loadFiles() checkMountStatus() } } _uiState.update { it.copy(isLoading = false) } } fun refresh() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } loadFiles() checkMountStatus() _uiState.update { it.copy(isLoading = false) } } } private suspend fun loadFiles() { val currentPath = _uiState.value.currentPath val directory = File(currentPath) // Create directory if it doesn't exist if (!directory.exists()) { RootManager.executeCommand("mkdir -p \"$currentPath\"") } // Try multiple methods to list files val files = loadFilesViaFind(currentPath) ?: loadFilesViaLs(currentPath) ?: loadFilesDirect(directory) _uiState.update { it.copy(isoFiles = files) } } private suspend fun loadFilesViaFind(currentPath: String): List? { // Use find command - more reliable for getting full paths val result = RootManager.executeCommand( "find \"$currentPath\" -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null" ) if (!result.success || result.output.isBlank()) return null return result.output.lines() .filter { it.isNotBlank() } .mapNotNull { filePath -> val file = File(filePath.trim()) val name = file.name // Get file size via stat val sizeResult = RootManager.executeCommand("stat -c %s \"$filePath\" 2>/dev/null") val size = sizeResult.output.trim().toLongOrNull() ?: 0L IsoFile( path = filePath.trim(), name = name, size = size ) } .sortedBy { it.name.lowercase() } .takeIf { it.isNotEmpty() } } private suspend fun loadFilesViaLs(currentPath: String): List? { // Simple ls command - just get filenames val result = RootManager.executeCommand( "ls \"$currentPath\" 2>/dev/null" ) if (!result.success || result.output.isBlank()) return null return result.output.lines() .filter { name -> name.isNotBlank() && (name.endsWith(".iso", true) || name.endsWith(".img", true)) } .mapNotNull { name -> val filePath = "$currentPath/$name" // Get file size via stat val sizeResult = RootManager.executeCommand("stat -c %s \"$filePath\" 2>/dev/null") val size = sizeResult.output.trim().toLongOrNull() ?: 0L IsoFile( path = filePath, name = name.trim(), size = size ) } .sortedBy { it.name.lowercase() } .takeIf { it.isNotEmpty() } } private fun loadFilesDirect(directory: File): List { // Fallback to direct file access (works if app has storage permission) return directory.listFiles() ?.filter { file -> file.isFile && (file.name.endsWith(".iso", true) || file.name.endsWith(".img", true)) } ?.map { IsoFile.fromFile(it) } ?.sortedBy { it.name.lowercase() } ?: emptyList() } private suspend fun checkMountStatus() { val status = isoDriveManager.getStatus() _uiState.update { it.copy(mountStatus = status) } updateNotification(status) } private fun updateNotification(status: MountStatus) { if (status.mounted) { notificationHelper.showMountedNotification(status) } else { notificationHelper.hideNotification() } } suspend fun mount(path: String, options: MountOptions) { _uiState.update { it.copy(isLoading = true) } val result = isoDriveManager.mount(path, options) if (result.success) { checkMountStatus() _uiState.update { it.copy(successMessage = result.message, isLoading = false) } } else { _uiState.update { it.copy(errorMessage = result.message, isLoading = false) } } } suspend fun unmount() { _uiState.update { it.copy(isLoading = true) } val result = isoDriveManager.unmount() if (result.success) { checkMountStatus() _uiState.update { it.copy(successMessage = result.message, isLoading = false) } } else { _uiState.update { it.copy(errorMessage = result.message, isLoading = false) } } } fun setIsoDirectory(path: String) { viewModelScope.launch { dataStore.edit { preferences -> preferences[KEY_ISO_DIRECTORY] = path } navigationStack.clear() navigationStack.add(path) _uiState.update { it.copy(isoDirectory = path, currentPath = path) } loadFiles() } } fun navigateUp() { if (navigationStack.size > 1) { navigationStack.removeAt(navigationStack.lastIndex) val parentPath = navigationStack.last() _uiState.update { it.copy(currentPath = parentPath) } viewModelScope.launch { loadFiles() } } } fun canNavigateUp(): Boolean { return navigationStack.size > 1 } fun requestRootAccess() { viewModelScope.launch { val granted = RootManager.requestRoot() if (granted) { // Save to preferences getApplication() .getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) .edit() .putBoolean("root_granted", true) .apply() // Re-initialize with root _uiState.update { it.copy(hasRoot = true, rootDenied = false) } isoDriveManager.initialize() val supportStatus = isoDriveManager.isSupported() val isSupported = supportStatus == SupportStatus.CONFIGFS_SUPPORTED || supportStatus == SupportStatus.SYSFS_SUPPORTED _uiState.update { it.copy(isSupported = isSupported) } if (isSupported) { loadFiles() checkMountStatus() } _uiState.update { it.copy(successMessage = "Root access granted") } } else { // User denied root access _uiState.update { it.copy(rootDenied = true) } } } } fun refreshRootStatus() { viewModelScope.launch { // Check cached status (no popup) val cachedStatus = RootManager.isRootGrantedCached() if (cachedStatus == true && !_uiState.value.hasRoot) { // Root was granted externally (e.g., in Magisk settings) _uiState.update { it.copy(hasRoot = true, rootDenied = false) } // Save to preferences getApplication() .getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) .edit() .putBoolean("root_granted", true) .apply() // Initialize if needed isoDriveManager.initialize() val supportStatus = isoDriveManager.isSupported() val isSupported = supportStatus == SupportStatus.CONFIGFS_SUPPORTED || supportStatus == SupportStatus.SYSFS_SUPPORTED _uiState.update { it.copy(isSupported = isSupported) } if (isSupported) { loadFiles() checkMountStatus() } } } } fun createImg(options: CreateImgOptions) { viewModelScope.launch { val fileName = "${options.fileName}.img" currentCreateImgFileName = fileName val filePath = "${_uiState.value.currentPath}/$fileName" val totalBytes = options.totalBytes // Check if file already exists val checkResult = RootManager.executeCommand("test -f \"$filePath\" && echo exists") if (checkResult.output.trim() == "exists") { _uiState.update { it.copy(errorMessage = "File already exists: $fileName") } return@launch } // Start creating _uiState.update { it.copy( createImgProgress = CreateImgProgress( isCreating = true, fileName = fileName, progress = 0f, bytesWritten = 0, totalBytes = totalBytes ) ) } CreateImgEventBus.resetCancel() // Use dd with progress reporting // We'll create in chunks and report progress val blockSize = 1024 * 1024L // 1MB blocks val totalBlocks = totalBytes / blockSize var writtenBlocks = 0L // Create file with dd in background, checking for cancellation val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { // First, create the file with truncate to reserve space indication val createResult = RootManager.executeCommand( "dd if=/dev/zero of=\"$filePath\" bs=1M count=0 seek=$totalBlocks 2>/dev/null" ) if (!createResult.success) { return@withContext false } // Now fill with zeros in chunks for progress reporting while (writtenBlocks < totalBlocks) { if (CreateImgEventBus.isCancelRequested()) { // Clean up partial file RootManager.executeCommand("rm -f \"$filePath\"") return@withContext false } // Write a chunk (up to 64MB at a time for efficiency) val chunksToWrite = minOf(64, totalBlocks - writtenBlocks) val chunkResult = RootManager.executeCommand( "dd if=/dev/zero of=\"$filePath\" bs=1M count=$chunksToWrite seek=$writtenBlocks conv=notrunc 2>/dev/null" ) if (!chunkResult.success) { RootManager.executeCommand("rm -f \"$filePath\"") return@withContext false } writtenBlocks += chunksToWrite val bytesWritten = writtenBlocks * blockSize CreateImgEventBus.emitProgress(bytesWritten, totalBytes) } true } catch (e: Exception) { RootManager.executeCommand("rm -f \"$filePath\"") false } } if (CreateImgEventBus.isCancelRequested()) { CreateImgEventBus.emitComplete(false, null) } else { CreateImgEventBus.emitComplete(result, if (result) filePath else null) } } } fun cancelCreateImg() { viewModelScope.launch { CreateImgEventBus.cancel() } } fun clearError() { _uiState.update { it.copy(errorMessage = null) } } fun clearSuccess() { _uiState.update { it.copy(successMessage = null) } } } data class MainUiState( val isLoading: Boolean = true, val hasRoot: Boolean = false, val rootDenied: Boolean = false, // True if user denied root in Magisk val isSupported: Boolean = false, val mountStatus: MountStatus = MountStatus.UNMOUNTED, val isoFiles: List = emptyList(), val currentPath: String = "", val isoDirectory: String = "", val errorMessage: String? = null, val successMessage: String? = null, val createImgProgress: CreateImgProgress = CreateImgProgress() )