Expanded folder sync functionality to support multiple folders and improved performance. (#47)

- Added `SyncedFolder` data class and transitioned from a single `syncedFolderUri` to a list of `syncedFolders`.
- Implemented logic to add, remove, and persist multiple synced folders using JSON in shared preferences, including a migration path for legacy single-folder settings.
- Updated `FolderSyncWorker` to iterate through all registered folders and added an annotation sidecar preloading optimization to reduce file I/O during sync.
- Redesigned the "Folder Sync" UI in `LibraryScreen` to display a list of active folders with individual management options and a 3-folder limit.
- Refined permission handling to request both read and write access for synced folders.
This commit is contained in:
Aryan 2026-03-08 17:56:09 +05:30 committed by GitHub
parent 76780bff31
commit 6ea414c9b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 426 additions and 298 deletions

View file

@ -55,28 +55,57 @@ class FolderSyncWorker(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false) val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
// Check if folder is still linked before starting
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) {
Timber.tag("FolderSync").w("Worker: Folder unlinked. Aborting work.") val jsonString = prefs.getString("synced_folders_list_json", null)
val folders = mutableListOf<String>()
if (jsonString != null) {
try {
val array = org.json.JSONArray(jsonString)
for (i in 0 until array.length()) {
folders.add(array.getJSONObject(i).getString("uri"))
}
} catch (e: Exception) { Timber.e(e) }
} else {
val single = prefs.getString("synced_folder_uri", null)
if (single != null) folders.add(single)
}
if (folders.isEmpty()) {
Timber.tag("FolderSync").w("Worker: No folders linked. Aborting.")
return Result.success() return Result.success()
} }
Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...") Timber.tag("FolderSync").d("Worker: processing ${folders.size} folders.")
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
syncMutex.withLock { syncMutex.withLock {
Timber.tag("FolderSync").d("Worker: Lock acquired. Starting Sync.") var allSuccess = true
performSync(isMetadataOnly)
for (uriString in folders) {
val success = performSyncForFolder(uriString, isMetadataOnly)
if (!success) allSuccess = false
}
if (jsonString != null) {
try {
val array = org.json.JSONArray(jsonString)
val now = System.currentTimeMillis()
for (i in 0 until array.length()) {
array.getJSONObject(i).put("lastScanTime", now)
}
prefs.edit { putString("synced_folders_list_json", array.toString()) }
} catch (_: Exception) {}
}
if (allSuccess) Result.success() else Result.failure()
} }
} }
} }
private suspend fun performSync(metadataOnly: Boolean): Result { private suspend fun performSyncForFolder(folderUriString: String, metadataOnly: Boolean): Boolean {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) if (folderUriString.isBlank()) return true
val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null)
if (folderUriString.isNullOrBlank()) return Result.success()
val folderUri = folderUriString.toUri() val folderUri = folderUriString.toUri()
try { try {
@ -86,17 +115,20 @@ class FolderSyncWorker(
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
) )
} catch (_: SecurityException) { } catch (_: SecurityException) {
return Result.failure() return false
} }
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri) val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
if (documentTree == null || !documentTree.isDirectory) { if (documentTree == null || !documentTree.isDirectory) {
return Result.failure() return false
} }
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...") Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...")
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree)
folderMetadataMap.forEach { (bookId, remoteMeta) -> folderMetadataMap.forEach { (bookId, remoteMeta) ->
val existingItem = recentFilesRepository.getFileByBookId(bookId) val existingItem = recentFilesRepository.getFileByBookId(bookId)
@ -120,19 +152,18 @@ class FolderSyncWorker(
} }
} }
// Phase 1.5: Sync Annotations for existing books (Runs in both Metadata-Only and Full modes)
Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...") Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...")
val processedBookIds = mutableSetOf<String>() val processedBookIds = mutableSetOf<String>()
val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
for (book in existingFolderBooks) { for (book in existingFolderBooks) {
processedBookIds.add(book.bookId) processedBookIds.add(book.bookId)
val sidecarData = LocalSyncUtils.getAnnotationSidecar(appContext, folderUri, book.bookId)
val sidecarData = preloadedSidecars[book.bookId]
if (sidecarData != null) { if (sidecarData != null) {
val (remoteTs, jsonPayload) = sidecarData val (remoteTs, jsonPayload) = sidecarData
// Check timestamps of ALL potential local annotation files
val localFiles = listOf( val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"), File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"), File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
@ -141,7 +172,7 @@ class FolderSyncWorker(
) )
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
if (remoteTs > (localTs + 1000)) { // 1s buffer if (remoteTs > (localTs + 1000)) {
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.") Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload) recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
} else { } else {
@ -217,7 +248,7 @@ class FolderSyncWorker(
} }
if (!processedBookIds.contains(stableId)) { if (!processedBookIds.contains(stableId)) {
val sidecarData = LocalSyncUtils.getAnnotationSidecar(appContext, folderUri, stableId) val sidecarData = preloadedSidecars[stableId]
if (sidecarData != null) { if (sidecarData != null) {
val (remoteTs, jsonPayload) = sidecarData val (remoteTs, jsonPayload) = sidecarData
@ -238,7 +269,6 @@ class FolderSyncWorker(
} }
} }
// Cleanup removed files
if (!isStopped) { if (!isStopped) {
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId } val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
@ -250,8 +280,6 @@ class FolderSyncWorker(
} }
} }
prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) }
if (!isStopped) { if (!isStopped) {
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.") Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build() val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
@ -262,11 +290,11 @@ class FolderSyncWorker(
) )
} }
return Result.success() return true
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.") Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
return Result.failure() return false
} }
} }

