cb9fa1d3db
Replace the synced-files list with a proper file explorer: - Phone tab: browse all of internal storage with quick-access shortcuts (Camera, Downloads, Documents, Pictures, Music, Videos), breadcrumb navigation, search, tap folder to enter, tap file to open/share - Cloud tab: browse connected cloud accounts, account switcher chips for multiple accounts, breadcrumb navigation, search, tap file to download+open Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
270 lines
11 KiB
Kotlin
270 lines
11 KiB
Kotlin
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<File>) : 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<List<SyncPairEntity>> = syncPairDao.observeAll()
|
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
|
|
|
val accounts = accountRepository.observeAll()
|
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
|
|
|
private val _selectedPairId = MutableStateFlow<Long?>(null)
|
|
|
|
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list ->
|
|
list.firstOrNull { it.id == id } ?: list.firstOrNull()
|
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
|
|
|
val files: StateFlow<List<SyncFileStateEntity>> = _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<FileAction>()
|
|
val fileAction: SharedFlow<FileAction> = _fileAction
|
|
|
|
private val _isDownloading = MutableStateFlow(false)
|
|
val isDownloading: StateFlow<Boolean> = _isDownloading
|
|
|
|
private val _selectedKeys = MutableStateFlow<Set<String>>(emptySet())
|
|
val selectedKeys: StateFlow<Set<String>> = _selectedKeys
|
|
val isSelectionMode: StateFlow<Boolean> = _selectedKeys.map { it.isNotEmpty() }
|
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
|
val selectedCount: StateFlow<Int> = _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 }
|
|
}
|
|
}
|