526 lines
19 KiB
Kotlin
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()
|
|
)
|