diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt index fcb567abc210..3dc90d77b318 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt @@ -8,12 +8,17 @@ package com.nextcloud.client.database.dao import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query import com.nextcloud.client.database.entity.FilesystemEntity import com.owncloud.android.db.ProviderMeta @Dao interface FileSystemDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(filesystemEntity: FilesystemEntity) + @Query( """ SELECT * @@ -37,4 +42,15 @@ interface FileSystemDao { """ ) suspend fun markFileAsUploaded(localPath: String, syncedFolderId: String) + + @Query( + """ + SELECT * + FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} + WHERE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH} = :localPath + AND ${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} = :syncedFolderId + LIMIT 1 + """ + ) + fun getFileByPathAndFolder(localPath: String, syncedFolderId: String): FilesystemEntity? } diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index b7d06ee2d9d3..3e67df51fa92 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -179,7 +179,7 @@ class BackgroundJobFactory @Inject constructor( powerManagementService = powerManagementService, syncedFolderProvider = syncedFolderProvider, backgroundJobManager = backgroundJobManager.get(), - repository = FileSystemRepository(dao = database.fileSystemDao()), + repository = FileSystemRepository(dao = database.fileSystemDao(), context), viewThemeUtils = viewThemeUtils.get() ) diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadHelper.kt index a43b524b9d16..15b23e20fbe7 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadHelper.kt @@ -7,9 +7,10 @@ package com.nextcloud.client.jobs.autoUpload -import com.nextcloud.utils.extensions.shouldSkipFile +import android.provider.MediaStore +import androidx.core.net.toUri import com.nextcloud.utils.extensions.toLocalPath -import com.owncloud.android.datamodel.FilesystemDataProvider +import com.owncloud.android.datamodel.MediaFolderType import com.owncloud.android.datamodel.SyncedFolder import com.owncloud.android.lib.common.utils.Log_OC import java.io.IOException @@ -29,7 +30,66 @@ class AutoUploadHelper { private const val MAX_DEPTH = 100 } - fun insertCustomFolderIntoDB(folder: SyncedFolder, filesystemDataProvider: FilesystemDataProvider?): Int { + fun insertEntries(folder: SyncedFolder, repository: FileSystemRepository) { + val enabledTimestampMs = folder.enabledTimestampMs + if (!folder.isEnabled || (!folder.isExisting && enabledTimestampMs < 0)) { + Log_OC.w( + TAG, + "Skipping insertDBEntries: enabled=${folder.isEnabled}, " + + "exists=${folder.isExisting}, enabledTs=$enabledTimestampMs" + ) + return + } + + when (folder.type) { + MediaFolderType.IMAGE -> { + repository.insertFromUri(MediaStore.Images.Media.INTERNAL_CONTENT_URI, folder) + repository.insertFromUri(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, folder) + } + + MediaFolderType.VIDEO -> { + repository.insertFromUri(MediaStore.Video.Media.INTERNAL_CONTENT_URI, folder) + repository.insertFromUri(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, folder) + } + + else -> { + insertCustomFolderIntoDB(folder, repository) + } + } + } + + /** + * Attempts to get the file path from a content URI string (e.g., content://media/external/images/media/2281) + * and checks its type. If the conditions are met, the file is stored for auto-upload. + *

+ * If any attempt fails, the method returns {@code false}. + * + * @param syncedFolder The folder marked for auto-upload. + * @param contentUris An array of content URI strings collected from + * {@link ContentObserverWork##checkAndTriggerAutoUpload()}. + * @return {@code true} if all changed content URIs were successfully stored; {@code false} otherwise. + */ + fun insertChangedEntries( + syncedFolder: SyncedFolder, + contentUris: Array?, + repository: FileSystemRepository + ): Boolean { + contentUris?.forEach { uriString -> + try { + val uri = uriString.toUri() + repository.insertFromUri(uri, syncedFolder, true) + } catch (e: Exception) { + Log_OC.e(TAG, "Invalid URI: $uriString", e) + return false + } + } + + Log_OC.d(TAG, "Changed content URIs successfully stored") + + return true + } + + fun insertCustomFolderIntoDB(folder: SyncedFolder, repository: FileSystemRepository): Int { val path = Paths.get(folder.localPath) if (!Files.exists(path)) { @@ -70,20 +130,9 @@ class AutoUploadHelper { val javaFile = file.toFile() val lastModified = attrs?.lastModifiedTime()?.toMillis() ?: javaFile.lastModified() val creationTime = attrs?.creationTime()?.toMillis() - - if (folder.shouldSkipFile(javaFile, lastModified, creationTime)) { - skipCount++ - return FileVisitResult.CONTINUE - } - val localPath = file.toLocalPath() - filesystemDataProvider?.storeOrUpdateFileValue( - localPath, - lastModified, - javaFile.isDirectory, - folder - ) + repository.insertOrReplace(localPath, lastModified, creationTime, folder) fileCount++ diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index 5a793e5f8191..e1fc93df33bc 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -42,7 +42,6 @@ import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.UploadFileOperation import com.owncloud.android.ui.activity.SettingsActivity import com.owncloud.android.utils.FileStorageUtils -import com.owncloud.android.utils.FilesSyncHelper import com.owncloud.android.utils.MimeType import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers @@ -230,16 +229,16 @@ class AutoUploadWorker( private suspend fun collectFileChangesFromContentObserverWork(contentUris: Array?) = try { withContext(Dispatchers.IO) { if (contentUris.isNullOrEmpty()) { - FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper) + helper.insertEntries(syncedFolder, repository) } else { - val isContentUrisStored = FilesSyncHelper.insertChangedEntries(syncedFolder, contentUris) + val isContentUrisStored = helper.insertChangedEntries(syncedFolder, contentUris, repository) if (!isContentUrisStored) { Log_OC.w( TAG, "changed content uris not stored, fallback to insert all db entries to not lose files" ) - FilesSyncHelper.insertAllDBEntriesForSyncedFolder(syncedFolder, helper) + helper.insertEntries(syncedFolder, repository) } } syncedFolder.lastScanTimestampMs = System.currentTimeMillis() diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt index a4bf50a03fa9..8cb6a74dc1ec 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/FileSystemRepository.kt @@ -7,20 +7,27 @@ package com.nextcloud.client.jobs.autoUpload +import android.content.Context +import android.net.Uri +import android.provider.MediaStore import com.nextcloud.client.database.dao.FileSystemDao +import com.nextcloud.client.database.entity.FilesystemEntity +import com.nextcloud.utils.extensions.shouldSkipFile +import com.nextcloud.utils.extensions.toFile import com.owncloud.android.datamodel.SyncedFolder import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.utils.SyncedFolderUtils import java.io.File +import java.util.zip.CRC32 -class FileSystemRepository(private val dao: FileSystemDao) { +@Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "MagicNumber", "ReturnCount") +class FileSystemRepository(private val dao: FileSystemDao, private val context: Context) { companion object { private const val TAG = "FilesystemRepository" const val BATCH_SIZE = 50 } - @Suppress("NestedBlockDepth") suspend fun getFilePathsWithIds(syncedFolder: SyncedFolder, lastId: Int): List> { val syncedFolderId = syncedFolder.id.toString() Log_OC.d(TAG, "Fetching candidate files for syncedFolderId = $syncedFolderId") @@ -52,7 +59,6 @@ class FileSystemRepository(private val dao: FileSystemDao) { return filtered } - @Suppress("TooGenericExceptionCaught") suspend fun markFileAsUploaded(localPath: String, syncedFolder: SyncedFolder) { val syncedFolderIdStr = syncedFolder.id.toString() @@ -63,4 +69,140 @@ class FileSystemRepository(private val dao: FileSystemDao) { Log_OC.e(TAG, "Error marking file as uploaded: ${e.message}", e) } } + + @JvmOverloads + fun insertFromUri(uri: Uri, syncedFolder: SyncedFolder, checkFileType: Boolean = false) { + val projection = arrayOf( + MediaStore.MediaColumns.DATA, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.DATE_ADDED + ) + + var syncedPath = syncedFolder.localPath + if (syncedPath.isNullOrEmpty()) { + Log_OC.w(TAG, "Synced folder path is null or empty") + return + } + + if (!syncedPath.endsWith(File.separator)) { + syncedPath += File.separator + } + + val selection = "${MediaStore.MediaColumns.DATA} LIKE ?" + val selectionArgs = arrayOf("$syncedPath%") + + Log_OC.d(TAG, "Querying MediaStore for files in: $syncedPath, uri: $uri") + + val cursor = context.contentResolver.query( + uri, + projection, + selection, + selectionArgs, + null + ) + + cursor?.use { + val idxData = cursor.getColumnIndex(MediaStore.MediaColumns.DATA) + val idxModified = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED) + val idxAdded = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED) + + if (idxData == -1) { + Log_OC.e(TAG, "MediaStore column DATA missing — cannot process URI: $uri") + return + } + + while (cursor.moveToNext()) { + val filePath = cursor.getString(idxData) + + val lastModifiedMs = if (idxModified != -1) { + cursor.getLong(idxModified) * 1000 + } else { + null + } + + val creationTimeMs = if (idxAdded != -1) { + cursor.getLong(idxAdded) * 1000 + } else { + null + } + + Log_OC.d( + TAG, + "Found file: $filePath (created=$creationTimeMs, modified=$lastModifiedMs)" + ) + + insertOrReplace(filePath, lastModifiedMs, creationTimeMs, syncedFolder, checkFileType) + } + } + } + + fun insertOrReplace( + localPath: String?, + lastModified: Long?, + creationTime: Long?, + syncedFolder: SyncedFolder, + checkFileType: Boolean = false + ) { + try { + val file = localPath?.toFile() + if (file == null) { + Log_OC.w(TAG, "file null, cannot insert or replace: $localPath") + return + } + + if (checkFileType && !syncedFolder.containsTypedFile(file, localPath)) { + Log_OC.w(TAG, "synced folder not contains typed file: $localPath") + return + } + + val fileModified = (lastModified ?: file.lastModified()) + val shouldSkipFileBasedOnFolderSettings = syncedFolder.shouldSkipFile(file, fileModified, creationTime) + if (shouldSkipFileBasedOnFolderSettings) { + return + } + + val entity = dao.getFileByPathAndFolder(localPath, syncedFolder.id.toString()) + if (entity != null && entity.fileSentForUpload == 1) { + Log_OC.w( + TAG, + "file already uploaded path: $localPath, " + + "syncedFolder: ${syncedFolder.localPath}, ${syncedFolder.id}" + ) + return + } + + val crc = getFileChecksum(file) + + val newEntity = FilesystemEntity( + id = entity?.id, + localPath = localPath, + fileIsFolder = if (file.isDirectory) 1 else 0, + fileFoundRecently = System.currentTimeMillis(), + fileSentForUpload = 0, + syncedFolderId = syncedFolder.id.toString(), + crc32 = crc?.toString(), + fileModified = fileModified + ) + + Log_OC.d(TAG, "inserting new file system entity: $newEntity") + + dao.insertOrReplace(newEntity) + } catch (e: Exception) { + Log_OC.e(TAG, "Failed to insert/update file: $localPath", e) + } + } + + private fun getFileChecksum(file: File): Long? = try { + file.inputStream().use { fis -> + val crc = CRC32() + val buffer = ByteArray(64 * 1024) + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } > 0) { + crc.update(buffer, 0, bytesRead) + } + crc.value + } + } catch (_: Exception) { + null + } } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt index 5251aa341812..c7bdadfe6f54 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/SyncedFolderExtensions.kt @@ -23,6 +23,8 @@ private const val TAG = "SyncedFolderExtensions" */ @Suppress("ReturnCount") fun SyncedFolder.shouldSkipFile(file: File, lastModified: Long, creationTime: Long?): Boolean { + Log_OC.d(TAG, "Checking file: ${file.name}, lastModified=$lastModified, lastScan=$lastScanTimestampMs") + if (isExcludeHidden && file.isHidden) { Log_OC.d(TAG, "Skipping hidden: ${file.absolutePath}") return true diff --git a/app/src/main/java/com/nextcloud/utils/extensions/UriExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UriExtensions.kt deleted file mode 100644 index 174cf97102e1..000000000000 --- a/app/src/main/java/com/nextcloud/utils/extensions/UriExtensions.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.utils.extensions - -import android.content.Context -import android.net.Uri -import android.provider.MediaStore -import com.owncloud.android.lib.common.utils.Log_OC - -/** - * Returns absolute filesystem path to the media item on disk. I/O errors that could occur. From Android 11 onwards, - * this column is read-only for apps that target R and higher. - * - * [More Info](https://developer.android.com/reference/android/provider/MediaStore.MediaColumns#DATA) - */ -@Suppress("ReturnCount", "TooGenericExceptionCaught") -fun Uri.toFilePath(context: Context): String? { - try { - val projection = arrayOf(MediaStore.MediaColumns.DATA) - - val resolver = context.contentResolver - - resolver.query(this, projection, null, null, null)?.use { cursor -> - if (!cursor.moveToFirst()) { - return null - } - - val dataIdx = cursor.getColumnIndex(MediaStore.MediaColumns.DATA) - val data = if (dataIdx != -1) cursor.getString(dataIdx) else null - return data - } - - return null - } catch (e: Exception) { - Log_OC.e("UriExtensions", "exception, toFilePath: $e") - return null - } -} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileSystemDataSet.java b/app/src/main/java/com/owncloud/android/datamodel/FileSystemDataSet.java deleted file mode 100644 index 44f768c2d511..000000000000 --- a/app/src/main/java/com/owncloud/android/datamodel/FileSystemDataSet.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Nextcloud Android client application - * - * @author Mario Danic - * @author Andy Scherzinger - * Copyright (C) 2017 Mario Danic - * Copyright (C) 2017 Nextcloud - * Copyright (C) 2018 Andy Scherzinger - * - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.datamodel; - -import androidx.annotation.Nullable; - -/** - * Model for filesystem data from the database. - */ -public class FileSystemDataSet { - private int id; - private String localPath; - private long modifiedAt; - private boolean folder; - private boolean sentForUpload; - private long foundAt; - private long syncedFolderId; - @Nullable private String crc32; - - public FileSystemDataSet(int id, String localPath, long modifiedAt, boolean folder, boolean sentForUpload, long foundAt, long syncedFolderId, String crc32) { - this.id = id; - this.localPath = localPath; - this.modifiedAt = modifiedAt; - this.folder = folder; - this.sentForUpload = sentForUpload; - this.foundAt = foundAt; - this.syncedFolderId = syncedFolderId; - this.crc32 = crc32; - } - - public FileSystemDataSet() { - // empty constructor - } - - public int getId() { - return this.id; - } - - public String getLocalPath() { - return this.localPath; - } - - public long getModifiedAt() { - return this.modifiedAt; - } - - public boolean isFolder() { - return this.folder; - } - - public boolean isSentForUpload() { - return this.sentForUpload; - } - - public long getFoundAt() { - return this.foundAt; - } - - public long getSyncedFolderId() { - return this.syncedFolderId; - } - - @Nullable - public String getCrc32() { - return this.crc32; - } - - public void setId(int id) { - this.id = id; - } - - public void setLocalPath(String localPath) { - this.localPath = localPath; - } - - public void setModifiedAt(long modifiedAt) { - this.modifiedAt = modifiedAt; - } - - public void setFolder(boolean folder) { - this.folder = folder; - } - - public void setSentForUpload(boolean sentForUpload) { - this.sentForUpload = sentForUpload; - } - - public void setFoundAt(long foundAt) { - this.foundAt = foundAt; - } - - public void setSyncedFolderId(long syncedFolderId) { - this.syncedFolderId = syncedFolderId; - } - - public void setCrc32(@Nullable String crc32) { - this.crc32 = crc32; - } -} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java b/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java index e1a2016715f6..48f212e8380e 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FilesystemDataProvider.java @@ -8,19 +8,10 @@ package com.owncloud.android.datamodel; import android.content.ContentResolver; -import android.content.ContentValues; -import android.database.Cursor; -import android.net.Uri; import com.owncloud.android.db.ProviderMeta; import com.owncloud.android.lib.common.utils.Log_OC; -import java.io.BufferedInputStream; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.zip.CRC32; - /** * Provider for stored filesystem data. */ @@ -46,134 +37,4 @@ public int deleteAllEntriesForSyncedFolder(String syncedFolderId) { ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID + " = ?", new String[]{syncedFolderId}); } - - public void storeOrUpdateFileValue(String localPath, long modifiedAt, boolean isFolder, SyncedFolder syncedFolder) { - Log_OC.d(TAG, "storeOrUpdateFileValue called, localPath: " + localPath + " ID: " + syncedFolder.getId()); - - // takes multiple milliseconds to query data from database (around 75% of execution time) (6ms) - FileSystemDataSet data = getFilesystemDataSet(localPath, syncedFolder); - - int isFolderValue = 0; - if (isFolder) { - isFolderValue = 1; - } - - ContentValues cv = new ContentValues(); - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_FOUND_RECENTLY, System.currentTimeMillis()); - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_MODIFIED, modifiedAt); - - if (data == null) { - Log_OC.d(TAG, "storeOrUpdateFileValue data is null"); - - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH, localPath); - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER, isFolderValue); - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD, Boolean.FALSE); - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID, syncedFolder.getId()); - - long newCrc32 = getFileChecksum(localPath); - if (newCrc32 != -1) { - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_CRC32, Long.toString(newCrc32)); - } - - Uri result = contentResolver.insert(ProviderMeta.ProviderTableMeta.CONTENT_URI_FILESYSTEM, cv); - - if (result == null) { - Log_OC.e(TAG, "Failed to insert filesystem data with local path: " + localPath); - } - } else { - Log_OC.d(TAG, "storeOrUpdateFileValue data is not null"); - - if (data.getModifiedAt() != modifiedAt) { - long newCrc32 = getFileChecksum(localPath); - if (data.getCrc32() == null || (newCrc32 != -1 && !data.getCrc32().equals(Long.toString(newCrc32)))) { - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_CRC32, Long.toString(newCrc32)); - cv.put(ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD, 0); - } - } - - // updating data takes multiple milliseconds (around 25% of exec time) (2 ms) - int result = contentResolver.update( - ProviderMeta.ProviderTableMeta.CONTENT_URI_FILESYSTEM, - cv, - ProviderMeta.ProviderTableMeta._ID + "=?", - new String[]{String.valueOf(data.getId())} - ); - - if (result == 0) { - Log_OC.e(TAG, "Failed to update filesystem data with local path: " + localPath); - } - } - } - - private FileSystemDataSet getFilesystemDataSet(String localPathParam, SyncedFolder syncedFolder) { - Log_OC.d(TAG, "getFilesForUpload called, localPath: " + localPathParam + " ID: " + syncedFolder.getId()); - - String[] projection = { - ProviderMeta.ProviderTableMeta._ID, - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH, - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_MODIFIED, - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER, - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_FOUND_RECENTLY, - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD, - ProviderMeta.ProviderTableMeta.FILESYSTEM_CRC32 - }; - - String selection = ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH + " = ? AND " + - ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID + " = ?"; - String[] selectionArgs = { localPathParam, String.valueOf(syncedFolder.getId()) }; - - try (Cursor cursor = contentResolver.query( - ProviderMeta.ProviderTableMeta.CONTENT_URI_FILESYSTEM, - projection, - selection, - selectionArgs, - null - )) { - if (cursor != null && cursor.moveToFirst()) { - int id = cursor.getInt(cursor.getColumnIndexOrThrow(ProviderMeta.ProviderTableMeta._ID)); - if (id == -1) { - Log_OC.e(TAG, "Arbitrary value could not be created from cursor"); - return null; - } - - String localPath = cursor.getString(cursor.getColumnIndexOrThrow( - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_LOCAL_PATH)); - long modifiedAt = cursor.getLong(cursor.getColumnIndexOrThrow( - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_MODIFIED)); - boolean isFolder = cursor.getInt(cursor.getColumnIndexOrThrow( - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER)) != 0; - long foundAt = cursor.getLong(cursor.getColumnIndexOrThrow( - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_FOUND_RECENTLY)); - boolean isSentForUpload = cursor.getInt(cursor.getColumnIndexOrThrow( - ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD)) != 0; - String crc32 = cursor.getString(cursor.getColumnIndexOrThrow( - ProviderMeta.ProviderTableMeta.FILESYSTEM_CRC32)); - - return new FileSystemDataSet(id, localPath, modifiedAt, isFolder, isSentForUpload, foundAt, - syncedFolder.getId(), crc32); - } - } catch (Exception e) { - Log_OC.e(TAG, "DB error restoring arbitrary values.", e); - } - - return null; - } - - private long getFileChecksum(String filepath) { - - try (FileInputStream fileInputStream = new FileInputStream(filepath); - InputStream inputStream = new BufferedInputStream(fileInputStream)) { - CRC32 crc = new CRC32(); - byte[] buf = new byte[1024 * 64]; - int size; - while ((size = inputStream.read(buf)) > 0) { - crc.update(buf, 0, size); - } - - return crc.getValue(); - - } catch (IOException e) { - return -1; - } - } } diff --git a/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.java b/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.java index 94461e00d5ad..60ac53201200 100644 --- a/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.java +++ b/app/src/main/java/com/owncloud/android/utils/FilesSyncHelper.java @@ -9,32 +9,18 @@ */ package com.owncloud.android.utils; -import android.content.ContentResolver; import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.provider.MediaStore; import com.nextcloud.client.account.UserAccountManager; import com.nextcloud.client.device.PowerManagementService; import com.nextcloud.client.jobs.BackgroundJobManager; -import com.nextcloud.client.jobs.ContentObserverWork; -import com.nextcloud.client.jobs.autoUpload.AutoUploadHelper; import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.network.ConnectivityService; -import com.nextcloud.utils.extensions.UriExtensionsKt; -import com.owncloud.android.MainApp; -import com.owncloud.android.datamodel.FilesystemDataProvider; -import com.owncloud.android.datamodel.MediaFolderType; import com.owncloud.android.datamodel.SyncedFolder; import com.owncloud.android.datamodel.SyncedFolderProvider; import com.owncloud.android.datamodel.UploadsStorageManager; import com.owncloud.android.lib.common.utils.Log_OC; -import java.io.File; - -import static com.owncloud.android.datamodel.OCFile.PATH_SEPARATOR; - /** * Various utilities that make auto upload tick */ @@ -47,175 +33,6 @@ private FilesSyncHelper() { // utility class -> private constructor } - public static void insertAllDBEntriesForSyncedFolder(SyncedFolder syncedFolder, AutoUploadHelper helper) { - Log_OC.d(TAG, "insertAllDBEntriesForSyncedFolder, called. ID: " + syncedFolder.getId()); - - final Context context = MainApp.getAppContext(); - final ContentResolver contentResolver = context.getContentResolver(); - - final long enabledTimestampMs = syncedFolder.getEnabledTimestampMs(); - - if (syncedFolder.isEnabled() && (syncedFolder.isExisting() || enabledTimestampMs >= 0)) { - MediaFolderType mediaType = syncedFolder.getType(); - final long lastCheckTimestampMs = syncedFolder.getLastScanTimestampMs(); - - Log_OC.d(TAG,"File-sync start check folder "+syncedFolder.getLocalPath()); - long startTime = System.nanoTime(); - - if (mediaType == MediaFolderType.IMAGE) { - Log_OC.d(TAG, "inserting IMAGE"); - FilesSyncHelper.insertContentIntoDB(MediaStore.Images.Media.INTERNAL_CONTENT_URI, - syncedFolder, - lastCheckTimestampMs); - FilesSyncHelper.insertContentIntoDB(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - syncedFolder, - lastCheckTimestampMs); - } else if (mediaType == MediaFolderType.VIDEO) { - Log_OC.d(TAG, "inserting VIDEO"); - FilesSyncHelper.insertContentIntoDB(MediaStore.Video.Media.INTERNAL_CONTENT_URI, - syncedFolder, - lastCheckTimestampMs); - FilesSyncHelper.insertContentIntoDB(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, - syncedFolder, - lastCheckTimestampMs); - } else { - Log_OC.d(TAG, "inserting other media types: " + mediaType.toString()); - FilesystemDataProvider filesystemDataProvider = new FilesystemDataProvider(contentResolver); - helper.insertCustomFolderIntoDB(syncedFolder, filesystemDataProvider); - } - - Log_OC.d(TAG,"File-sync finished full check for custom folder "+syncedFolder.getLocalPath()+" within "+(System.nanoTime() - startTime)+ "ns"); - } else { - if (!syncedFolder.isEnabled()) { - Log_OC.w(TAG, "insertAllDBEntriesForSyncedFolder, syncedFolder not enabled"); - } - - if (!syncedFolder.isExisting()) { - Log_OC.w(TAG, "insertAllDBEntriesForSyncedFolder, syncedFolder is not exists"); - } - - Log_OC.w(TAG, "insertAllDBEntriesForSyncedFolder, enabledTimestampMs: " + enabledTimestampMs); - } - } - - /** - * Attempts to get the file path from a content URI string (e.g., content://media/external/images/media/2281) - * and checks its type. If the conditions are met, the file is stored for auto-upload. - *

- * If any attempt fails, the method returns {@code false}. - * - * @param syncedFolder The folder marked for auto-upload. - * @param contentUris An array of content URI strings collected from {@link ContentObserverWork##checkAndTriggerAutoUpload()}. - * @return {@code true} if all changed content URIs were successfully stored; {@code false} otherwise. - */ - public static boolean insertChangedEntries(SyncedFolder syncedFolder, String[] contentUris) { - Log_OC.d(TAG, "insertChangedEntries, syncedFolderID: " + syncedFolder.getId()); - final Context context = MainApp.getAppContext(); - final ContentResolver contentResolver = context.getContentResolver(); - final FilesystemDataProvider filesystemDataProvider = new FilesystemDataProvider(contentResolver); - for (String contentUriString : contentUris) { - if (contentUriString == null) { - Log_OC.w(TAG, "null content uri string"); - return false; - } - - Uri contentUri; - try { - contentUri = Uri.parse(contentUriString); - } catch (Exception e) { - Log_OC.e(TAG, "Invalid URI: " + contentUriString, e); - return false; - } - - String filePath = UriExtensionsKt.toFilePath(contentUri, context); - if (filePath == null) { - Log_OC.w(TAG, "File path is null"); - return false; - } - - File file = new File(filePath); - if (!file.exists()) { - Log_OC.w(TAG, "syncedFolder contains not existing changed file: " + filePath); - return false; - } - - if (!syncedFolder.containsTypedFile(file, filePath)) { - Log_OC.w(TAG, "syncedFolder not contains typed file, changedFile: " + filePath); - return false; - } - - filesystemDataProvider.storeOrUpdateFileValue(filePath, file.lastModified(), file.isDirectory(), syncedFolder); - } - - Log_OC.d(TAG, "changed content uris successfully stored"); - - return true; - } - - private static void insertContentIntoDB(Uri uri, SyncedFolder syncedFolder, - long lastCheckTimestampMs) { - Log_OC.d(TAG, "insertContentIntoDB, URI: " + uri + " syncedFolderID: " + syncedFolder.getId() + " lastCheckTimestampMs " + lastCheckTimestampMs); - final Context context = MainApp.getAppContext(); - final ContentResolver contentResolver = context.getContentResolver(); - - Cursor cursor; - int column_index_data; - int column_index_date_modified; - - final FilesystemDataProvider filesystemDataProvider = new FilesystemDataProvider(contentResolver); - - String contentPath; - boolean isFolder; - - String[] projection = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DATE_MODIFIED}; - - String path = syncedFolder.getLocalPath(); - if (!path.endsWith(PATH_SEPARATOR)) { - Log_OC.w(TAG, "path is not ending with: " + PATH_SEPARATOR); - path = path + PATH_SEPARATOR; - } - path = path + "%"; - - long enabledTimestampMs = syncedFolder.getEnabledTimestampMs(); - - cursor = context.getContentResolver().query(uri, projection, MediaStore.MediaColumns.DATA + " LIKE ?", - new String[]{path}, null); - - if (cursor != null) { - column_index_data = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA); - column_index_date_modified = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED); - while (cursor.moveToNext()) { - contentPath = cursor.getString(column_index_data); - isFolder = new File(contentPath).isDirectory(); - - if (syncedFolder.getLastScanTimestampMs() != SyncedFolder.NOT_SCANNED_YET && - cursor.getLong(column_index_date_modified) < (lastCheckTimestampMs / 1000)) { - Log_OC.w(TAG, "skipping contentPath"); - continue; - } - - if (syncedFolder.isExisting() || cursor.getLong(column_index_date_modified) >= enabledTimestampMs / 1000) { - // storeOrUpdateFileValue takes a few ms - // -> Rest of this file check takes not even 1 ms. - filesystemDataProvider.storeOrUpdateFileValue(contentPath, - cursor.getLong(column_index_date_modified), isFolder, - syncedFolder); - } else { - if (!syncedFolder.isExisting()) { - Log_OC.w(TAG, "syncedFolder not exists"); - } - - if (cursor.getLong(column_index_date_modified) < enabledTimestampMs / 1000) { - Log_OC.w(TAG, "column_index_date_modified not meeting condition"); - } - } - } - cursor.close(); - } else { - Log_OC.w(TAG, "cursor is null "); - } - } - public static void restartUploadsIfNeeded(final UploadsStorageManager uploadsStorageManager, final UserAccountManager accountManager, final ConnectivityService connectivityService, diff --git a/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt b/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt index 1eb86275abb1..993ee41a2915 100644 --- a/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt +++ b/app/src/test/java/com/owncloud/android/utils/AutoUploadHelperTest.kt @@ -7,10 +7,16 @@ package com.owncloud.android.utils +import android.content.Context +import com.nextcloud.client.database.dao.FileSystemDao import com.nextcloud.client.jobs.autoUpload.AutoUploadHelper +import com.nextcloud.client.jobs.autoUpload.FileSystemRepository import com.nextcloud.client.preferences.SubFolderRule +import com.nextcloud.utils.extensions.shouldSkipFile import com.owncloud.android.datamodel.MediaFolderType import com.owncloud.android.datamodel.SyncedFolder +import io.mockk.clearAllMocks +import io.mockk.mockk import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -18,7 +24,6 @@ import org.junit.Before import org.junit.Test import java.io.File import java.nio.file.Files -import java.nio.file.attribute.FileTime @Suppress("MagicNumber") class AutoUploadHelperTest { @@ -27,16 +32,24 @@ class AutoUploadHelperTest { private val helper = AutoUploadHelper() private val accountName = "testAccount" + private val mockDao: FileSystemDao = mockk(relaxed = true) + private val mockContext: Context = mockk(relaxed = true) + + private lateinit var repo: FileSystemRepository + @Before fun setup() { tempDir = Files.createTempDirectory("auto_upload_test_").toFile() tempDir.mkdirs() assertTrue("Failed to create temp directory", tempDir.exists()) + + repo = FileSystemRepository(mockDao, mockContext) } @After fun cleanup() { tempDir.deleteRecursively() + clearAllMocks() } private fun createTestFolder( @@ -81,7 +94,7 @@ class AutoUploadHelperTest { type = MediaFolderType.CUSTOM ) - val processedCount = helper.insertCustomFolderIntoDB(folder, null) + val processedCount = helper.insertCustomFolderIntoDB(folder, repo) assertEquals("Should process 2 files", 2, processedCount) } @@ -98,7 +111,7 @@ class AutoUploadHelperTest { type = MediaFolderType.CUSTOM ) - val processedCount = helper.insertCustomFolderIntoDB(folder, null) + val processedCount = helper.insertCustomFolderIntoDB(folder, repo) assertTrue("Should process at least 1 file", processedCount >= 1) } @@ -109,21 +122,21 @@ class AutoUploadHelperTest { // Create an old file val oldFile = File(tempDir, "old.txt").apply { writeText("Old") } - oldFile.setLastModified(currentTime - 10000) // 10 seconds ago + val oldFileLastModified = currentTime - 10000 // 10 seconds ago // Create a new file val newFile = File(tempDir, "new.txt").apply { writeText("New") } - newFile.setLastModified(currentTime) val folder = createTestFolder( lastScan = currentTime - 5000, // Last scan was 5 seconds ago type = MediaFolderType.CUSTOM ) - val processedCount = helper.insertCustomFolderIntoDB(folder, null) + val shouldSkipOldFile = folder.shouldSkipFile(oldFile, oldFileLastModified, null) + assertTrue(shouldSkipOldFile) - // Should only process the new file (modified after last scan) - assertEquals("Should process only 1 new file", 1, processedCount) + val shouldSkipNewFile = folder.shouldSkipFile(newFile, currentTime, null) + assertTrue(!shouldSkipNewFile) } @Test @@ -132,10 +145,9 @@ class AutoUploadHelperTest { // old file should not be scanned val oldFile = File(tempDir, "old.txt").apply { writeText("Old") } - oldFile.setLastModified(currentTime - 10000) + val oldFileLastModified = currentTime - 10000 // 10 seconds ago val newFile = File(tempDir, "new.txt").apply { writeText("New") } - newFile.setLastModified(currentTime) // Enabled 5 seconds ago val folder = createTestFolder( @@ -145,16 +157,17 @@ class AutoUploadHelperTest { lastScanTimestampMs = currentTime } - val processedCount = helper.insertCustomFolderIntoDB(folder, null) + val shouldSkipOldFile = folder.shouldSkipFile(oldFile, oldFileLastModified, null) + assertTrue(shouldSkipOldFile) - // Should only process files newer than enabledTimestamp - assertEquals("Should process only files after enabled timestamp", 1, processedCount) + val shouldSkipNewFile = folder.shouldSkipFile(newFile, currentTime, null) + assertTrue(!shouldSkipNewFile) } @Test fun testInsertCustomFolderEmpty() { val folder = createTestFolder(type = MediaFolderType.CUSTOM) - val processedCount = helper.insertCustomFolderIntoDB(folder, null) + val processedCount = helper.insertCustomFolderIntoDB(folder, repo) assertEquals("Empty folder should process 0 files", 0, processedCount) } @@ -167,7 +180,7 @@ class AutoUploadHelperTest { type = MediaFolderType.CUSTOM ) - val processedCount = helper.insertCustomFolderIntoDB(folder, null) + val processedCount = helper.insertCustomFolderIntoDB(folder, repo) assertEquals("Non-existent folder should return 0", 0, processedCount) } @@ -181,7 +194,7 @@ class AutoUploadHelperTest { File(subDir, "nested.txt").writeText("Nested file") val folder = createTestFolder(type = MediaFolderType.CUSTOM) - val processedCount = helper.insertCustomFolderIntoDB(folder, null) + val processedCount = helper.insertCustomFolderIntoDB(folder, repo) assertEquals("Should process files in root and subdirectories", 2, processedCount) } @@ -209,7 +222,7 @@ class AutoUploadHelperTest { lastScanTimestampMs = currentTime } - val processedCount = helper.insertCustomFolderIntoDB(folder, null) + val processedCount = helper.insertCustomFolderIntoDB(folder, repo) // Should skip hidden directory and its contents assertEquals("Should only process regular file", 1, processedCount) @@ -253,16 +266,16 @@ class AutoUploadHelperTest { type = MediaFolderType.CUSTOM ) - /* - * Expected file count with full paths: - * ${tempDir.absolutePath}/FOLDER_A/FILE_A.txt -> 1 - * ${tempDir.absolutePath}/FOLDER_A/FOLDER_B/FILE_B.txt -> 1 - * ${tempDir.absolutePath}/FOLDER_A/FOLDER_C/FILE_A.txt -> 1 - * ${tempDir.absolutePath}/FOLDER_A/FOLDER_C/FILE_B.txt -> 1 - * ${tempDir.absolutePath}/FOLDER_A/FOLDER_B/FOLDER_D/FOLDER_E/FILE_A.txt -> 1 - * Total = 5 files - */ - val processedCount = helper.insertCustomFolderIntoDB(syncedFolder, null) + /* + * Expected file count with full paths: + * ${tempDir.absolutePath}/FOLDER_A/FILE_A.txt -> 1 + * ${tempDir.absolutePath}/FOLDER_A/FOLDER_B/FILE_B.txt -> 1 + * ${tempDir.absolutePath}/FOLDER_A/FOLDER_C/FILE_A.txt -> 1 + * ${tempDir.absolutePath}/FOLDER_A/FOLDER_C/FILE_B.txt -> 1 + * ${tempDir.absolutePath}/FOLDER_A/FOLDER_B/FOLDER_D/FOLDER_E/FILE_A.txt -> 1 + * Total = 5 files + */ + val processedCount = helper.insertCustomFolderIntoDB(syncedFolder, repo) assertEquals("Should process all files in complex nested structure", 5, processedCount) } @@ -274,26 +287,15 @@ class AutoUploadHelperTest { val oldFile = File(tempDir, "old_file.txt").apply { writeText("Old file") } - - val oldFilePath = oldFile.toPath() - Files.setAttribute( - oldFilePath, - "creationTime", - FileTime.fromMillis(currentTime - 60_000) // 1 minute before enabling - ) - - Thread.sleep(1000) + val oldFileCreationTime = currentTime - 10_000 + val oldFileLastModified = currentTime - 5_000 // New file (created after enabling auto-upload) val newFile = File(tempDir, "new_file.txt").apply { writeText("New file") } - val newFilePath = newFile.toPath() - Files.setAttribute( - newFilePath, - "creationTime", - FileTime.fromMillis(currentTime + 60_000) // 1 minute after enabling - ) + val newFileCreationTime = currentTime + 10_000 + val newFileLastModified = currentTime + 5_000 val folderSkipOld = createTestFolder( localPath = tempDir.absolutePath, @@ -303,12 +305,11 @@ class AutoUploadHelperTest { setEnabled(true, currentTime) } - val processedCountSkipOld = helper.insertCustomFolderIntoDB(folderSkipOld, null) - assertEquals( - "When 'also upload existing' is disabled, only new files created after enabling should be processed", - 1, - processedCountSkipOld - ) + val shouldSkipOldFile = folderSkipOld.shouldSkipFile(oldFile, oldFileLastModified, oldFileCreationTime) + assertTrue(shouldSkipOldFile) + + val shouldSkipNewFile = folderSkipOld.shouldSkipFile(newFile, newFileLastModified, newFileCreationTime) + assertTrue(!shouldSkipNewFile) val folderUploadAll = createTestFolder( localPath = tempDir.absolutePath, @@ -318,11 +319,12 @@ class AutoUploadHelperTest { setEnabled(true, currentTime) } - val processedCountAll = helper.insertCustomFolderIntoDB(folderUploadAll, null) - assertEquals( - "When 'also upload existing' is enabled, should upload all files", - 2, - processedCountAll - ) + val shouldSkipOldFileIfAlsoUploadExistingFile = + folderUploadAll.shouldSkipFile(oldFile, oldFileLastModified, oldFileCreationTime) + assertTrue(!shouldSkipOldFileIfAlsoUploadExistingFile) + + val shouldSkipNewFileIfAlsoUploadExistingFile = + folderUploadAll.shouldSkipFile(newFile, newFileLastModified, newFileCreationTime) + assertTrue(!shouldSkipNewFileIfAlsoUploadExistingFile) } }