diff --git a/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt index c67881a..888027a 100644 --- a/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt +++ b/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt @@ -1,7 +1,7 @@ package com.syncflow.data.providers.webdav -import android.util.Log import com.syncflow.data.providers.CloudProvider +import timber.log.Timber import com.syncflow.domain.model.CloudAccount import com.syncflow.domain.model.RemoteFile import kotlinx.coroutines.Dispatchers @@ -59,14 +59,13 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider { withContext(Dispatchers.IO) { val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build() client.newCall(req).execute().use { resp -> - Log.d("SyncFlow/WebDAV", "testConnection ${resp.code} url=$baseUrl") + Timber.d("WebDAV testConnection %d url=%s", resp.code, baseUrl) if (!resp.isSuccessful && resp.code != 207) { - val body = resp.body?.string()?.take(300) ?: "" - throw Exception("HTTP ${resp.code} ${resp.message} — $body") + throw Exception("HTTP ${resp.code} ${resp.message}") } } } - }.onFailure { e -> Log.e("SyncFlow/WebDAV", "testConnection failed: ${e::class.simpleName}: ${e.message}", e.cause) } + }.onFailure { e -> Timber.e(e, "WebDAV testConnection failed") } override suspend fun listFiles(remotePath: String): Result> = runCatching { withContext(Dispatchers.IO) { @@ -192,9 +191,14 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider { "response" -> if (inResponse && href.isNotBlank()) { val rawName = href.trimEnd('/').substringAfterLast('/') val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName } - val relPath = "$parentPath/$name".replace("//", "/") - results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType)) - inResponse = false + // Guard against path-traversal sequences delivered by a malicious server + if (name.contains("..") || name.contains('/') || name.contains('\\')) { + inResponse = false + } else { + val relPath = "$parentPath/$name".replace("//", "/") + results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType)) + inResponse = false + } } } } diff --git a/app/src/main/kotlin/com/syncflow/data/security/CredentialStore.kt b/app/src/main/kotlin/com/syncflow/data/security/CredentialStore.kt index 3e18b73..7afb7fa 100644 --- a/app/src/main/kotlin/com/syncflow/data/security/CredentialStore.kt +++ b/app/src/main/kotlin/com/syncflow/data/security/CredentialStore.kt @@ -12,6 +12,7 @@ import javax.inject.Singleton class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) { private val prefs: SharedPreferences by lazy { + @Suppress("DEPRECATION") val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) EncryptedSharedPreferences.create( "syncflow_credentials", diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt index b2970f1..b570f86 100644 --- a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt @@ -33,6 +33,7 @@ fun FilesScreen( val pairs by vm.pairs.collectAsState() val selectedPair by vm.selectedPair.collectAsState() val files by vm.files.collectAsState() + val isDownloading by vm.isDownloading.collectAsState() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -182,6 +183,31 @@ fun FilesScreen( } } + if (isDownloading) { + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 4.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text("Downloading for preview…", style = MaterialTheme.typography.bodySmall) + } + } + } + } + SnackbarHost( hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter), diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt index baf0fac..eb9aa53 100644 --- a/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt @@ -7,6 +7,8 @@ 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 @@ -27,6 +29,8 @@ sealed class FileAction { 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() { @@ -50,22 +54,33 @@ class FilesViewModel @Inject constructor( private val _fileAction = MutableSharedFlow() val fileAction: SharedFlow = _fileAction + private val _isDownloading = MutableStateFlow(false) + val isDownloading: StateFlow = _isDownloading + fun selectPair(id: Long) { _selectedPairId.value = id } fun openFile(file: SyncFileStateEntity) { - val resolved = resolveFile(file) ?: return - viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) } + val resolved = resolveFile(file, emitErrorIfMissing = false) + if (resolved != null) { + viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) } + } else { + downloadAndOpen(file) + } } fun shareFile(file: SyncFileStateEntity) { - val resolved = resolveFile(file) ?: return - viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) } + 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) + val resolved = resolveFile(file, emitErrorIfMissing = false) resolved?.delete() fileStateDao.delete(file.syncPairId, file.relativePath) } catch (e: Exception) { @@ -85,7 +100,6 @@ class FilesViewModel @Inject constructor( _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}") @@ -94,12 +108,71 @@ class FilesViewModel @Inject constructor( } } - private fun resolveFile(file: SyncFileStateEntity): File? { + // ── 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() + } + 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()) { - viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) } + if (emitErrorIfMissing) { + viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) } + } return null } return f diff --git a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt index 43853d1..f13299a 100644 --- a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt +++ b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt @@ -34,7 +34,8 @@ class FileWatchService : Service() { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val mainHandler = Handler(Looper.getMainLooper()) - private val fileObservers = mutableMapOf() + // Multiple FileObserver instances per pair: one per directory (recursive) + private val fileObservers = mutableMapOf>() private val contentObservers = mutableMapOf() private val debounceJobs = mutableMapOf() @@ -106,7 +107,7 @@ class FileWatchService : Service() { } } - val count = fileObservers.size + contentObservers.size + val count = fileObservers.keys.size + contentObservers.size updateNotification(count) if (count == 0) { @@ -138,23 +139,44 @@ class FileWatchService : Service() { Timber.w("FileWatchService: path does not exist for pair $pairId: $path") return } + fileObservers[pairId] = mutableListOf() + watchDirRecursive(dir, pairId, wifiOnly, chargingOnly) + Timber.d("FileWatchService: watching pair $pairId at $path (${fileObservers[pairId]?.size} dirs)") + scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) } + } + + private fun watchDirRecursive(dir: File, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { + if (!dir.isDirectory) return val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or FileObserver.MOVED_FROM or FileObserver.MOVED_TO val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { object : FileObserver(dir, mask) { - override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly) + override fun onEvent(event: Int, path: String?) { + if (event and FileObserver.CREATE != 0 && path != null) { + val created = File(dir, path) + if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly) + } + onChangeDetected(pairId, wifiOnly, chargingOnly) + } } } else { @Suppress("DEPRECATION") - object : FileObserver(path, mask) { - override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly) + object : FileObserver(dir.absolutePath, mask) { + override fun onEvent(event: Int, path: String?) { + if (event and FileObserver.CREATE != 0 && path != null) { + val created = File(dir, path) + if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly) + } + onChangeDetected(pairId, wifiOnly, chargingOnly) + } } } observer.startWatching() - fileObservers[pairId] = observer - Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path") - // Check if anything changed while the service was not running - scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) } + fileObservers.getOrPut(pairId) { mutableListOf() }.add(observer) + // Recursively watch existing subdirectories + dir.listFiles()?.filter { it.isDirectory }?.forEach { sub -> + watchDirRecursive(sub, pairId, wifiOnly, chargingOnly) + } } private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) { @@ -199,7 +221,7 @@ class FileWatchService : Service() { } private fun clearWatchers() { - fileObservers.values.forEach { it.stopWatching() } + fileObservers.values.flatten().forEach { it.stopWatching() } fileObservers.clear() contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) } contentObservers.clear() diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 311b83f..abbdc73 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -6,90 +6,61 @@ android:viewportWidth="108" android:viewportHeight="108"> - - + - - + - - - - - - + android:startX="26" android:startY="34" + android:endX="82" android:endY="34" + android:startColor="#64C8FF" + android:endColor="#32EDBB"/> + - - + - - - - - - + android:startX="26" android:startY="54" + android:endX="82" android:endY="54" + android:startColor="#64C8FF" + android:endColor="#32EDBB"/> + - - + - - - - - - + android:startX="26" android:startY="74" + android:endX="82" android:endY="74" + android:startColor="#64C8FF" + android:endColor="#32EDBB"/> + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 9c5159d..791b284 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,4 +3,5 @@ + diff --git a/version.properties b/version.properties index 9ecb0e6..66acc41 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.23 -VERSION_CODE=24 +VERSION_NAME=1.0.24 +VERSION_CODE=25