package com.syncflow.ui.files import android.content.Context import android.media.MediaScannerConnection import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.providers.ProviderFactory import com.syncflow.data.repository.AccountRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.flow.update import timber.log.Timber import java.io.File import javax.inject.Inject sealed class FileAction { data class Open(val file: File) : FileAction() data class Share(val file: File) : FileAction() data class ShareMultiple(val files: List) : FileAction() data class Error(val message: String) : FileAction() } @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class FilesViewModel @Inject constructor( private val syncPairDao: SyncPairDao, private val fileStateDao: SyncFileStateDao, private val accountRepository: AccountRepository, private val providerFactory: ProviderFactory, @ApplicationContext private val context: Context, ) : ViewModel() { val pairs: StateFlow> = syncPairDao.observeAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) val accounts = accountRepository.observeAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) private val _selectedPairId = MutableStateFlow(null) val selectedPair: StateFlow = combine(_selectedPairId, pairs) { id, list -> list.firstOrNull { it.id == id } ?: list.firstOrNull() }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) val files: StateFlow> = _selectedPairId .flatMapLatest { id -> if (id == null) pairs.map { it.firstOrNull()?.id }.filterNotNull() .flatMapLatest { fileStateDao.observeForPair(it) } else fileStateDao.observeForPair(id) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) private val _fileAction = MutableSharedFlow() val fileAction: SharedFlow = _fileAction private val _isDownloading = MutableStateFlow(false) val isDownloading: StateFlow = _isDownloading private val _selectedKeys = MutableStateFlow>(emptySet()) val selectedKeys: StateFlow> = _selectedKeys val isSelectionMode: StateFlow = _selectedKeys.map { it.isNotEmpty() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) val selectedCount: StateFlow = _selectedKeys.map { it.size } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) fun selectPair(id: Long) { _selectedPairId.value = id } fun openFile(file: SyncFileStateEntity) { val resolved = resolveFile(file, emitErrorIfMissing = false) if (resolved != null) { // Ensure MediaStore knows about this file so gallery apps can open it MediaScannerConnection.scanFile(context, arrayOf(resolved.absolutePath), null, null) viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) } } else { downloadAndOpen(file) } } fun shareFile(file: SyncFileStateEntity) { val resolved = resolveFile(file, emitErrorIfMissing = false) if (resolved != null) { viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) } } else { downloadAndShare(file) } } fun deleteFile(file: SyncFileStateEntity) { viewModelScope.launch { try { val resolved = resolveFile(file, emitErrorIfMissing = false) resolved?.delete() fileStateDao.delete(file.syncPairId, file.relativePath) } catch (e: Exception) { Timber.e(e, "Delete failed: ${file.relativePath}") _fileAction.emit(FileAction.Error("Delete failed: ${e.message}")) } } } fun renameFile(file: SyncFileStateEntity, newName: String) { viewModelScope.launch { try { val resolved = resolveFile(file) ?: return@launch val parent = resolved.parentFile ?: return@launch val dest = File(parent, newName) if (!resolved.renameTo(dest)) { _fileAction.emit(FileAction.Error("Rename failed")) return@launch } fileStateDao.delete(file.syncPairId, file.relativePath) } catch (e: Exception) { Timber.e(e, "Rename failed: ${file.relativePath}") _fileAction.emit(FileAction.Error("Rename failed: ${e.message}")) } } } fun isSelected(file: SyncFileStateEntity): Boolean = fileKey(file) in _selectedKeys.value fun toggleSelection(file: SyncFileStateEntity) { val key = fileKey(file) _selectedKeys.update { if (key in it) it - key else it + key } } fun clearSelection() { _selectedKeys.value = emptySet() } fun deleteSelected() { viewModelScope.launch { val toDelete = files.value.filter { isSelected(it) } toDelete.forEach { file -> try { resolveFile(file, emitErrorIfMissing = false)?.delete() fileStateDao.delete(file.syncPairId, file.relativePath) } catch (e: Exception) { Timber.e(e, "Bulk delete failed: ${file.relativePath}") } } clearSelection() } } fun shareSelected() { viewModelScope.launch { val toShare = files.value.filter { isSelected(it) } val resolved = toShare.mapNotNull { resolveFile(it, emitErrorIfMissing = false) } if (resolved.isEmpty()) { _fileAction.emit(FileAction.Error("No local files available to share")) return@launch } _fileAction.emit(FileAction.ShareMultiple(resolved)) } } fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}" fun openCloudFile(accountId: Long, remotePath: String) { viewModelScope.launch { val account = accountRepository.getAccount(accountId) ?: run { _fileAction.emit(FileAction.Error("Account not found")) return@launch } val provider = providerFactory.create(account) val fileName = remotePath.substringAfterLast('/') val cacheFile = File(context.cacheDir, "syncflow_open/$fileName") cacheFile.parentFile?.mkdirs() _isDownloading.value = true try { cacheFile.outputStream().use { out -> provider.downloadFile(remotePath, out) { }.getOrThrow() } _fileAction.emit(FileAction.Open(cacheFile)) } catch (e: Exception) { Timber.e(e, "Cloud open failed: $remotePath") _fileAction.emit(FileAction.Error("Cannot open: ${e.message}")) } finally { _isDownloading.value = false } } } // ── Download-then-open/share ────────────────────────────────────────────── private fun downloadAndOpen(file: SyncFileStateEntity) { viewModelScope.launch { downloadToCache(file)?.let { cached -> _fileAction.emit(FileAction.Open(cached)) } } } private fun downloadAndShare(file: SyncFileStateEntity) { viewModelScope.launch { downloadToCache(file)?.let { cached -> _fileAction.emit(FileAction.Share(cached)) } } } private suspend fun downloadToCache(file: SyncFileStateEntity): File? { val pair = selectedPair.value ?: run { _fileAction.emit(FileAction.Error("No sync pair selected")) return null } val account = accountRepository.getAccount(pair.accountId) ?: run { _fileAction.emit(FileAction.Error("Cloud account not found")) return null } val provider = providerFactory.create(account) val fileName = file.relativePath.substringAfterLast('/') val cacheFile = File(context.cacheDir, "syncflow_open/$fileName") cacheFile.parentFile?.mkdirs() _isDownloading.value = true return try { cacheFile.outputStream().use { out -> provider.downloadFile("${pair.remotePath}/${file.relativePath}", out) { }.getOrThrow() } MediaScannerConnection.scanFile(context, arrayOf(cacheFile.absolutePath), null, null) cacheFile } catch (e: Exception) { Timber.e(e, "Download for preview failed: ${file.relativePath}") cacheFile.delete() _fileAction.emit(FileAction.Error("Download failed: ${e.message}")) null } finally { _isDownloading.value = false } } // ── Path resolution ─────────────────────────────────────────────────────── private fun resolveFile(file: SyncFileStateEntity, emitErrorIfMissing: Boolean = true): File? { // Guard against path traversal from untrusted server responses if (file.relativePath.contains("..")) { viewModelScope.launch { _fileAction.emit(FileAction.Error("Invalid file path")) } return null } val pair = selectedPair.value ?: return null val root = safTreeUriToRealPath(pair.localPath) ?: pair.localPath // localPath is a content:// URI we couldn't resolve — File-based access won't work if (root.startsWith("content://")) return null val f = File(root, file.relativePath) if (!f.exists()) { if (emitErrorIfMissing) { viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) } } return null } return f } private fun safTreeUriToRealPath(uriString: String): String? { if (!uriString.startsWith("content://")) return uriString return try { val treeUri = android.net.Uri.parse(uriString) val docId = android.provider.DocumentsContract.getTreeDocumentId(treeUri) if (docId.startsWith("primary:")) "/storage/emulated/0/${docId.removePrefix("primary:")}" else null } catch (e: Exception) { null } } }