diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index eb15648e7a26..efdd29733fc5 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -272,24 +272,6 @@ internal class BackgroundJobManagerImpl( return workInfo.map { it -> it.map { fromWorkInfo(it) ?: JobInfo() }.sortedBy { it.started }.reversed() } } - @Suppress("MagicNumber") - override fun scheduleContentObserverJob() { - val constrains = Constraints.Builder() - .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) - .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) - .setTriggerContentUpdateDelay(Duration.ofSeconds(5)) - .setTriggerContentMaxDelay(Duration.ofSeconds(10)) - .build() - - val request = oneTimeRequestBuilder(ContentObserverWork::class, JOB_CONTENT_OBSERVER) - .setConstraints(constrains) - .build() - - workManager.enqueueUniqueWork(JOB_CONTENT_OBSERVER, ExistingWorkPolicy.REPLACE, request) - } - override fun schedulePeriodicContactsBackup(user: User) { val data = Data.Builder() .putString(ContactsBackupWork.KEY_ACCOUNT, user.accountName) @@ -481,6 +463,25 @@ internal class BackgroundJobManagerImpl( ) } + @Suppress("MagicNumber") + override fun scheduleContentObserverJob() { + val constrains = Constraints.Builder() + .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Files.getContentUri("external"), true) + .setTriggerContentUpdateDelay(Duration.ofSeconds(5)) + .setTriggerContentMaxDelay(Duration.ofSeconds(10)) + .build() + + val request = oneTimeRequestBuilder(ContentObserverWork::class, JOB_CONTENT_OBSERVER) + .setConstraints(constrains) + .build() + + workManager.enqueueUniqueWork(JOB_CONTENT_OBSERVER, ExistingWorkPolicy.REPLACE, request) + } + override fun startAutoUpload(syncedFolder: SyncedFolder, overridePowerSaving: Boolean) { val syncedFolderID = syncedFolder.id 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 a7b69ac00a2e..685bb7a77dc7 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 @@ -175,7 +175,7 @@ class FileSystemRepository( return } - if (checkFileType && !syncedFolder.containsTypedFile(file, localPath)) { + if (checkFileType && !syncedFolder.isFileInFolderWithCorrectMediaType(file, localPath)) { Log_OC.w(TAG, "synced folder not contains typed file: $localPath") return } diff --git a/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java index 257edc890f01..ef7d005f9e82 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java +++ b/app/src/main/java/com/owncloud/android/datamodel/SyncedFolder.java @@ -301,12 +301,35 @@ public void setExcludeHidden(boolean excludeHidden) { this.excludeHidden = excludeHidden; } - public boolean containsTypedFile(File file,String filePath){ + /** + * Determines whether the given file: + * + * + * @param file the file to validate + * @param filePath the absolute path of the file e.g. /storage/emulated/0/DCIM/Camera/document.pdf + * @return {@code true} if the file is located under {@code localPath} + * and matches the folder's media type; {@code false} otherwise + */ + public boolean isFileInFolderWithCorrectMediaType(File file, String filePath) { + if (filePath == null || filePath.isEmpty() || localPath == null || localPath.isEmpty()) { + return false; + } + + String normalizedLocal = localPath.endsWith(File.separator) + ? localPath + : localPath + File.separator; + + boolean isInLocalDirectory = filePath.startsWith(normalizedLocal); + boolean isCorrectMediaType = - (getType() == MediaFolderType.IMAGE && MimeTypeUtil.isImage(file)) || + (getType() == MediaFolderType.IMAGE && MimeTypeUtil.isImage(file)) || (getType() == MediaFolderType.VIDEO && MimeTypeUtil.isVideo(file)) || - getType() == MediaFolderType.CUSTOM; - return filePath.contains(localPath) && isCorrectMediaType; + getType() == MediaFolderType.CUSTOM; + + return isInLocalDirectory && isCorrectMediaType; } public long getLastScanTimestampMs() { return lastScanTimestampMs; } diff --git a/app/src/test/java/com/owncloud/android/utils/AutoUploadFileMatchingTests.kt b/app/src/test/java/com/owncloud/android/utils/AutoUploadFileMatchingTests.kt new file mode 100644 index 000000000000..e392dccaf9a7 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/utils/AutoUploadFileMatchingTests.kt @@ -0,0 +1,302 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.utils + +import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.files.services.NameCollisionPolicy +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +@Suppress("TooManyFunctions") +class AutoUploadFileMatchingTests { + + companion object { + private const val LOCAL_PATH = "/storage/emulated/0/DCIM/Camera" + private const val LOCAL_PATH_WITH_SEPARATOR = "/storage/emulated/0/DCIM/Camera/" + + private const val IMAGE_FILE_PATH = "/storage/emulated/0/DCIM/Camera/photo.jpg" + private const val VIDEO_FILE_PATH = "/storage/emulated/0/DCIM/Camera/video.mp4" + private const val CUSTOM_FILE_PATH = "/storage/emulated/0/DCIM/Camera/document.pdf" + + private const val VIDEO_FILE_OUTSIDE_PATH = "/storage/emulated/0/Downloads/archive.mp4" + private const val VIDEO_FILE_SIMILAR_PATH = "/storage/emulated/0/DCIM/Camera_backup/archive.mp4" + + private const val IMAGE_FILE_OUTSIDE_PATH = "/storage/emulated/0/Downloads/photo.jpg" + private const val IMAGE_FILE_SIMILAR_PATH = "/storage/emulated/0/DCIM/Camera_backup/photo.jpg" + } + + private lateinit var imageFile: File + private lateinit var videoFile: File + private lateinit var customFile: File + + @Before + fun setUp() { + mockkStatic(MimeTypeUtil::class) + + imageFile = File(IMAGE_FILE_PATH) + videoFile = File(VIDEO_FILE_PATH) + customFile = File(CUSTOM_FILE_PATH) + } + + @After + fun tearDown() { + unmockkAll() + } + + // region Helper + private fun createSyncedFolder(localPath: String?, type: MediaFolderType): SyncedFolder = SyncedFolder( + localPath, + "/remote/path", + false, + false, + false, + false, + "account@server", + 0, + NameCollisionPolicy.SKIP.serialize(), + true, + System.currentTimeMillis(), + type, + false, + com.nextcloud.client.preferences.SubFolderRule.YEAR_MONTH, + false, + System.currentTimeMillis() + ) + // endregion + + // region IMAGE type tests + @Test + fun `given image folder when file is image and under local path then returns true`() { + every { MimeTypeUtil.isImage(imageFile) } returns true + every { MimeTypeUtil.isVideo(imageFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.IMAGE) + assertTrue(folder.isFileInFolderWithCorrectMediaType(imageFile, IMAGE_FILE_PATH)) + } + + @Test + fun `given image folder when file is video and under local path then returns false`() { + every { MimeTypeUtil.isImage(videoFile) } returns false + every { MimeTypeUtil.isVideo(videoFile) } returns true + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.IMAGE) + assertFalse(folder.isFileInFolderWithCorrectMediaType(videoFile, VIDEO_FILE_PATH)) + } + + @Test + fun `given image folder when file is image but outside local path then returns false`() { + val outsideFile = File(IMAGE_FILE_OUTSIDE_PATH) + every { MimeTypeUtil.isImage(outsideFile) } returns true + every { MimeTypeUtil.isVideo(outsideFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.IMAGE) + assertFalse(folder.isFileInFolderWithCorrectMediaType(outsideFile, IMAGE_FILE_OUTSIDE_PATH)) + } + + @Test + fun `given image folder when file is image and local path has trailing separator then returns true`() { + every { MimeTypeUtil.isImage(imageFile) } returns true + every { MimeTypeUtil.isVideo(imageFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH_WITH_SEPARATOR, MediaFolderType.IMAGE) + assertTrue(folder.isFileInFolderWithCorrectMediaType(imageFile, IMAGE_FILE_PATH)) + } + + @Test + fun `given image folder when file path is in similar named folder then returns false`() { + val similarFile = File(IMAGE_FILE_SIMILAR_PATH) + every { MimeTypeUtil.isImage(similarFile) } returns true + every { MimeTypeUtil.isVideo(similarFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.IMAGE) + assertFalse( + "Should not match path in a folder that starts with the same prefix but is a different folder", + folder.isFileInFolderWithCorrectMediaType(similarFile, IMAGE_FILE_SIMILAR_PATH) + ) + } + + @Test + fun `given image folder when file path is null then returns false`() { + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.IMAGE) + assertFalse(folder.isFileInFolderWithCorrectMediaType(imageFile, null)) + } + + @Test + fun `given image folder when local path is null then returns false`() { + every { MimeTypeUtil.isImage(imageFile) } returns true + val folder = createSyncedFolder(null, MediaFolderType.IMAGE) + assertFalse(folder.isFileInFolderWithCorrectMediaType(imageFile, IMAGE_FILE_PATH)) + } + + // endregion + + // region VIDEO type tests + @Test + fun `given video folder when file is video and under local path then returns true`() { + every { MimeTypeUtil.isImage(videoFile) } returns false + every { MimeTypeUtil.isVideo(videoFile) } returns true + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.VIDEO) + assertTrue(folder.isFileInFolderWithCorrectMediaType(videoFile, VIDEO_FILE_PATH)) + } + + @Test + fun `given video folder when file is image and under local path then returns false`() { + every { MimeTypeUtil.isImage(imageFile) } returns true + every { MimeTypeUtil.isVideo(imageFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.VIDEO) + assertFalse(folder.isFileInFolderWithCorrectMediaType(imageFile, IMAGE_FILE_PATH)) + } + + @Test + fun `given video folder when file is video but outside local path then returns false`() { + val outsideVideo = File(VIDEO_FILE_OUTSIDE_PATH) + every { MimeTypeUtil.isImage(outsideVideo) } returns false + every { MimeTypeUtil.isVideo(outsideVideo) } returns true + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.VIDEO) + assertFalse(folder.isFileInFolderWithCorrectMediaType(outsideVideo, VIDEO_FILE_OUTSIDE_PATH)) + } + + @Test + fun `given video folder when file is video and local path has trailing separator then returns true`() { + every { MimeTypeUtil.isImage(videoFile) } returns false + every { MimeTypeUtil.isVideo(videoFile) } returns true + + val folder = createSyncedFolder(LOCAL_PATH_WITH_SEPARATOR, MediaFolderType.VIDEO) + assertTrue(folder.isFileInFolderWithCorrectMediaType(videoFile, VIDEO_FILE_PATH)) + } + + @Test + fun `given video folder when file path is in similar named folder then returns false`() { + val similarVideo = File(VIDEO_FILE_OUTSIDE_PATH) + every { MimeTypeUtil.isImage(similarVideo) } returns false + every { MimeTypeUtil.isVideo(similarVideo) } returns true + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.VIDEO) + assertFalse( + "Should not match path in a folder that starts with the same prefix but is a different folder", + folder.isFileInFolderWithCorrectMediaType(similarVideo, VIDEO_FILE_SIMILAR_PATH) + ) + } + + @Test + fun `given video folder when file path is null then returns false`() { + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.VIDEO) + assertFalse(folder.isFileInFolderWithCorrectMediaType(videoFile, null)) + } + + // endregion + + // region CUSTOM type tests + @Test + fun `given custom folder when any file type is under local path then returns true`() { + every { MimeTypeUtil.isImage(customFile) } returns false + every { MimeTypeUtil.isVideo(customFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.CUSTOM) + assertTrue(folder.isFileInFolderWithCorrectMediaType(customFile, CUSTOM_FILE_PATH)) + } + + @Test + fun `given custom folder when image file is under local path then returns true`() { + every { MimeTypeUtil.isImage(imageFile) } returns true + every { MimeTypeUtil.isVideo(imageFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.CUSTOM) + assertTrue(folder.isFileInFolderWithCorrectMediaType(imageFile, IMAGE_FILE_PATH)) + } + + @Test + fun `given custom folder when video file is under local path then returns true`() { + every { MimeTypeUtil.isImage(videoFile) } returns false + every { MimeTypeUtil.isVideo(videoFile) } returns true + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.CUSTOM) + assertTrue(folder.isFileInFolderWithCorrectMediaType(videoFile, VIDEO_FILE_PATH)) + } + + @Test + fun `given custom folder when file is outside local path then returns false`() { + val outsideFile = File(IMAGE_FILE_OUTSIDE_PATH) + every { MimeTypeUtil.isImage(outsideFile) } returns true + every { MimeTypeUtil.isVideo(outsideFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.CUSTOM) + assertFalse(folder.isFileInFolderWithCorrectMediaType(outsideFile, IMAGE_FILE_OUTSIDE_PATH)) + } + + @Test + fun `given custom folder when local path has trailing separator then returns true`() { + every { MimeTypeUtil.isImage(customFile) } returns false + every { MimeTypeUtil.isVideo(customFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH_WITH_SEPARATOR, MediaFolderType.CUSTOM) + assertTrue(folder.isFileInFolderWithCorrectMediaType(customFile, CUSTOM_FILE_PATH)) + } + + @Test + fun `given custom folder when file path is in similar named folder then returns false`() { + val similarFile = File(IMAGE_FILE_SIMILAR_PATH) + every { MimeTypeUtil.isImage(similarFile) } returns true + every { MimeTypeUtil.isVideo(similarFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.CUSTOM) + assertFalse( + "Should not match path in a folder that starts with the same prefix but is a different folder", + folder.isFileInFolderWithCorrectMediaType(similarFile, IMAGE_FILE_SIMILAR_PATH) + ) + } + + @Test + fun `given custom folder when file path is null then returns false`() { + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.CUSTOM) + assertFalse(folder.isFileInFolderWithCorrectMediaType(customFile, null)) + } + // endregion + + // region Path edge case tests + @Test + fun `given image folder when file is directly inside local path root then returns true`() { + every { MimeTypeUtil.isImage(imageFile) } returns true + every { MimeTypeUtil.isVideo(imageFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.IMAGE) + assertTrue(folder.isFileInFolderWithCorrectMediaType(imageFile, IMAGE_FILE_PATH)) + } + + @Test + fun `given any folder type when file is in nested subdirectory then returns true`() { + val nestedPath = "/storage/emulated/0/DCIM/Camera/2024/March/photo.jpg" + val nestedFile = File(nestedPath) + every { MimeTypeUtil.isImage(nestedFile) } returns true + every { MimeTypeUtil.isVideo(nestedFile) } returns false + + val folder = createSyncedFolder(LOCAL_PATH, MediaFolderType.IMAGE) + assertTrue(folder.isFileInFolderWithCorrectMediaType(nestedFile, nestedPath)) + } + + @Test + fun `given any folder type when local path is empty string then returns false`() { + every { MimeTypeUtil.isImage(imageFile) } returns true + + val folder = createSyncedFolder("", MediaFolderType.IMAGE) + val result = folder.isFileInFolderWithCorrectMediaType(imageFile, IMAGE_FILE_PATH) + assertFalse("Empty local path should not match arbitrary paths", result) + } + // endregion +}