Files
ISODroid/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt

526 lines
19 KiB
Kotlin

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<Preferences> 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<MainUiState> = _uiState.asStateFlow()
private var navigationStack = mutableListOf<String>()
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<android.app.Application>()
.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<IsoFile>? {
// 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<IsoFile>? {
// 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<IsoFile> {
// 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<android.app.Application>()
.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<android.app.Application>()
.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<IsoFile> = emptyList(),
val currentPath: String = "",
val isoDirectory: String = "",
val errorMessage: String? = null,
val successMessage: String? = null,
val createImgProgress: CreateImgProgress = CreateImgProgress()
)