View file

@ -345,16 +345,15 @@ fun HomeScreen(
onRefresh = { viewModel.refreshLibrary() }, onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing, isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled, isSyncEnabled = uiState.isSyncEnabled,
hasSyncedFolder = uiState.syncedFolderUri != null hasSyncedFolder = uiState.syncedFolders.isNotEmpty()
) )
} }
} }
// Loading Indicator Overlay
if (uiState.isLoading) { if (uiState.isLoading) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f) // Semi-transparent overlay color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f)
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View file

@ -60,7 +60,6 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -154,7 +153,7 @@ fun LibraryScreen(
contract = ActivityResultContracts.OpenDocumentTree() contract = ActivityResultContracts.OpenDocumentTree()
) { uri -> ) { uri ->
uri?.let { uri?.let {
viewModel.setSyncedFolder(it) viewModel.addSyncedFolder(it)
} }
} }
@ -264,9 +263,11 @@ fun LibraryScreen(
onScanNowClick = viewModel::scanSyncedFolder, onScanNowClick = viewModel::scanSyncedFolder,
onSyncMetadataClick = viewModel::syncFolderMetadata, onSyncMetadataClick = viewModel::syncFolderMetadata,
onSelectSyncFolderClick = onSelectSyncFolderClick, onSelectSyncFolderClick = onSelectSyncFolderClick,
onDisconnectSyncFolderClick = viewModel::disconnectSyncedFolder, syncedFolders = uiState.syncedFolders,
onAddFolderClick = { uri -> viewModel.addSyncedFolder(uri) },
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds, downloadingBookIds = uiState.downloadingBookIds,
syncedFolderUri = uiState.syncedFolderUri,
lastFolderScanTime = uiState.lastFolderScanTime, lastFolderScanTime = uiState.lastFolderScanTime,
isLoading = uiState.isLoading isLoading = uiState.isLoading
) )
@ -461,9 +462,11 @@ fun LibraryScreenContent(
onSelectSyncFolderClick: () -> Unit, onSelectSyncFolderClick: () -> Unit,
onDisconnectSyncFolderClick: () -> Unit, onDisconnectSyncFolderClick: () -> Unit,
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
syncedFolderUri: String?,
lastFolderScanTime: Long?, lastFolderScanTime: Long?,
isLoading: Boolean, isLoading: Boolean,
syncedFolders: List<SyncedFolder>,
onAddFolderClick: (android.net.Uri) -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
) { ) {
val isBookContextualModeActive = selectedItems.isNotEmpty() val isBookContextualModeActive = selectedItems.isNotEmpty()
val isShelfContextualModeActive = selectedShelves.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty()
@ -666,13 +669,11 @@ fun LibraryScreenContent(
} }
2 -> { 2 -> {
FolderSyncScreen( FolderSyncScreen(
syncedFolderUri = syncedFolderUri, syncedFolders = syncedFolders,
lastScanTime = lastFolderScanTime, onAddFolderClick = onAddFolderClick,
onSelectFolderClick = onSelectSyncFolderClick, onRemoveFolderClick = onRemoveFolderClick,
onScanNowClick = onScanNowClick, onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick, onSyncMetadataClick = onSyncMetadataClick,
onChangeFolderClick = onSelectSyncFolderClick,
onDisconnectClick = onDisconnectSyncFolderClick,
isLoading = isLoading isLoading = isLoading
) )
} }
@ -1382,182 +1383,43 @@ private fun getDisplayPathFromUri(context: Context, uriString: String): String {
@Composable @Composable
private fun FolderSyncScreen( private fun FolderSyncScreen(
syncedFolderUri: String?, syncedFolders: List<SyncedFolder>,
lastScanTime: Long?, onAddFolderClick: (android.net.Uri) -> Unit,
onSelectFolderClick: () -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit,
onScanNowClick: () -> Unit, onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit, onSyncMetadataClick: () -> Unit,
onChangeFolderClick: () -> Unit,
onDisconnectClick: () -> Unit,
isLoading: Boolean isLoading: Boolean
) { ) {
val context = LocalContext.current val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
onAddFolderClick(it)
}
}
if (syncedFolderUri == null) { LocalContext.current
EmptyState(
title = "Sync Local Folder", Scaffold(
message = "Connect a folder to create a live library. Episteme will automatically monitor your files for new additions and keep your reading progress and other book metadata in sync with your folder.", floatingActionButton = {
onSelectFileClick = onSelectFolderClick, if (syncedFolders.size < 3) {
primaryButtonText = "Select Folder", ExtendedFloatingActionButton(
modifier = Modifier.fillMaxSize() text = { Text("Add Folder") },
icon = { Icon(Icons.Default.Add, "Add") },
onClick = { pickFolderLauncher.launch(null) }
) )
} else {
val folderPath = remember(syncedFolderUri) {
getDisplayPathFromUri(context, syncedFolderUri)
}
// Calculate times
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = remember(lastScanTime) {
if (lastScanTime == null || lastScanTime == 0L) "Never"
else dateFormat.format(Date(lastScanTime))
}
val nextScanText = remember(lastScanTime) {
if (lastScanTime == null || lastScanTime == 0L) "Pending first scan..."
else {
// Adding 4 hours (4 * 60 * 60 * 1000) to match the Worker interval
val nextTime = lastScanTime + (4 * 60 * 60 * 1000)
dateFormat.format(Date(nextTime))
} }
} }
) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// 1. Status Dashboard Card
androidx.compose.material3.ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Header Row with Status // Global Actions Header
Row( if (syncedFolders.isNotEmpty()) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.FolderSpecial,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Active Sync",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
// Status Indicator
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = androidx.compose.foundation.shape.CircleShape
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(6.dp))
Text(
"Scanning...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
} else {
Box(
modifier = Modifier
.size(8.dp)
.background(
Color(0xFF4CAF50), // Green for active
androidx.compose.foundation.shape.CircleShape
)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
"Monitoring",
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// Folder Path
Column {
Text(
text = "LOCATION",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = folderPath,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
// Times Grid
Row(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "LAST CHECK",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = "NEXT AUTO SYNC",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(text = nextScanText, style = MaterialTheme.typography.bodySmall)
}
}
}
}
// 2. Main Actions
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Actions",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp)
)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
@ -1568,13 +1430,13 @@ private fun FolderSyncScreen(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small shape = MaterialTheme.shapes.small
) { ) {
Icon( if (isLoading) {
imageVector = Icons.Default.Search, CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
contentDescription = null, } else {
modifier = Modifier.size(18.dp) Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp))
) }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text("Scan Files") Text(if (isLoading) "Scanning..." else "Scan All")
} }
androidx.compose.material3.OutlinedButton( androidx.compose.material3.OutlinedButton(
@ -1583,45 +1445,121 @@ private fun FolderSyncScreen(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small shape = MaterialTheme.shapes.small
) { ) {
Icon( Icon(painterResource(id = R.drawable.sync), null, modifier = Modifier.size(18.dp))
painter = painterResource(id = R.drawable.sync),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text("Sync Data") Text("Sync Meta")
}
}
} else {
EmptyState(
title = "Sync Local Folders",
message = "Connect local folders to create a live library. Episteme will monitor files and sync progress.",
onSelectFileClick = { pickFolderLauncher.launch(null) },
primaryButtonText = "Select Folder",
modifier = Modifier.fillMaxSize()
)
}
// List of Folders
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB
) {
items(syncedFolders, key = { it.uriString }) { folder ->
FolderCard(folder, onRemoveFolderClick)
} }
} }
} }
}
}
Spacer(modifier = Modifier.weight(1f)) @Composable
private fun FolderCard(
Column { folder: SyncedFolder,
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) onRemoveClick: (SyncedFolder) -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = if (folder.lastScanTime == 0L) "Never" else dateFormat.format(Date(folder.lastScanTime))
androidx.compose.material3.ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
TextButton(onClick = onChangeFolderClick, enabled = !isLoading) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Text("Change Folder") Icon(
imageVector = Icons.Default.FolderSpecial,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = folder.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} }
TextButton( Box {
onClick = onDisconnectClick, IconButton(onClick = { showMenu = true }) {
enabled = !isLoading, Icon(Icons.Default.MoreVert, "Options")
colors = ButtonDefaults.textButtonColors( }
contentColor = MaterialTheme.colorScheme.error DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
DropdownMenuItem(
text = { Text("Remove Folder") },
onClick = {
showMenu = false
onRemoveClick(folder)
},
colors = androidx.compose.material3.MenuDefaults.itemColors(
textColor = MaterialTheme.colorScheme.error
) )
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.size(16.dp)
) )
Spacer(modifier = Modifier.width(8.dp)) }
Text("Disconnect") }
}
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
// Details
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "LAST SYNC",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
}
// You could add Book Count here if we queried it from DB
// For now, let's just show Status
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Text(
text = "STATUS",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(6.dp)
.background(Color(0xFF4CAF50), androidx.compose.foundation.shape.CircleShape)
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = "Active", style = MaterialTheme.typography.bodySmall)
} }
} }
} }

