Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,6 @@ interface BackgroundJobManager {
fun scheduleInternal2WaySync(intervalMinutes: Long)
fun cancelAllFilesDownloadJobs()
fun startMetadataSyncJob(currentDirPath: String)
fun downloadFolder(folder: OCFile, accountName: String)
fun downloadFolder(folder: OCFile, accountName: String, recursive: Boolean = false)
fun cancelFolderDownload()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.ListenableWorker
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest
Expand Down Expand Up @@ -795,25 +796,62 @@ internal class BackgroundJobManagerImpl(
workManager.enqueueUniquePeriodicWork(JOB_INTERNAL_TWO_WAY_SYNC, ExistingPeriodicWorkPolicy.UPDATE, request)
}

override fun downloadFolder(folder: OCFile, accountName: String) {
override fun downloadFolder(folder: OCFile, accountName: String, recursive: Boolean) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresStorageNotLow(true)
.build()

val data = Data.Builder()
// Prepare input data for FolderDownloadWorker
val downloadData = Data.Builder()
.putLong(FolderDownloadWorker.FOLDER_ID, folder.fileId)
.putString(FolderDownloadWorker.ACCOUNT_NAME, accountName)
.putBoolean(FolderDownloadWorker.RECURSIVE_DOWNLOAD, recursive)
.build()

// Prepare input data for MetadataWorker (needs folder path to sync metadata)
// IMPORTANT: Add FORCE_REFRESH flag to ensure MetadataWorker fetches content
// regardless of eTag - this fixes the issue where FolderDownloadWorker runs
// before database is populated with file entries
val metadataData = Data.Builder()
.putString(MetadataWorker.FILE_PATH, folder.remotePath)
.putBoolean(MetadataWorker.FORCE_REFRESH, true) // Force full refresh
.build()

val metadataConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()

val request = oneTimeRequestBuilder(FolderDownloadWorker::class, JOB_DOWNLOAD_FOLDER)
// Create MetadataWorker request - this will sync folder metadata to database first
val metadataWork = OneTimeWorkRequestBuilder<MetadataWorker>()
.addTag(TAG_ALL)
.addTag(formatNameTag(JOB_METADATA_SYNC))
.addTag(formatTimeTag(clock.currentTime))
.addTag(formatClassTag(MetadataWorker::class))
.setConstraints(metadataConstraints)
.setInputData(metadataData)
.build()

// Create FolderDownloadWorker request
val downloadWork = OneTimeWorkRequestBuilder<FolderDownloadWorker>()
.addTag(TAG_ALL)
.addTag(formatNameTag(JOB_DOWNLOAD_FOLDER))
.addTag(formatTimeTag(clock.currentTime))
.addTag(formatClassTag(FolderDownloadWorker::class))
.addTag(JOB_DOWNLOAD_FOLDER)
.setInputData(data)
.setInputData(downloadData)
.setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()

workManager.enqueueUniqueWork(JOB_DOWNLOAD_FOLDER, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
// Use WorkChain to ensure MetadataWorker completes before FolderDownloadWorker starts
// This fixes the race condition where FolderDownloadWorker was running before
// MetadataWorker populated the database with folder contents
workManager
.beginWith(metadataWork)
.then(downloadWork)
.enqueue()
}

override fun cancelFolderDownload() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,12 @@ class FileDownloadHelper {
)
}

fun downloadFolder(folder: OCFile?, accountName: String) {
fun downloadFolder(folder: OCFile?, accountName: String, recursive: Boolean = false) {
if (folder == null) {
Log_OC.e(TAG, "folder cannot be null, cant sync")
return
}
backgroundJobManager.downloadFolder(folder, accountName)
backgroundJobManager.downloadFolder(folder, accountName, recursive)
}

fun cancelFolderDownload() = backgroundJobManager.cancelFolderDownload()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class FolderDownloadWorker(
private const val TAG = "📂" + "FolderDownloadWorker"
const val FOLDER_ID = "FOLDER_ID"
const val ACCOUNT_NAME = "ACCOUNT_NAME"
const val RECURSIVE_DOWNLOAD = "RECURSIVE_DOWNLOAD"

private val pendingDownloads: MutableSet<Long> = ConcurrentHashMap.newKeySet<Long>()

Expand All @@ -61,6 +62,8 @@ class FolderDownloadWorker(
return Result.failure()
}

val recursiveDownload = inputData.getBoolean(RECURSIVE_DOWNLOAD, false)

val optionalUser = accountManager.getUser(accountName)
if (optionalUser.isEmpty) {
Log_OC.e(TAG, "failed user is not present")
Expand All @@ -75,7 +78,7 @@ class FolderDownloadWorker(
return Result.failure()
}

Log_OC.d(TAG, "🕒 started for ${user.accountName} downloading ${folder.fileName}")
Log_OC.d(TAG, "🕒 started for ${user.accountName} downloading ${folder.fileName} (recursive: $recursiveDownload)")

trySetForeground(folder)

Expand All @@ -85,7 +88,13 @@ class FolderDownloadWorker(

return withContext(Dispatchers.IO) {
try {
val files = getFiles(folder, storageManager)
val files = getFiles(folder, storageManager, recursiveDownload)

// Add warning log when no files found for recursive download
if (files.isEmpty()) {
Log_OC.w(TAG, "⚠️ No files found for recursive download in folder: ${folder.fileName}")
}

val account = user.toOwnCloudAccount()
val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(account, context)

Expand Down Expand Up @@ -182,9 +191,40 @@ class FolderDownloadWorker(
return file
}

private fun getFiles(folder: OCFile, storageManager: FileDataStorageManager): List<OCFile> =
storageManager.getFolderContent(folder, false)
.filter { !it.isFolder && !it.isDown }
private fun getFiles(folder: OCFile, storageManager: FileDataStorageManager, recursive: Boolean): List<OCFile> {
if (recursive) {
return getAllFilesRecursive(folder, storageManager)
}
// Use folder ID to avoid fileExists() check
return storageManager.getFolderContent(folder.fileId, false)
.filter { !it.isFolder }
}

/**
* Recursively get all files in the folder and its subfolders
*/
private fun getAllFilesRecursive(folder: OCFile, storageManager: FileDataStorageManager): List<OCFile> {
val result = mutableListOf<OCFile>()

// Use the folder ID directly to avoid fileExists() check that fails for subfolders not yet downloaded
val folderContent = storageManager.getFolderContent(folder.fileId, false)

Log_OC.d(TAG, "📂 getAllFilesRecursive: folder=${folder.fileName}, folderId=${folder.fileId}, contentCount=${folderContent.size}")

for (file in folderContent) {
if (!file.isFolder) {
// Add all files, regardless of download status, to ensure subfolders are synced
result.add(file)
} else {
Log_OC.d(TAG, "📂 Found subfolder: ${file.fileName}, recursing...")
// Recursively process subfolders
result.addAll(getAllFilesRecursive(file, storageManager))
}
}

Log_OC.d(TAG, "📂 getAllFilesRecursive: returning ${result.size} files from folder ${folder.fileName}")
return result
}

private fun checkDiskSize(file: OCFile): Boolean {
val fileSizeInByte = file.fileLength
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.RefreshFolderOperation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.LinkedList
import java.util.Queue

@Suppress("DEPRECATION", "ReturnCount", "TooGenericExceptionCaught")
class MetadataWorker(private val context: Context, params: WorkerParameters, private val user: User) :
Expand All @@ -26,6 +28,7 @@ class MetadataWorker(private val context: Context, params: WorkerParameters, pri
companion object {
private const val TAG = "MetadataWorker"
const val FILE_PATH = "file_path"
const val FORCE_REFRESH = "force_refresh" // When true, ignore eTag and always fetch content
}

override suspend fun doWork(): Result {
Expand All @@ -35,6 +38,10 @@ class MetadataWorker(private val context: Context, params: WorkerParameters, pri
return Result.failure()
}

// Check if we should force a full refresh (ignore eTag)
val forceRefresh = inputData.getBoolean(FORCE_REFRESH, false)
Log_OC.d(TAG, "📥 Force refresh mode: $forceRefresh for path: $filePath")

if (user.isAnonymous) {
Log_OC.w(TAG, "user is anonymous cannot start metadata worker")
return Result.failure()
Expand All @@ -54,7 +61,7 @@ class MetadataWorker(private val context: Context, params: WorkerParameters, pri
Log_OC.d(TAG, "🕒 Starting metadata sync for folder: $filePath, id: ${currentDir.fileId}")

// First check current dir
val currentRefreshResult = refreshFolder(currentDir, storageManager)
val currentRefreshResult = refreshFolder(currentDir, storageManager, forceRefresh)
if (!currentRefreshResult) {
Log_OC.e(TAG, "❌ Failed to refresh current directory: $filePath")
return Result.failure()
Expand All @@ -67,31 +74,72 @@ class MetadataWorker(private val context: Context, params: WorkerParameters, pri
return Result.failure()
}

// then get up-to-date subfolders
val subfolders = storageManager.getNonEncryptedSubfolders(refreshedDir.fileId, user.accountName)
Log_OC.d(TAG, "Found ${subfolders.size} subfolders to sync")

// IMPORTANT: Also fetch immediate files in the current folder, not just subfolders
// This ensures FolderDownloadWorker has access to files when it runs after MetadataWorker
val currentFolderFiles = storageManager.getFolderContent(refreshedDir.fileId, false)
.filter { !it.isFolder }
Log_OC.d(TAG, "Found ${currentFolderFiles.size} files in current folder: $filePath")

// Use BFS to recursively process ALL nested subfolders
// This ensures we fetch metadata for folders at all depth levels
// (e.g., Artists/ABBA, Artists/Beatles, etc.)
val folderQueue: Queue<OCFile> = LinkedList()

// Get the first level of subfolders
val initialSubfolders = storageManager.getNonEncryptedSubfolders(refreshedDir.fileId, user.accountName)
Log_OC.d(TAG, "Found ${initialSubfolders.size} top-level subfolders to sync")
folderQueue.addAll(initialSubfolders)

var processedCount = 0
var failedCount = 0
subfolders.forEach { subFolder ->

// BFS: Process all folders level by level
while (folderQueue.isNotEmpty()) {
val subFolder = folderQueue.poll() ?: continue
processedCount++

if (!subFolder.hasValidParentId()) {
Log_OC.e(TAG, "❌ Skipping subfolder with invalid ID: ${subFolder.remotePath}")
failedCount++
return@forEach
continue
}

val success = refreshFolder(subFolder, storageManager)
Log_OC.d(TAG, "📂 Processing folder (${processedCount}): ${subFolder.remotePath}")

// Refresh this folder
val success = refreshFolder(subFolder, storageManager, forceRefresh)
if (!success) {
Log_OC.e(TAG, "❌ Failed to refresh folder: ${subFolder.remotePath}")
failedCount++
}

// After refreshing, get this folder's subfolders and add them to the queue
// This enables recursive processing of ALL nested folders
val reloadedFolder = storageManager.getFileByPath(subFolder.remotePath)
if (reloadedFolder != null && reloadedFolder.hasValidParentId()) {
val nestedSubfolders = storageManager.getNonEncryptedSubfolders(reloadedFolder.fileId, user.accountName)
if (nestedSubfolders.isNotEmpty()) {
Log_OC.d(TAG, " └── Found ${nestedSubfolders.size} nested subfolders in: ${subFolder.remotePath}")
folderQueue.addAll(nestedSubfolders)
}

// Also fetch files in this subfolder (not just sub-subfolders)
// This ensures FolderDownloadWorker has access to all files at every level
val subfolderFiles = storageManager.getFolderContent(reloadedFolder.fileId, false)
.filter { !it.isFolder }
if (subfolderFiles.isNotEmpty()) {
Log_OC.d(TAG, " └── Found ${subfolderFiles.size} files in: ${subFolder.remotePath}")
}
}
}

Log_OC.d(TAG, "🏁 Metadata sync completed for folder: $filePath. Failed: $failedCount/${subfolders.size}")
Log_OC.d(TAG, "🏁 Metadata sync completed for folder: $filePath. Processed: $processedCount, Failed: $failedCount")

return Result.success()
}

@Suppress("DEPRECATION")
private suspend fun refreshFolder(folder: OCFile, storageManager: FileDataStorageManager): Boolean =
private suspend fun refreshFolder(folder: OCFile, storageManager: FileDataStorageManager, forceRefresh: Boolean = false): Boolean =
withContext(Dispatchers.IO) {
Log_OC.d(
TAG,
Expand All @@ -105,11 +153,17 @@ class MetadataWorker(private val context: Context, params: WorkerParameters, pri
return@withContext false
}

if (!folder.isEtagChanged) {
// Skip eTag check if forceRefresh is true
if (!forceRefresh && !folder.isEtagChanged) {
Log_OC.d(TAG, "Skipping ${folder.remotePath}, eTag didn't change")
return@withContext true
}

// If forceRefresh is true, log that we're doing a forced refresh
if (forceRefresh) {
Log_OC.d(TAG, "🔄 Forcing refresh for: ${folder.remotePath}, ignoring eTag")
}

Log_OC.d(TAG, "⏳ Fetching metadata for: ${folder.remotePath}, id: ${folder.fileId}")

val operation = RefreshFolderOperation(folder, storageManager, user, context)
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ enum class FileAction(
// Uploads and downloads
DOWNLOAD_FILE(R.id.action_download_file, R.string.filedetails_download, R.drawable.ic_cloud_download),
DOWNLOAD_FOLDER(R.id.action_sync_file, R.string.filedetails_sync_file, R.drawable.ic_sync),
DOWNLOAD_FOLDER_RECURSIVE(R.id.action_sync_file_recursive, R.string.filedetails_sync_file_recursive, R.drawable.ic_sync),
CANCEL_SYNC(R.id.action_cancel_sync, R.string.common_cancel_sync, R.drawable.ic_sync_off),

// File sharing
Expand Down Expand Up @@ -136,6 +137,7 @@ enum class FileAction(
if (file?.isFolder == true) {
result.add(R.id.action_send_file)
result.add(R.id.action_sync_file)
result.add(R.id.action_sync_file_recursive)
}

if (file?.isAPKorAAB == true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1192,7 +1192,7 @@ public void migrateStoredFiles(String sourcePath, String destinationPath)
}
}

private List<OCFile> getFolderContent(long parentId, boolean onlyOnDevice) {
public List<OCFile> getFolderContent(long parentId, boolean onlyOnDevice) {
Log_OC.d(TAG, "getFolderContent - start");
List<OCFile> folderContent = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ private void startDirectDownloads() {
Log_OC.d(TAG, "Exception caught at startDirectDownloads" + e);
}
} else {
fileDownloadHelper.downloadFolder(mLocalFolder, user.getAccountName());
fileDownloadHelper.downloadFolder(mLocalFolder, user.getAccountName(), false);
}
}

Expand Down
Loading