Compare commits

...

2 Commits

Author SHA1 Message Date
amir dc2a0b2c68 v1.0.27: knot-inspired icon, fix media-not-found on photo open
Icon: two thick tube-style arcs with 3D glossy highlights.
  Arc 1 (left side): coral #E8665A to orange #E8A040
  Arc 2 (right side): steel blue #4A7FD4 to deep purple #7B5EA7
  Arrowheads: orange and purple. Background: dark purple-black.
  Inspired by the braided knot color palette.

Fix "media not found" when opening photos:
  - Intent now sets ClipData alongside FLAG_GRANT_READ_URI_PERMISSION
    so the permission correctly propagates through the system chooser
    to whichever app the user picks.
  - openFile() and downloadToCache() both call MediaScannerConnection
    so newly synced/downloaded files appear in gallery MediaStore index
    before the viewer launches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 03:08:44 +00:00
amir 742f634084 v1.0.26: fix multi-selection reactivity, redesign icon, security review
Fix multi-selection: selectedKeys exposed as StateFlow, collected in
FilesScreen so checkboxes and highlights update correctly on every tap.
fileKey() made public so UI can check membership without ViewModel calls.

Icon: white cloud body with two cyan/teal circular sync arcs (AutoSync
style), deep blue-to-teal gradient background.

