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:
2026-05-24 23:25:58 +00:00
parent 422e8f0f0f
commit 08dc4f5bd4
8 changed files with 472 additions and 208 deletions
@@ -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 }
}
}