View file

@ -30,6 +30,7 @@ import android.content.SharedPreferences
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.DocumentsContract
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
@ -133,6 +134,12 @@ data class DeviceLimitReachedState(
val isLimitReached: Boolean = false, val registeredDevices: List<DeviceItem> = emptyList() val isLimitReached: Boolean = false, val registeredDevices: List<DeviceItem> = emptyList()
) )
data class SyncedFolder(
val uriString: String,
val name: String,
val lastScanTime: Long
)
data class Shelf(val name: String, val books: List<RecentFileItem>) { data class Shelf(val name: String, val books: List<RecentFileItem>) {
val bookCount: Int val bookCount: Int
get() = books.size get() = books.size
@ -185,7 +192,7 @@ data class ReaderScreenState(
val isRequestingDrivePermission: Boolean = false, val isRequestingDrivePermission: Boolean = false,
val downloadingBookIds: Set<String> = emptySet(), val downloadingBookIds: Set<String> = emptySet(),
val uploadingBookIds: Set<String> = emptySet(), val uploadingBookIds: Set<String> = emptySet(),
val syncedFolderUri: String? = null, val syncedFolders: List<SyncedFolder> = emptyList(),
val lastFolderScanTime: Long? = null, val lastFolderScanTime: Long? = null,
val hasUnreadFeedback: Boolean = false, val hasUnreadFeedback: Boolean = false,
val searchQuery: String = "", val searchQuery: String = "",
@ -269,7 +276,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentUser = authRepository.getSignedInUser(), currentUser = authRepository.getSignedInUser(),
isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false), isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false),
isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false), isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false),
syncedFolderUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null), syncedFolders = loadSyncedFoldersFromPrefs(),
lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong( lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong(
KEY_LAST_FOLDER_SCAN_TIME, KEY_LAST_FOLDER_SCAN_TIME,
0L 0L
@ -453,7 +460,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
): PageModificationResult = withContext(Dispatchers.Default) { ): PageModificationResult = withContext(Dispatchers.Default) {
Timber.d("Removing page at index $removeIndex for book $bookId") Timber.d("Removing page at index $removeIndex for book $bookId")
// 1. Update Layout
val newLayout = currentLayout.toMutableList() val newLayout = currentLayout.toMutableList()
if (removeIndex in newLayout.indices) { if (removeIndex in newLayout.indices) {
newLayout.removeAt(removeIndex) newLayout.removeAt(removeIndex)
@ -463,7 +469,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
} }
// 2. Shift Annotations (Delete for removed page, decrement for pages > removed)
val newAnnotations = mutableMapOf<Int, List<PdfAnnotation>>() val newAnnotations = mutableMapOf<Int, List<PdfAnnotation>>()
currentAnnotations.forEach { (pageIdx, annots) -> currentAnnotations.forEach { (pageIdx, annots) ->
if (pageIdx != removeIndex) { if (pageIdx != removeIndex) {
@ -473,7 +478,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
// 3. Shift Bookmarks
val newTotalPages = newLayout.size val newTotalPages = newLayout.size
val newBookmarksJson = try { val newBookmarksJson = try {
if (currentBookmarksJson.isNotBlank()) { if (currentBookmarksJson.isNotBlank()) {
@ -504,7 +508,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentBookmarksJson currentBookmarksJson
} }
// 4. Save Layout persistently
pageLayoutRepository.saveLayout(bookId, newLayout) pageLayoutRepository.saveLayout(bookId, newLayout)
PageModificationResult(newLayout, newAnnotations, newBookmarksJson) PageModificationResult(newLayout, newAnnotations, newBookmarksJson)
@ -528,9 +531,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val isMigrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false) val isMigrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false)
if (_internalState.value.syncedFolderUri != null) { if (_internalState.value.syncedFolders.isNotEmpty()) {
if (isMigrationCompleted) { if (isMigrationCompleted) {
Timber.d("App Start: Triggering local folder metadata-only sync.")
syncFolderMetadata() syncFolderMetadata()
} else { } else {
Timber.d("App Start: Skipping sync. Waiting for migration/detachment logic.") Timber.d("App Start: Skipping sync. Waiting for migration/detachment logic.")
@ -586,10 +588,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
} }
val folderUri = _internalState.value.syncedFolderUri
val migrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false) val migrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false)
if (folderUri != null && !migrationCompleted) { val hasFolders = _internalState.value.syncedFolders.isNotEmpty()
if (hasFolders && !migrationCompleted) {
Timber.tag("FolderSync").d("First time after refactor: Showing migration dialog.") Timber.tag("FolderSync").d("First time after refactor: Showing migration dialog.")
_internalState.update { it.copy(showFolderMigrationDialog = true) } _internalState.update { it.copy(showFolderMigrationDialog = true) }
} }
@ -608,6 +610,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
private fun getDisplayPathFromUri(context: Context, uriString: String): String {
val uri = uriString.toUri()
val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder"
if (DocumentsContract.isTreeUri(uri) && DocumentsContract.getTreeDocumentId(uri).isNotEmpty()) {
val documentId = DocumentsContract.getTreeDocumentId(uri)
val split = documentId.split(":")
if (split.size > 1) {
return split[1]
}
}
return fallbackName
}
private val fontsRepository = FontsRepository(appContext) private val fontsRepository = FontsRepository(appContext)
val customFonts = fontsRepository.getAllFonts().stateIn( val customFonts = fontsRepository.getAllFonts().stateIn(
@ -1268,20 +1283,83 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
fun setSyncedFolder(folderUri: Uri) { private fun loadSyncedFoldersFromPrefs(): List<SyncedFolder> {
val jsonString = prefs.getString(KEY_SYNCED_FOLDERS_JSON, null)
val folders = mutableListOf<SyncedFolder>()
if (jsonString == null && prefs.contains(KEY_SYNCED_FOLDER_URI)) {
val oldUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null)
val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L)
if (oldUri != null) {
val name = getDisplayPathFromUri(appContext, oldUri)
val migrated = SyncedFolder(oldUri, name, oldTime)
folders.add(migrated)
saveSyncedFoldersToPrefs(folders)
prefs.edit {
remove(KEY_SYNCED_FOLDER_URI)
remove(KEY_LAST_FOLDER_SCAN_TIME)
}
}
} else if (jsonString != null) {
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
folders.add(
SyncedFolder(
uriString = obj.getString("uri"),
name = obj.getString("name"),
lastScanTime = obj.optLong("lastScanTime", 0L)
)
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse synced folders JSON")
}
}
return folders
}
private fun saveSyncedFoldersToPrefs(folders: List<SyncedFolder>) {
val jsonArray = JSONArray()
folders.forEach { folder ->
val obj = JSONObject()
obj.put("uri", folder.uriString)
obj.put("name", folder.name)
obj.put("lastScanTime", folder.lastScanTime)
jsonArray.put(obj)
}
prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) }
}
fun addSyncedFolder(folderUri: Uri) {
val currentFolders = _internalState.value.syncedFolders
if (currentFolders.size >= MAX_FOLDER_LIMIT) {
showBanner("Limit reached: Maximum $MAX_FOLDER_LIMIT folders allowed.", isError = true)
return
}
if (currentFolders.any { it.uriString == folderUri.toString() }) {
showBanner("This folder is already synced.", isError = true)
return
}
viewModelScope.launch { viewModelScope.launch {
try { try {
appContext.contentResolver.takePersistableUriPermission( appContext.contentResolver.takePersistableUriPermission(
folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
) )
Timber.d("Persistable URI permission taken for folder: $folderUri")
prefs.edit { val name = getDisplayPathFromUri(appContext, folderUri.toString())
putString(KEY_SYNCED_FOLDER_URI, folderUri.toString()) val newFolder = SyncedFolder(folderUri.toString(), name, 0L)
putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true) val newStats = currentFolders + newFolder
}
saveSyncedFoldersToPrefs(newStats)
_internalState.update { it.copy( _internalState.update { it.copy(
syncedFolderUri = folderUri.toString(), syncedFolders = newStats,
showFolderMigrationDialog = false showFolderMigrationDialog = false
) } ) }
@ -1289,23 +1367,47 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val workManager = WorkManager.getInstance(appContext) val workManager = WorkManager.getInstance(appContext)
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
val syncRequest = PeriodicWorkRequestBuilder<FolderSyncWorker>(4, TimeUnit.HOURS)
val syncRequest = .setConstraints(constraints)
PeriodicWorkRequestBuilder<FolderSyncWorker>(4, TimeUnit.HOURS).setConstraints( .build()
constraints
).build()
workManager.enqueueUniquePeriodicWork( workManager.enqueueUniquePeriodicWork(
FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.REPLACE, syncRequest FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, syncRequest
) )
Timber.d("Scheduled periodic folder sync worker.")
showBanner("Folder added: $name")
} catch (e: SecurityException) { } catch (e: SecurityException) {
Timber.e(e, "Failed to take persistable URI permission for $folderUri") Timber.e(e, "Failed to take permissions for $folderUri")
_internalState.update { showBanner("Failed to access folder permissions.", isError = true)
it.copy(errorMessage = "Could not get permission for the selected folder.")
} }
} }
} }
fun removeSyncedFolder(folder: SyncedFolder) {
viewModelScope.launch {
val currentFolders = _internalState.value.syncedFolders.toMutableList()
currentFolders.removeAll { it.uriString == folder.uriString }
saveSyncedFoldersToPrefs(currentFolders)
_internalState.update { it.copy(syncedFolders = currentFolders) }
// Cleanup
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
try {
appContext.contentResolver.releasePersistableUriPermission(
folder.uriString.toUri(),
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: Exception) {
Timber.w("Failed to release permissions: ${e.message}")
}
if (currentFolders.isEmpty()) {
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
}
showBanner("Folder removed.")
}
} }
fun syncFolderMetadata() { fun syncFolderMetadata() {
@ -1317,8 +1419,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
private fun triggerFolderSyncWorker(metadataOnly: Boolean) { private fun triggerFolderSyncWorker(metadataOnly: Boolean) {
@Suppress("UnusedVariable", "Unused") val folderUriString = _internalState.value.syncedFolderUri ?: return val folders = _internalState.value.syncedFolders
Timber.tag("FolderSync").d("Requesting folder sync (metadataOnly=$metadataOnly)") if (folders.isEmpty()) return
Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly)")
val workManager = WorkManager.getInstance(appContext) val workManager = WorkManager.getInstance(appContext)
val data = androidx.work.Data.Builder() val data = androidx.work.Data.Builder()
@ -1369,35 +1473,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
fun disconnectSyncedFolder() { fun disconnectAllSyncedFolders() {
viewModelScope.launch { viewModelScope.launch {
val folderUriString = _internalState.value.syncedFolderUri val folders = _internalState.value.syncedFolders
folders.forEach { folder ->
Timber.tag("FolderSync").d("Cancelling all folder sync workers...") recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME) try {
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME) appContext.contentResolver.releasePersistableUriPermission(
WorkManager.getInstance(appContext).cancelUniqueWork(MetadataExtractionWorker.WORK_NAME) folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (_: Exception) {}
}
prefs.edit { prefs.edit {
remove(KEY_SYNCED_FOLDERS_JSON)
remove(KEY_SYNCED_FOLDER_URI) remove(KEY_SYNCED_FOLDER_URI)
remove(KEY_LAST_FOLDER_SCAN_TIME)
} }
_internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) } _internalState.update { it.copy(syncedFolders = emptyList()) }
if (folderUriString != null) { WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.")
recentFilesRepository.deleteFilesBySourceFolder(folderUriString)
try {
val uri = folderUriString.toUri()
val contentResolver = appContext.contentResolver
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.releasePersistableUriPermission(uri, takeFlags)
Timber.tag("FolderSync").d("Released permission for: $uri")
} catch (e: Exception) {
Timber.e(e, "Failed to release permission")
}
}
} }
} }
@ -2640,11 +2734,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun refreshLibrary() { fun refreshLibrary() {
val syncEnabled = _internalState.value.isSyncEnabled val syncEnabled = _internalState.value.isSyncEnabled
val hasFolder = _internalState.value.syncedFolderUri != null // Check for URI instead of toggle val hasFolder = _internalState.value.syncedFolders.isNotEmpty()
if (!syncEnabled && !hasFolder) { if (!syncEnabled && !hasFolder) {
Timber.d("Refresh skipped: No sync methods active.") Timber.d("Refresh skipped: No sync methods active.")
_internalState.update { it.copy(isRefreshing = false) } // Ensure indicator retracts immediately _internalState.update { it.copy(isRefreshing = false) }
return return
} }
@ -2657,14 +2751,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
if (hasFolder) { if (hasFolder) {
// This triggers the worker which we observe above to clear isRefreshing
syncFolderMetadata() syncFolderMetadata()
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Refresh failed") Timber.e(e, "Refresh failed")
_internalState.update { it.copy(isRefreshing = false) } _internalState.update { it.copy(isRefreshing = false) }
} finally { } finally {
// If folder sync isn't running, we must close the indicator here
if (!hasFolder) { if (!hasFolder) {
_internalState.update { it.copy(isRefreshing = false) } _internalState.update { it.copy(isRefreshing = false) }
} }
@ -3346,8 +3438,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
private const val KEY_MIGRATION_CHECKED_UIDS = "migration_checked_uids" private const val KEY_MIGRATION_CHECKED_UIDS = "migration_checked_uids"
private const val KEY_INSTALLATION_ID = "installation_id" private const val KEY_INSTALLATION_ID = "installation_id"
private const val KEY_APP_OPEN_COUNT = "app_open_count"
internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri" internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri"
internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time" internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
private const val KEY_APP_OPEN_COUNT = "app_open_count" private const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
private const val MAX_FOLDER_LIMIT = 3
} }
} }

View file

@ -29,8 +29,12 @@ class MetadataExtractionWorker(
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) {
Timber.tag("MetadataWorker").w("Folder disconnected. Stopping extraction worker.") val hasLegacy = prefs.contains("synced_folder_uri")
val hasNew = prefs.contains("synced_folders_list_json")
if (!hasLegacy && !hasNew) {
Timber.tag("MetadataWorker").w("No folders linked. Stopping.")
return@withContext Result.success() return@withContext Result.success()
} }
@ -46,7 +50,7 @@ class MetadataExtractionWorker(
filesToProcess.forEach { item -> filesToProcess.forEach { item ->
if (isStopped) return@forEach if (isStopped) return@forEach
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) return@forEach if (item.sourceFolderUri == null) return@forEach
try { try {
val uri = item.uriString?.toUri() ?: return@forEach val uri = item.uriString?.toUri() ?: return@forEach

View file

@ -184,6 +184,71 @@ object LocalSyncUtils {
} }
} }
suspend fun preloadAnnotationSidecars(
context: Context,
rootTree: DocumentFile
): Map<String, Pair<Long, String>> = withContext(Dispatchers.IO) {
val results = mutableMapOf<String, Pair<Long, String>>()
try {
val allFiles = rootTree.listFiles()
val annotationFiles = allFiles.filter { file ->
val name = file.name ?: ""
name.contains(ANNOTATION_SUFFIX) && name.endsWith(".json") && !name.endsWith(".tmp")
}
val filesByBookId = annotationFiles.groupBy { file ->
val name = file.name ?: ""
var temp = name.substringBeforeLast(".json")
if (temp.contains(".sync-conflict")) {
temp = temp.substringBefore(".sync-conflict")
}
if (temp.endsWith(ANNOTATION_SUFFIX)) {
temp = temp.substring(0, temp.length - ANNOTATION_SUFFIX.length)
}
if (temp.startsWith(".")) {
temp = temp.substring(1)
}
temp
}
filesByBookId.forEach { (bookId, files) ->
if (bookId.isNotBlank()) {
var bestTs = -1L
var bestData: String? = null
for (file in files) {
try {
val content = context.contentResolver.openInputStream(file.uri)?.use {
it.bufferedReader().readText()
} ?: continue
val json = JSONObject(content)
val ts = json.optLong("timestamp", 0L)
val data = json.optJSONObject("data")?.toString()
if (data != null && ts > bestTs) {
bestTs = ts
bestData = data
}
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error parsing preloaded file: ${file.name}")
}
}
if (bestData != null) {
results[bookId] = Pair(bestTs, bestData)
}
}
}
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
}
return@withContext results
}
suspend fun getAnnotationSidecar( suspend fun getAnnotationSidecar(
context: Context, context: Context,
sourceFolderUri: Uri, sourceFolderUri: Uri,