v1.0.23: functional Files tab, background service persistence, startup indexer, curved icon
- FilesScreen: per-file context menu (Open, Share, Rename, Delete), rename dialog, delete confirmation, FileProvider-based open/share intents, Snackbar error feedback - FilesViewModel: FileAction sealed class + SharedFlow; openFile, shareFile, deleteFile, renameFile with DB cleanup; resolveFile handles SAF primary: URIs - FileWatchService: stopWithTask=false keeps watcher alive after app swipe-away; catchupScan on startup detects changes missed while service was not running; SyncFileStateDao injected; FileObserver used for real-path SAF URIs - BootReceiver: handles MY_PACKAGE_REPLACED to restart service after app update - file_paths.xml: added external-path so FileProvider can serve /storage/emulated/0 files - ic_launcher_foreground: three curved stroke-based arrows (quadratic bezier, round caps) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.syncflow.ui.files
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.syncflow.data.db.SyncFileStateDao
|
||||
@@ -7,15 +8,26 @@ import com.syncflow.data.db.SyncPairDao
|
||||
import com.syncflow.data.db.entities.SyncFileStateEntity
|
||||
import com.syncflow.data.db.entities.SyncPairEntity
|
||||
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 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 Error(val message: String) : FileAction()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class FilesViewModel @Inject constructor(
|
||||
syncPairDao: SyncPairDao,
|
||||
private val syncPairDao: SyncPairDao,
|
||||
private val fileStateDao: SyncFileStateDao,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
|
||||
@@ -35,5 +47,71 @@ class FilesViewModel @Inject constructor(
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _fileAction = MutableSharedFlow<FileAction>()
|
||||
val fileAction: SharedFlow<FileAction> = _fileAction
|
||||
|
||||
fun selectPair(id: Long) { _selectedPairId.value = id }
|
||||
|
||||
fun openFile(file: SyncFileStateEntity) {
|
||||
val resolved = resolveFile(file) ?: return
|
||||
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
|
||||
}
|
||||
|
||||
fun shareFile(file: SyncFileStateEntity) {
|
||||
val resolved = resolveFile(file) ?: return
|
||||
viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) }
|
||||
}
|
||||
|
||||
fun deleteFile(file: SyncFileStateEntity) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val resolved = resolveFile(file)
|
||||
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
|
||||
}
|
||||
// Update DB: delete old state; the next sync will re-detect as a new upload
|
||||
fileStateDao.delete(file.syncPairId, file.relativePath)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Rename failed: ${file.relativePath}")
|
||||
_fileAction.emit(FileAction.Error("Rename failed: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveFile(file: SyncFileStateEntity): File? {
|
||||
val pair = selectedPair.value ?: return null
|
||||
val root = safTreeUriToRealPath(pair.localPath) ?: pair.localPath
|
||||
val f = File(root, file.relativePath)
|
||||
if (!f.exists()) {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user