v1.0.24: harmonious icon, recursive file watching, download-then-open, security fixes

Icon: three identical parallel arcing arrows (same bezier curve, same blue-to-teal
gradient #64C8FF→#32EDBB, same arrowhead geometry) — visually cohesive and clearly
visible against the near-black background.

FileWatchService: FileObserver is now recursive — watchDirRecursive() creates an
observer for each subdirectory at startup, and adds new watchers when CREATE events
produce new directories. Fixes files added to subdirectories not being detected.

FilesViewModel: openFile/shareFile now fall back to download-then-open when the file
is absent locally. AccountRepository + ProviderFactory injected; downloads to
context.cacheDir/syncflow_open/ with isDownloading state. Path traversal guard added
(reject relativePath containing ".."). file_paths.xml gains cache-path entry.

WebDavProvider: path-traversal guard in parsePropfind — skip any server-returned
filename containing "..", "/" or "\". Replace android.util.Log with Timber so debug
logs are stripped from release builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:37:16 +00:00
parent 08dc4f5bd4
commit 146b8baf9a
8 changed files with 184 additions and 86 deletions
@@ -1,7 +1,7 @@
package com.syncflow.data.providers.webdav package com.syncflow.data.providers.webdav
import android.util.Log
import com.syncflow.data.providers.CloudProvider import com.syncflow.data.providers.CloudProvider
import timber.log.Timber
import com.syncflow.domain.model.CloudAccount import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -59,14 +59,13 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build() val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
client.newCall(req).execute().use { resp -> 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) { if (!resp.isSuccessful && resp.code != 207) {
val body = resp.body?.string()?.take(300) ?: "" throw Exception("HTTP ${resp.code} ${resp.message}")
throw Exception("HTTP ${resp.code} ${resp.message}$body")
} }
} }
} }
}.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<List<RemoteFile>> = runCatching { override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -192,9 +191,14 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
"response" -> if (inResponse && href.isNotBlank()) { "response" -> if (inResponse && href.isNotBlank()) {
val rawName = href.trimEnd('/').substringAfterLast('/') val rawName = href.trimEnd('/').substringAfterLast('/')
val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName } val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName }
val relPath = "$parentPath/$name".replace("//", "/") // Guard against path-traversal sequences delivered by a malicious server
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType)) if (name.contains("..") || name.contains('/') || name.contains('\\')) {
inResponse = false inResponse = false
} else {
val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false
}
} }
} }
} }
@@ -12,6 +12,7 @@ import javax.inject.Singleton
class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) { class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) {
private val prefs: SharedPreferences by lazy { private val prefs: SharedPreferences by lazy {
@Suppress("DEPRECATION")
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create( EncryptedSharedPreferences.create(
"syncflow_credentials", "syncflow_credentials",
@@ -33,6 +33,7 @@ fun FilesScreen(
val pairs by vm.pairs.collectAsState() val pairs by vm.pairs.collectAsState()
val selectedPair by vm.selectedPair.collectAsState() val selectedPair by vm.selectedPair.collectAsState()
val files by vm.files.collectAsState() val files by vm.files.collectAsState()
val isDownloading by vm.isDownloading.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() 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( SnackbarHost(
hostState = snackbarHostState, hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
@@ -7,6 +7,8 @@ import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.db.entities.SyncPairEntity 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -27,6 +29,8 @@ sealed class FileAction {
class FilesViewModel @Inject constructor( class FilesViewModel @Inject constructor(
private val syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao, private val fileStateDao: SyncFileStateDao,
private val accountRepository: AccountRepository,
private val providerFactory: ProviderFactory,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
@@ -50,22 +54,33 @@ class FilesViewModel @Inject constructor(
private val _fileAction = MutableSharedFlow<FileAction>() private val _fileAction = MutableSharedFlow<FileAction>()
val fileAction: SharedFlow<FileAction> = _fileAction val fileAction: SharedFlow<FileAction> = _fileAction
private val _isDownloading = MutableStateFlow(false)
val isDownloading: StateFlow<Boolean> = _isDownloading
fun selectPair(id: Long) { _selectedPairId.value = id } fun selectPair(id: Long) { _selectedPairId.value = id }
fun openFile(file: SyncFileStateEntity) { fun openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file) ?: return val resolved = resolveFile(file, emitErrorIfMissing = false)
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) } if (resolved != null) {
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
} else {
downloadAndOpen(file)
}
} }
fun shareFile(file: SyncFileStateEntity) { fun shareFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file) ?: return val resolved = resolveFile(file, emitErrorIfMissing = false)
viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) } if (resolved != null) {
viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) }
} else {
downloadAndShare(file)
}
} }
fun deleteFile(file: SyncFileStateEntity) { fun deleteFile(file: SyncFileStateEntity) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val resolved = resolveFile(file) val resolved = resolveFile(file, emitErrorIfMissing = false)
resolved?.delete() resolved?.delete()
fileStateDao.delete(file.syncPairId, file.relativePath) fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) { } catch (e: Exception) {
@@ -85,7 +100,6 @@ class FilesViewModel @Inject constructor(
_fileAction.emit(FileAction.Error("Rename failed")) _fileAction.emit(FileAction.Error("Rename failed"))
return@launch return@launch
} }
// Update DB: delete old state; the next sync will re-detect as a new upload
fileStateDao.delete(file.syncPairId, file.relativePath) fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Rename failed: ${file.relativePath}") 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 pair = selectedPair.value ?: return null
val root = safTreeUriToRealPath(pair.localPath) ?: pair.localPath 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) val f = File(root, file.relativePath)
if (!f.exists()) { 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 null
} }
return f return f
@@ -34,7 +34,8 @@ class FileWatchService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val fileObservers = mutableMapOf<Long, FileObserver>() // Multiple FileObserver instances per pair: one per directory (recursive)
private val fileObservers = mutableMapOf<Long, MutableList<FileObserver>>()
private val contentObservers = mutableMapOf<Long, ContentObserver>() private val contentObservers = mutableMapOf<Long, ContentObserver>()
private val debounceJobs = mutableMapOf<Long, Job>() private val debounceJobs = mutableMapOf<Long, Job>()
@@ -106,7 +107,7 @@ class FileWatchService : Service() {
} }
} }
val count = fileObservers.size + contentObservers.size val count = fileObservers.keys.size + contentObservers.size
updateNotification(count) updateNotification(count)
if (count == 0) { if (count == 0) {
@@ -138,23 +139,44 @@ class FileWatchService : Service() {
Timber.w("FileWatchService: path does not exist for pair $pairId: $path") Timber.w("FileWatchService: path does not exist for pair $pairId: $path")
return 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 val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or
FileObserver.MOVED_FROM or FileObserver.MOVED_TO FileObserver.MOVED_FROM or FileObserver.MOVED_TO
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(dir, mask) { 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 { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
object : FileObserver(path, mask) { object : FileObserver(dir.absolutePath, 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)
}
} }
} }
observer.startWatching() observer.startWatching()
fileObservers[pairId] = observer fileObservers.getOrPut(pairId) { mutableListOf() }.add(observer)
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path") // Recursively watch existing subdirectories
// Check if anything changed while the service was not running dir.listFiles()?.filter { it.isDirectory }?.forEach { sub ->
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) } watchDirRecursive(sub, pairId, wifiOnly, chargingOnly)
}
} }
private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) { private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) {
@@ -199,7 +221,7 @@ class FileWatchService : Service() {
} }
private fun clearWatchers() { private fun clearWatchers() {
fileObservers.values.forEach { it.stopWatching() } fileObservers.values.flatten().forEach { it.stopWatching() }
fileObservers.clear() fileObservers.clear()
contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) } contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) }
contentObservers.clear() contentObservers.clear()
@@ -6,90 +6,61 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Subtle inner glow --> <!--
<path Three identical arcs pointing right, evenly spaced vertically.
android:pathData="M54,54m-30,0a30,30 0 1,0 60,0a30,30 0 1,0 -60,0" Same curve shape, same gradient direction, same arrowhead geometry — purely harmonious.
android:fillColor="#0AFFFFFF"/> Control point 14dp above midline on each arc.
-->
<!-- ═══ Arrow 1: curving RIGHT (top) — coral gradient ═══ --> <!-- Arc 1 — top -->
<!-- Curved shaft via quadratic bezier: left→ dips up → right -->
<path <path
android:pathData="M28,40 Q54,22 80,36" android:pathData="M 26,34 Q 54,20 82,34"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeWidth="7" android:strokeWidth="8.5"
android:strokeLineCap="round" android:strokeLineCap="round"
android:strokeLineJoin="round"> android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor"> <aapt:attr name="android:strokeColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="28" android:startY="36" android:startX="26" android:startY="34"
android:endX="80" android:endY="36" android:endX="82" android:endY="34"
android:startColor="#FF6B6B" android:startColor="#64C8FF"
android:endColor="#FFB347"/> android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<!-- Arrowhead at (80,36) pointing right/slightly-down -->
<path android:pathData="M76,28 L72,44 L88,38 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="72" android:startY="36"
android:endX="88" android:endY="36"
android:startColor="#FFB347"
android:endColor="#FF8C42"/>
</aapt:attr> </aapt:attr>
</path> </path>
<path android:pathData="M 77.5,27.3 L 82,34 L 74.0,34.5 Z" android:fillColor="#32EDBB"/>
<!-- ═══ Arrow 2: curving LEFT (middle) — silver/white ═══ --> <!-- Arc 2 — middle -->
<!-- Curved shaft: right→ dips down → left -->
<path <path
android:pathData="M80,56 Q54,74 28,60" android:pathData="M 26,54 Q 54,40 82,54"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeWidth="7" android:strokeWidth="8.5"
android:strokeLineCap="round" android:strokeLineCap="round"
android:strokeLineJoin="round"> android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor"> <aapt:attr name="android:strokeColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="80" android:startY="60" android:startX="26" android:startY="54"
android:endX="28" android:endY="60" android:endX="82" android:endY="54"
android:startColor="#D0D8EE" android:startColor="#64C8FF"
android:endColor="#8892AA"/> android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<!-- Arrowhead at (28,60) pointing left/slightly-down -->
<path android:pathData="M32,52 L36,68 L20,62 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="36" android:startY="60"
android:endX="20" android:endY="60"
android:startColor="#D0D8EE"
android:endColor="#8892AA"/>
</aapt:attr> </aapt:attr>
</path> </path>
<path android:pathData="M 77.5,47.3 L 82,54 L 74.0,54.5 Z" android:fillColor="#32EDBB"/>
<!-- ═══ Arrow 3: curving RIGHT (bottom) — teal gradient ═══ --> <!-- Arc 3 — bottom -->
<!-- Curved shaft: left→ dips up → right -->
<path <path
android:pathData="M28,74 Q54,58 80,72" android:pathData="M 26,74 Q 54,60 82,74"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeWidth="7" android:strokeWidth="8.5"
android:strokeLineCap="round" android:strokeLineCap="round"
android:strokeLineJoin="round"> android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor"> <aapt:attr name="android:strokeColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="28" android:startY="72" android:startX="26" android:startY="74"
android:endX="80" android:endY="72" android:endX="82" android:endY="74"
android:startColor="#4DD0E1" android:startColor="#64C8FF"
android:endColor="#00BCD4"/> android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<!-- Arrowhead at (80,72) pointing right/slightly-up -->
<path android:pathData="M76,64 L72,80 L88,74 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="72" android:startY="72"
android:endX="88" android:endY="72"
android:startColor="#00BCD4"
android:endColor="#00ACC1"/>
</aapt:attr> </aapt:attr>
</path> </path>
<path android:pathData="M 77.5,67.3 L 82,74 L 74.0,74.5 Z" android:fillColor="#32EDBB"/>
</vector> </vector>
+1
View File
@@ -3,4 +3,5 @@
<external-path name="external_storage" path="." /> <external-path name="external_storage" path="." />
<external-files-path name="external_files" path="." /> <external-files-path name="external_files" path="." />
<files-path name="internal_files" path="." /> <files-path name="internal_files" path="." />
<cache-path name="syncflow_cache" path="syncflow_open/" />
</paths> </paths>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.23 VERSION_NAME=1.0.24
VERSION_CODE=24 VERSION_CODE=25