diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index 7212caf8fea9..b9e9c42ee68d 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -63,11 +63,11 @@ import com.owncloud.android.operations.upload.RemoteFileExistence; import com.owncloud.android.operations.upload.UploadFileException; import com.owncloud.android.operations.upload.UploadFileOperationExtensionsKt; +import com.owncloud.android.operations.upload.UploadFileResolver; import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.EncryptionUtilsV2; import com.owncloud.android.utils.FileStorageUtils; import com.owncloud.android.utils.FileUtil; -import com.owncloud.android.utils.MimeType; import com.owncloud.android.utils.MimeTypeUtil; import com.owncloud.android.utils.UriUtils; import com.owncloud.android.utils.theme.CapabilityUtils; @@ -95,11 +95,9 @@ import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.security.spec.InvalidParameterSpecException; -import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; @@ -175,29 +173,10 @@ public class UploadFileOperation extends SyncOperation { private volatile boolean encryptedAncestor; private OCFile duplicatedEncryptedFile; private AtomicBoolean missingPermissionThrown = new AtomicBoolean(false); + private final UploadFileResolver uploadFileResolver; public static OCFile obtainNewOCFileToUpload(String remotePath, String localPath, String mimeType) { - OCFile newFile = new OCFile(remotePath); - newFile.setStoragePath(localPath); - newFile.setLastSyncDateForProperties(0); - newFile.setLastSyncDateForData(0); - - // size - if (!TextUtils.isEmpty(localPath)) { - File localFile = new File(localPath); - newFile.setFileLength(localFile.length()); - newFile.setLastSyncDateForData(localFile.lastModified()); - } // don't worry about not assigning size, the problems with localPath - // are checked when the UploadFileOperation instance is created - - // MIME type - if (TextUtils.isEmpty(mimeType)) { - newFile.setMimeType(MimeTypeUtil.getBestMimeTypeByFilename(localPath)); - } else { - newFile.setMimeType(mimeType); - } - - return newFile; + return UploadFileResolver.obtainNewOCFileToUpload(remotePath, localPath, mimeType); } public UploadFileOperation(UploadsStorageManager uploadsStorageManager, @@ -281,6 +260,7 @@ public UploadFileOperation(UploadsStorageManager uploadsStorageManager, mIgnoringPowerSaveMode = mCreatedBy == CREATED_BY_USER; mFolderUnlockToken = upload.getFolderUnlockToken(); mDisableRetries = disableRetries; + uploadFileResolver = new UploadFileResolver(this); } public boolean isWifiRequired() { @@ -384,6 +364,10 @@ public long getOCUploadId() { return mOCUploadId; } + public UploadsStorageManager getUploadsStorageManager() { + return uploadsStorageManager; + } + public Set getDataTransferListeners() { return mDataTransferListeners; } @@ -452,7 +436,7 @@ protected RemoteOperationResult run(OwnCloudClient client) { mPaused.set(false); mUploadStarted.set(true); - updateSize(0); + uploadFileResolver.updateSize(0); Log_OC.d(TAG, "file size set to 0KB before upload"); String remoteParentPath = new File(getRemotePath()).getParent(); @@ -564,7 +548,7 @@ private RemoteOperationResult encryptedUpload(OwnCloudClient client, OCFile pare E2EClientData clientData = new E2EClientData(client, token, publicKey); - List fileNames = getCollidedFileNames(object); + List fileNames = uploadFileResolver.getCollidedFileNames(object); final var collisionResult = checkNameCollision(parentFile, client, fileNames, parentFile.isEncrypted()); if (collisionResult != null) { @@ -601,7 +585,7 @@ private RemoteOperationResult encryptedUpload(OwnCloudClient client, OCFile pare channel = channelResult.getThird(); size = getChannelSize(channel); - updateSize(size); + uploadFileResolver.updateSize(size); setUploadOperationForE2E(token, e2eFiles.getEncryptedTempFile(), e2eData.getEncryptedFileName(), lastModifiedTimestamp, creationTimestamp, size); result = performE2EUpload(clientData); @@ -667,23 +651,6 @@ private DecryptedFolderMetadataFileV1 getDecryptedFolderMetadataV1(String public return metadata; } - private List getCollidedFileNames(Object object) { - List result = new ArrayList<>(); - - if (object instanceof DecryptedFolderMetadataFileV1 metadata) { - for (DecryptedFile file : metadata.getFiles().values()) { - result.add(file.getEncrypted().getFilename()); - } - } else if (object instanceof DecryptedFolderMetadataFile metadataFile) { - Map files = metadataFile.getMetadata().getFiles(); - for (com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile file : files.values()) { - result.add(file.getFilename()); - } - } - - return result; - } - private String getEncryptedFileName(Object object) { String encryptedFileName = EncryptionUtils.generateUid(); @@ -1130,7 +1097,7 @@ private RemoteOperationResult normalUpload(OwnCloudClient client) { } final var formattedFileSize = Formatter.formatFileSize(mContext, size); - updateSize(size); + uploadFileResolver.updateSize(size); Log_OC.d(TAG, "file size set to " + formattedFileSize); final long serverMaxChunkSize = getCapabilities().getChunkedUploadMaxSize(); @@ -1228,14 +1195,6 @@ private RemoteOperationResult normalUpload(OwnCloudClient client) { return result; } - private void updateSize(long size) { - OCUpload ocUpload = uploadsStorageManager.getUploadById(getOCUploadId()); - if (ocUpload != null) { - ocUpload.setFileSize(size); - uploadsStorageManager.updateUpload(ocUpload); - } - } - private void logResult(RemoteOperationResult result, String sourcePath, String targetPath) { if (result.isSuccess()) { Log_OC.i(TAG, "Upload of " + sourcePath + " to " + targetPath + ": " + result.getLogMessage()); @@ -1296,7 +1255,11 @@ private RemoteOperationResult checkNameCollision(OCFile parentFile, case RENAME: mRemotePath = getNewAvailableRemotePath(client, mRemotePath, fileNames, encrypted); mWasRenamed = true; - createNewOCFile(mRemotePath); + + final var newFile = uploadFileResolver.createNewOCFile(mFile, mRemotePath); + mOldFile = mFile; + mFile = newFile; + Log_OC.d(TAG, "File renamed as " + mRemotePath); if (mRenameUploadListener != null) { mRenameUploadListener.onRenameUpload(); @@ -1448,7 +1411,7 @@ private RemoteOperationResult grantFolderExistence(String pathToGrant, OwnClo if (result.isSuccess()) { OCFile parentDir = getStorageManager().getFileByPath(pathToGrant); if (parentDir == null) { - parentDir = createLocalFolder(pathToGrant); + parentDir = uploadFileResolver.createLocalFolder(pathToGrant); } if (parentDir != null) { result = new RemoteOperationResult<>(ResultCode.OK); @@ -1459,50 +1422,6 @@ private RemoteOperationResult grantFolderExistence(String pathToGrant, OwnClo return result; } - private OCFile createLocalFolder(String remotePath) { - String parentPath = new File(remotePath).getParent(); - parentPath = parentPath.endsWith(OCFile.PATH_SEPARATOR) ? - parentPath : parentPath + OCFile.PATH_SEPARATOR; - OCFile parent = getStorageManager().getFileByPath(parentPath); - if (parent == null) { - parent = createLocalFolder(parentPath); - } - if (parent != null) { - OCFile createdFolder = new OCFile(remotePath); - createdFolder.setMimeType(MimeType.DIRECTORY); - createdFolder.setParentId(parent.getFileId()); - getStorageManager().saveFile(createdFolder); - return createdFolder; - } - return null; - } - - - /** - * Create a new OCFile mFile with new remote path. This is required if nameCollisionPolicy==RENAME. New file is - * stored as mFile, original as mOldFile. - * - * @param newRemotePath new remote path - */ - private void createNewOCFile(String newRemotePath) { - // a new OCFile instance must be created for a new remote path - OCFile newFile = new OCFile(newRemotePath); - newFile.setCreationTimestamp(mFile.getCreationTimestamp()); - newFile.setFileLength(mFile.getFileLength()); - newFile.setMimeType(mFile.getMimeType()); - newFile.setModificationTimestamp(mFile.getModificationTimestamp()); - newFile.setModificationTimestampAtLastSyncForData( - mFile.getModificationTimestampAtLastSyncForData() - ); - newFile.setEtag(mFile.getEtag()); - newFile.setLastSyncDateForProperties(mFile.getLastSyncDateForProperties()); - newFile.setLastSyncDateForData(mFile.getLastSyncDateForData()); - newFile.setStoragePath(mFile.getStoragePath()); - newFile.setParentId(mFile.getParentId()); - mOldFile = mFile; - mFile = newFile; - } - /** * Returns a new and available (does not exists on the server) remotePath. This adds an incremental suffix. * @@ -1759,7 +1678,7 @@ private void saveUploadedFile(OwnCloudClient client) { ReadFileRemoteOperation operation = new ReadFileRemoteOperation(path); RemoteOperationResult result = operation.execute(client); if (result.isSuccess()) { - updateOCFile(file, (RemoteFile) result.getData().get(0)); + uploadFileResolver.updateOCFile(file, (RemoteFile) result.getData().get(0)); file.setLastSyncDateForProperties(syncDate); } else { Log_OC.e(TAG, "Error reading properties of file after successful upload; this is gonna hurt..."); @@ -1790,24 +1709,7 @@ private void saveUploadedFile(OwnCloudClient client) { task.execute(new ThumbnailsCacheManager.ThumbnailGenerationTaskObject(file, file.getRemoteId())); } - private void updateOCFile(OCFile file, RemoteFile remoteFile) { - file.setCreationTimestamp(remoteFile.getCreationTimestamp()); - file.setFileLength(remoteFile.getLength()); - file.setMimeType(remoteFile.getMimeType()); - file.setModificationTimestamp(remoteFile.getModifiedTimestamp()); - file.setModificationTimestampAtLastSyncForData(remoteFile.getModifiedTimestamp()); - file.setEtag(remoteFile.getEtag()); - file.setEtagOnServer(remoteFile.getEtag()); - file.setRemoteId(remoteFile.getRemoteId()); - file.setPermissions(remoteFile.getPermissions()); - file.setUploadTimestamp(remoteFile.getUploadTimestamp()); - file.setPreviewAvailable(remoteFile.isHasPreview()); - file.setImageDimension(remoteFile.getImageDimension()); - file.setGeoLocation(remoteFile.getGeoLocation()); - } - public interface OnRenameListener { - void onRenameUpload(); } } diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt new file mode 100644 index 000000000000..8a3df939cdc1 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt @@ -0,0 +1,97 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.utils.MimeType +import com.owncloud.android.utils.MimeTypeUtil +import java.io.File + +class UploadFileResolver(private val operation: UploadFileOperation) { + + companion object { + @JvmStatic + fun obtainNewOCFileToUpload(remotePath: String?, localPath: String?, mimeType: String?): OCFile = + OCFile(remotePath).apply { + setStoragePath(localPath) + lastSyncDateForProperties = 0 + lastSyncDateForData = 0 + if (!localPath.isNullOrEmpty()) { + val localFile = File(localPath) + fileLength = localFile.length() + lastSyncDateForData = localFile.lastModified() + } + setMimeType( + if (mimeType.isNullOrEmpty()) MimeTypeUtil.getBestMimeTypeByFilename(localPath) else mimeType + ) + } + } + + @Suppress("DEPRECATION") + fun getCollidedFileNames(metadata: Any?): List = when (metadata) { + is DecryptedFolderMetadataFileV1 -> metadata.files.values.map { it.encrypted.filename } + is DecryptedFolderMetadataFile -> metadata.metadata.files.values.map { it.filename } + else -> emptyList() + } + + fun updateSize(size: Long) { + val storageManager = operation.uploadsStorageManager + storageManager.getUploadById(operation.ocUploadId)?.let { + it.fileSize = size + storageManager.updateUpload(it) + } + } + + fun createNewOCFile(fileToBeUploaded: OCFile, newRemotePath: String): OCFile = OCFile(newRemotePath).apply { + creationTimestamp = fileToBeUploaded.creationTimestamp + fileLength = fileToBeUploaded.fileLength + mimeType = fileToBeUploaded.mimeType + modificationTimestamp = fileToBeUploaded.modificationTimestamp + modificationTimestampAtLastSyncForData = fileToBeUploaded.modificationTimestampAtLastSyncForData + etag = fileToBeUploaded.etag + lastSyncDateForProperties = fileToBeUploaded.lastSyncDateForProperties + lastSyncDateForData = fileToBeUploaded.lastSyncDateForData + storagePath = fileToBeUploaded.storagePath + parentId = fileToBeUploaded.parentId + } + + fun createLocalFolder(remotePath: String): OCFile? { + val storageManager = operation.storageManager + val parent = File(remotePath).parent + ?.let { if (it.endsWith(OCFile.PATH_SEPARATOR)) it else it + OCFile.PATH_SEPARATOR } + ?.let { storageManager.getFileByPath(it) ?: createLocalFolder(it) } + ?: return null + + return OCFile(remotePath).apply { + mimeType = MimeType.DIRECTORY + parentId = parent.fileId + }.also { storageManager.saveFile(it) } + } + + fun updateOCFile(file: OCFile, remoteFile: RemoteFile) { + file.apply { + creationTimestamp = remoteFile.creationTimestamp + fileLength = remoteFile.length + mimeType = remoteFile.mimeType + modificationTimestamp = remoteFile.modifiedTimestamp + modificationTimestampAtLastSyncForData = remoteFile.modifiedTimestamp + etag = remoteFile.etag + etagOnServer = remoteFile.etag + remoteId = remoteFile.remoteId + permissions = remoteFile.permissions + uploadTimestamp = remoteFile.uploadTimestamp + isPreviewAvailable = remoteFile.isHasPreview + imageDimension = remoteFile.imageDimension + geoLocation = remoteFile.geoLocation + } + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt new file mode 100644 index 000000000000..f6d77b36e715 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt @@ -0,0 +1,244 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.datamodel.e2e.v1.decrypted.Data +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata +import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.resources.files.model.GeoLocation +import com.owncloud.android.lib.resources.files.model.ImageDimension +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.utils.MimeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile as DecryptedFileV1 + +@Suppress("MagicNumber") +class UploadFileResolverTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val uploadsStorageManager: UploadsStorageManager = mock() + private val fileDataStorageManager: FileDataStorageManager = mock() + private val operation: UploadFileOperation = mock() + private lateinit var resolver: UploadFileResolver + + @Before + fun setUp() { + whenever(operation.uploadsStorageManager).thenReturn(uploadsStorageManager) + whenever(operation.storageManager).thenReturn(fileDataStorageManager) + whenever(operation.ocUploadId).thenReturn(UPLOAD_ID) + resolver = UploadFileResolver(operation) + } + + @Test + fun obtainNewOCFileToUploadUsesLocalFileSizeAndModificationDate() { + val localFile = temporaryFolder.newFile("photo.jpg").apply { + writeBytes(ByteArray(42)) + setLastModified(1_700_000_000_000) + } + + val file = UploadFileResolver.obtainNewOCFileToUpload("/photo.jpg", localFile.absolutePath, "image/jpeg") + + assertEquals("/photo.jpg", file.remotePath) + assertEquals(localFile.absolutePath, file.storagePath) + assertEquals(42L, file.fileLength) + assertEquals(localFile.lastModified(), file.lastSyncDateForData) + assertEquals(0L, file.lastSyncDateForProperties) + assertEquals("image/jpeg", file.mimeType) + } + + @Test + fun obtainNewOCFileToUploadWithoutLocalPathKeepsDefaults() { + val file = UploadFileResolver.obtainNewOCFileToUpload("/photo.jpg", null, "image/jpeg") + + assertNull(file.storagePath) + assertEquals(0L, file.fileLength) + assertEquals(0L, file.lastSyncDateForData) + assertEquals("image/jpeg", file.mimeType) + } + + @Test + fun collidedFileNamesOfV1MetadataAreEncryptedFilenames() { + val files = FILE_NAMES.associateWith { name -> + DecryptedFileV1().apply { encrypted = Data().apply { filename = name } } + } + val metadata = DecryptedFolderMetadataFileV1().apply { setFiles(files) } + + assertEquals(FILE_NAMES, resolver.getCollidedFileNames(metadata)) + } + + @Test + fun collidedFileNamesOfV2MetadataAreFilenames() { + val files = FILE_NAMES.associateWith { name -> + DecryptedFile(filename = name, mimetype = "text/plain", nonce = "", authenticationTag = "", key = "") + }.toMutableMap() + val metadata = DecryptedFolderMetadataFile( + metadata = DecryptedMetadata(files = files, metadataKey = ByteArray(0)), + version = "2.0" + ) + + assertEquals(FILE_NAMES, resolver.getCollidedFileNames(metadata)) + } + + @Test + fun collidedFileNamesOfUnknownMetadataAreEmpty() { + assertTrue(resolver.getCollidedFileNames(null).isEmpty()) + assertTrue(resolver.getCollidedFileNames("metadata").isEmpty()) + } + + @Test + fun updateSizeStoresSizeOfExistingUpload() { + val upload: OCUpload = mock() + whenever(uploadsStorageManager.getUploadById(UPLOAD_ID)).thenReturn(upload) + + resolver.updateSize(1024) + + verify(upload).fileSize = 1024 + verify(uploadsStorageManager).updateUpload(upload) + } + + @Test + fun updateSizeIgnoresMissingUpload() { + whenever(uploadsStorageManager.getUploadById(UPLOAD_ID)).thenReturn(null) + + resolver.updateSize(1024) + + verify(uploadsStorageManager, never()).updateUpload(any()) + } + + @Test + fun createNewOCFileCopiesPropertiesToNewRemotePath() { + val original = OCFile("/photo.jpg").apply { + creationTimestamp = 1 + fileLength = 2 + mimeType = "image/jpeg" + modificationTimestamp = 3 + modificationTimestampAtLastSyncForData = 4 + etag = "etag" + lastSyncDateForProperties = 5 + lastSyncDateForData = 6 + storagePath = "/storage/photo.jpg" + parentId = 7 + } + + val renamed = resolver.createNewOCFile(original, "/photo (2).jpg") + + assertEquals("/photo (2).jpg", renamed.remotePath) + assertEquals(1L, renamed.creationTimestamp) + assertEquals(2L, renamed.fileLength) + assertEquals("image/jpeg", renamed.mimeType) + assertEquals(3L, renamed.modificationTimestamp) + assertEquals(4L, renamed.modificationTimestampAtLastSyncForData) + assertEquals("etag", renamed.etag) + assertEquals(5L, renamed.lastSyncDateForProperties) + assertEquals(6L, renamed.lastSyncDateForData) + assertEquals("/storage/photo.jpg", renamed.storagePath) + assertEquals(7L, renamed.parentId) + } + + @Test + fun createLocalFolderUsesExistingParent() { + val parent = OCFile("/a/").apply { fileId = 10 } + whenever(fileDataStorageManager.getFileByPath("/a/")).thenReturn(parent) + + val folder = resolver.createLocalFolder("/a/b/") + + assertNotNull(folder) + assertEquals("/a/b/", folder?.remotePath) + assertEquals(MimeType.DIRECTORY, folder?.mimeType) + assertEquals(10L, folder?.parentId) + verify(fileDataStorageManager).saveFile(folder) + } + + @Test + fun createLocalFolderCreatesMissingParents() { + val root = OCFile(OCFile.ROOT_PATH).apply { fileId = 1 } + whenever(fileDataStorageManager.getFileByPath(OCFile.ROOT_PATH)).thenReturn(root) + var nextId = 2L + doAnswer { + it.getArgument(0).fileId = nextId++ + true + }.whenever(fileDataStorageManager).saveFile(any()) + + val folder = resolver.createLocalFolder("/a/b/") + + assertEquals("/a/b/", folder?.remotePath) + assertEquals(2L, folder?.parentId) + verify(fileDataStorageManager).saveFile(argThat { remotePath == "/a/" && parentId == 1L }) + } + + @Test + fun createLocalFolderReturnsNullWithoutParentPath() { + assertNull(resolver.createLocalFolder(OCFile.ROOT_PATH)) + verify(fileDataStorageManager, never()).saveFile(any()) + } + + @Test + fun updateOCFileCopiesRemoteProperties() { + val imageDimension = ImageDimension(640f, 480f) + val geoLocation = GeoLocation(52.52, 13.405) + val remoteFile: RemoteFile = mock { + on { creationTimestamp } doReturn 1L + on { length } doReturn 2L + on { mimeType } doReturn "image/jpeg" + on { modifiedTimestamp } doReturn 3L + on { etag } doReturn "etag" + on { remoteId } doReturn "remote-id" + on { permissions } doReturn "RGDNVW" + on { uploadTimestamp } doReturn 4L + on { isHasPreview } doReturn true + on { this.imageDimension } doReturn imageDimension + on { this.geoLocation } doReturn geoLocation + } + val file = OCFile("/photo.jpg") + + resolver.updateOCFile(file, remoteFile) + + assertEquals(1L, file.creationTimestamp) + assertEquals(2L, file.fileLength) + assertEquals("image/jpeg", file.mimeType) + assertEquals(3L, file.modificationTimestamp) + assertEquals(3L, file.modificationTimestampAtLastSyncForData) + assertEquals("etag", file.etag) + assertEquals("etag", file.etagOnServer) + assertEquals("remote-id", file.remoteId) + assertEquals("RGDNVW", file.permissions) + assertEquals(4L, file.uploadTimestamp) + assertTrue(file.isPreviewAvailable) + assertEquals(imageDimension, file.imageDimension) + assertEquals(geoLocation, file.geoLocation) + } + + private companion object { + const val UPLOAD_ID = 5L + val FILE_NAMES = listOf("first.txt", "second.txt") + } +}