Security review clean: no hardcoded credentials, cleartext blocked by
network_security_config, allowBackup=false, path traversal guards in
place on both server responses and local resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 02:45:43 +00:00
5 changed files with 115 additions and 46 deletions
@@ -1,5 +1,6 @@
package com.syncflow.ui.files package com.syncflow.ui.files
import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
@@ -39,8 +40,9 @@ fun FilesScreen(
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 isDownloading by vm.isDownloading.collectAsState()
val isSelectionMode by vm.isSelectionMode.collectAsState() val selectedKeys by vm.selectedKeys.collectAsState()
val selectedCount by vm.selectedCount.collectAsState() val isSelectionMode = selectedKeys.isNotEmpty()
val selectedCount = selectedKeys.size
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -56,15 +58,18 @@ fun FilesScreen(
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file context, "${context.packageName}.fileprovider", action.file
) )
val mimeType = action.file.name.mimeType()
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, action.file.name.mimeType()) setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) // ClipData is required so FLAG_GRANT_READ_URI_PERMISSION
// propagates to whichever app the system chooser picks.
clipData = ClipData.newRawUri("", uri)
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_ACTIVITY_NEW_TASK
)
} }
context.startActivity( context.startActivity(intent)
Intent.createChooser(intent, "Open with").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) { } catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot open file: ${e.message}") snackbarHostState.showSnackbar("Cannot open file: ${e.message}")
} }
@@ -77,6 +82,7 @@ fun FilesScreen(
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {
type = action.file.name.mimeType() type = action.file.name.mimeType()
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri("", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
} }
context.startActivity( context.startActivity(
@@ -252,12 +258,11 @@ fun FilesScreen(
} }
} }
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file -> items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
val selected = vm.isSelected(file)
FileRow( FileRow(
file = file, file = file,
isInSubDir = dir.isNotEmpty() && !isSelectionMode, isInSubDir = dir.isNotEmpty() && !isSelectionMode,
isSelectionMode = isSelectionMode, isSelectionMode = isSelectionMode,
isSelected = selected, isSelected = vm.fileKey(file) in selectedKeys,
vm = vm, vm = vm,
) )
HorizontalDivider( HorizontalDivider(
@@ -1,6 +1,7 @@
package com.syncflow.ui.files package com.syncflow.ui.files
import android.content.Context import android.content.Context
import android.media.MediaScannerConnection
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncFileStateDao
@@ -60,6 +61,7 @@ class FilesViewModel @Inject constructor(
val isDownloading: StateFlow<Boolean> = _isDownloading val isDownloading: StateFlow<Boolean> = _isDownloading
private val _selectedKeys = MutableStateFlow<Set<String>>(emptySet()) private val _selectedKeys = MutableStateFlow<Set<String>>(emptySet())
val selectedKeys: StateFlow<Set<String>> = _selectedKeys
val isSelectionMode: StateFlow<Boolean> = _selectedKeys.map { it.isNotEmpty() } val isSelectionMode: StateFlow<Boolean> = _selectedKeys.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val selectedCount: StateFlow<Int> = _selectedKeys.map { it.size } val selectedCount: StateFlow<Int> = _selectedKeys.map { it.size }
@@ -70,6 +72,8 @@ class FilesViewModel @Inject constructor(
fun openFile(file: SyncFileStateEntity) { fun openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file, emitErrorIfMissing = false) val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) { 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)) } viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
} else { } else {
downloadAndOpen(file) downloadAndOpen(file)
@@ -152,7 +156,7 @@ class FilesViewModel @Inject constructor(
} }
} }
private fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}" fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
// ── Download-then-open/share ────────────────────────────────────────────── // ── Download-then-open/share ──────────────────────────────────────────────
@@ -191,6 +195,7 @@ class FilesViewModel @Inject constructor(
cacheFile.outputStream().use { out -> cacheFile.outputStream().use { out ->
provider.downloadFile("${pair.remotePath}/${file.relativePath}", out) { }.getOrThrow() provider.downloadFile("${pair.remotePath}/${file.relativePath}", out) { }.getOrThrow()
} }
MediaScannerConnection.scanFile(context, arrayOf(cacheFile.absolutePath), null, null)
cacheFile cacheFile
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Download for preview failed: ${file.relativePath}") Timber.e(e, "Download for preview failed: ${file.relativePath}")
@@ -1,10 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
<gradient xmlns:aapt="http://schemas.android.com/aapt"
android:type="radial" android:width="108dp"
android:gradientRadius="80%" android:height="108dp"
android:centerX="0.5" android:viewportWidth="108"
android:centerY="0.4" android:viewportHeight="108">
android:startColor="#1B2A3B"
android:endColor="#06090E"/> <!-- Deep purple-black base, matching the knot reference icon's dark background -->
</shape> <path android:pathData="M0,0 H108 V108 H0 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="radial"
android:gradientRadius="76"
android:centerX="54" android:centerY="44"
android:startColor="#1E1628"
android:endColor="#080610"/>
</aapt:attr>
</path>
</vector>
@@ -1,4 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
SyncFlow icon foreground (108x108dp, safe zone 72dp centred at 54,54).
Design: two thick tube-style sync arcs that form a circular refresh symbol,
inspired by the braided knot icon's color palette:
Arc 1 (left side, CW): coral #E8665A -> orange #E8A040
Arc 2 (right side, CW): steel blue #4A7FD4 -> purple #7B5EA7
Each arc has a thin inner highlight to give a 3D glossy tube look.
Geometry: circle centred at (54,54), radius 27.
Arc 1: 100 deg to 260 deg CW (160 deg, through west/left)
start (49.31, 80.59) end (49.31, 27.41)
Arc 2: 280 deg to 80 deg CW (160 deg, through east/right)
start (58.69, 27.41) end (58.69, 80.59)
20-degree gaps at top (~260-280) and bottom (~80-100).
Highlights at r=23 (inner edge):
Arc 1 highlight: (49.99, 76.64) to (49.99, 31.36)
Arc 2 highlight: (58.01, 31.36) to (58.01, 76.64)
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt" xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
@@ -6,45 +26,74 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- <!-- Arc 1 shadow (offset slightly down-right for depth) -->
Circular sync icon (AutoSync-style): two 170° arcs forming a clockwise
rotation. Arc 1 sweeps over the bottom (right→bottom→left), Arc 2 over the
top (left→top→right). Arrowheads at the 9-o'clock and 3-o'clock gaps.
Circle radius 26, center (54,54).
-->
<!-- Arc 1: right → bottom → left (CW, small arc 170°) -->
<path <path
android:pathData="M 79.9,56.3 A 26,26 0 0,1 28.1,56.3" android:pathData="M 50.31,82.59 A 27,27 0 0,1 50.31,29.41"
android:fillColor="#00000000"
android:strokeWidth="9"
android:strokeLineCap="round"
android:strokeColor="#44000000"/>
<!-- Arc 2 shadow -->
<path
android:pathData="M 59.69,29.41 A 27,27 0 0,1 59.69,82.59"
android:fillColor="#00000000"
android:strokeWidth="9"
android:strokeLineCap="round"
android:strokeColor="#44000000"/>
<!-- Arc 1: left side, coral to orange -->
<path
android:pathData="M 49.31,80.59 A 27,27 0 0,1 49.31,27.41"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeWidth="9" android:strokeWidth="9"
android:strokeLineCap="round"> android:strokeLineCap="round">
<aapt:attr name="android:strokeColor"> <aapt:attr name="android:strokeColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="79.9" android:startY="56.3" android:startX="49.31" android:startY="80.59"
android:endX="28.1" android:endY="56.3" android:endX="49.31" android:endY="27.41"
android:startColor="#40C4FF" android:startColor="#E8665A"
android:endColor="#00E5FF"/> android:endColor="#E8A040"/>
</aapt:attr> </aapt:attr>
</path> </path>
<!-- Arrowhead at left side (175°), pointing upward -->
<path android:pathData="M 23.9,65.7 L 28.1,56.3 L 33.9,64.8 Z" android:fillColor="#00E5FF"/>
<!-- Arc 2: left → top → right (CW, small arc 170°) --> <!-- Arc 2: right side, blue to purple -->
<path <path
android:pathData="M 28.1,51.7 A 26,26 0 0,1 79.9,51.7" android:pathData="M 58.69,27.41 A 27,27 0 0,1 58.69,80.59"
android:fillColor="#00000000" android:fillColor="#00000000"
android:strokeWidth="9" android:strokeWidth="9"
android:strokeLineCap="round"> android:strokeLineCap="round">
<aapt:attr name="android:strokeColor"> <aapt:attr name="android:strokeColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="28.1" android:startY="51.7" android:startX="58.69" android:startY="27.41"
android:endX="79.9" android:endY="51.7" android:endX="58.69" android:endY="80.59"
android:startColor="#00E5FF" android:startColor="#4A7FD4"
android:endColor="#40C4FF"/> android:endColor="#7B5EA7"/>
</aapt:attr> </aapt:attr>
</path> </path>
<!-- Arrowhead at right side (355°), pointing downward -->
<path android:pathData="M 84.1,42.3 L 79.9,51.7 L 74.1,43.2 Z" android:fillColor="#40C4FF"/> <!-- Arc 1 inner highlight (glossy tube sheen) -->
<path
android:pathData="M 49.99,76.64 A 23,23 0 0,1 49.99,31.36"
android:fillColor="#00000000"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:strokeColor="#55FFC8B0"/>
<!-- Arc 2 inner highlight -->
<path
android:pathData="M 58.01,31.36 A 23,23 0 0,1 58.01,76.64"
android:fillColor="#00000000"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:strokeColor="#55B0C8FF"/>
<!-- Arrowhead at end of Arc 1 (260 deg, top-left area, pointing right-up) -->
<path android:pathData="M 42.13,32.74 L 49.31,27.41 L 40.73,24.86 Z"
android:fillColor="#E8A040"/>
<!-- Arrowhead at end of Arc 2 (80 deg, bottom-right area, pointing left-down) -->
<path android:pathData="M 67.27,83.14 L 58.69,80.59 L 65.87,75.26 Z"
android:fillColor="#7B5EA7"/>
</vector> </vector>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.25 VERSION_NAME=1.0.27
VERSION_CODE=26 VERSION_CODE=28