diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 940739adb944..26b457f12841 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -118,6 +118,7 @@ android:icon="@mipmap/ic_launcher" android:installLocation="internalOnly" android:label="@string/app_name" + android:largeHeap="true" android:manageSpaceActivity="com.owncloud.android.ui.activity.ManageSpaceActivity" android:memtagMode="async" android:networkSecurityConfig="@xml/network_security_config" 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 b13ebabe7578..db0b09a2af7e 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 @@ -502,7 +502,7 @@ class AutoUploadWorker( val dateTime = formatter.parse(exifDate, pos) if (dateTime != null) { lastModificationTime = dateTime.time - Log_OC.w(TAG, "calculateLastModificationTime calculatedTime is: $lastModificationTime") + Log_OC.i(TAG, "calculateLastModificationTime calculatedTime is: $lastModificationTime") } else { Log_OC.w(TAG, "calculateLastModificationTime dateTime is empty") } diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index a9aa21d61f73..92c747faa22a 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -172,7 +172,7 @@ object UploadErrorNotificationManager { result.code == ResultCode.USER_CANCELLED || operation.isMissingPermissionThrown ) { - Log_OC.w(TAG, "operation is successful, cancelled or lack of storage permission") + Log_OC.i(TAG, "operation is successful, cancelled or lack of storage permission") return false } diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 0b0bba6c6812..14a9b4140133 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -98,6 +98,7 @@ public class FileDataStorageManager { private static final String FAILED_TO_INSERT_MSG = "Fail to insert insert file to database "; private static final String SENDING_TO_FILECONTENTPROVIDER_MSG = "Sending %d operations to FileContentProvider"; private static final String EXCEPTION_MSG = "Exception in batch of operations "; + private static final int BATCH_SIZE = 500; // Maximum number of operations per batch to avoid memory issues public static final int ROOT_PARENT_ID = 0; private static final String JSON_NULL_STRING = "null"; @@ -670,13 +671,59 @@ public void saveNewFile(OCFile newFile) { * @param filesToRemove */ public void saveFolder(OCFile folder, List updatedFiles, Collection filesToRemove) { + String threadName = Thread.currentThread().getName(); Log_OC.d(TAG, "Saving folder " + folder.getRemotePath() + " with " + updatedFiles.size() - + " children and " + filesToRemove.size() + " files to remove"); + + " children and " + filesToRemove.size() + " files to remove [Thread: " + threadName + "]"); + + // Process files in batches to avoid memory issues with large folders + int totalFiles = updatedFiles.size(); + if (totalFiles > BATCH_SIZE) { + Log_OC.d(TAG, "Large folder detected (" + totalFiles + " files). Processing in batches of " + BATCH_SIZE + " [Thread: " + threadName + "]"); + + // Process files in batches + for (int i = 0; i < totalFiles; i += BATCH_SIZE) { + int endIndex = Math.min(i + BATCH_SIZE, totalFiles); + List batchFiles = updatedFiles.subList(i, endIndex); + saveFolderBatch(folder, batchFiles, i); + } + } else { + // Small folder - process normally + saveFolderBatch(folder, updatedFiles, 0); + } - ArrayList operations = new ArrayList<>(updatedFiles.size()); + // Process deletions separately + if (!filesToRemove.isEmpty()) { + processFileRemovals(folder, filesToRemove); + } + + // Update folder metadata (always last) + updateFolderMetadata(folder); + } + + /** + * Saves a batch of files to the database without updating folder metadata. + * This is used when processing large folders in batches to avoid memory issues. + * + * @param folder The parent folder + * @param batchFiles The files to save in this batch + * @param startIndex The starting index in the original list (for logging) + */ + public void saveFolderBatchOnly(OCFile folder, List batchFiles, int startIndex) { + saveFolderBatch(folder, batchFiles, startIndex); + } + + /** + * Saves a batch of files to the database. + * + * @param folder The parent folder + * @param batchFiles The files to save in this batch + * @param startIndex The starting index in the original list (for logging) + */ + private void saveFolderBatch(OCFile folder, List batchFiles, int startIndex) { + ArrayList operations = new ArrayList<>(batchFiles.size()); // prepare operations to insert or update files to save in the given folder - for (OCFile ocFile : updatedFiles) { + for (OCFile ocFile : batchFiles) { ContentValues contentValues = createContentValuesForFile(ocFile); contentValues.put(ProviderTableMeta.FILE_PARENT, folder.getFileId()); @@ -700,10 +747,51 @@ public void saveFolder(OCFile folder, List updatedFiles, Collection fileIterator = batchFiles.iterator(); + for (ContentProviderResult result : results) { + OCFile ocFile = fileIterator.hasNext() ? fileIterator.next() : null; + if (result.uri != null && ocFile != null) { + try { + long newId = Long.parseLong(result.uri.getPathSegments().get(1)); + ocFile.setFileId(newId); + } catch (NumberFormatException | IndexOutOfBoundsException e) { + Log_OC.e(TAG, "Failed to parse file ID from URI: " + result.uri, e); + } + } + } + } + } + + /** + * Processes file removals for a folder. + * + * @param folder The parent folder + * @param filesToRemove The files to remove + */ + public void processFileRemovals(OCFile folder, Collection filesToRemove) { + ArrayList operations = new ArrayList<>(); String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + ProviderTableMeta.FILE_PATH + " = ?"; String[] whereArgs = new String[2]; whereArgs[0] = user.getAccountName(); + for (OCFile ocFile : filesToRemove) { if (ocFile.getParentId() == folder.getFileId()) { whereArgs[1] = ocFile.getRemotePath(); @@ -731,49 +819,43 @@ public void saveFolder(OCFile folder, List updatedFiles, Collection operations = new ArrayList<>(1); + operations.add(ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI) .withValues(contentValues) .withSelection(ProviderTableMeta._ID + " = ?", new String[]{String.valueOf(folder.getFileId())}) .build()); - // apply operations in batch - ContentProviderResult[] results = null; - Log_OC.d(TAG, String.format(Locale.ENGLISH, SENDING_TO_FILECONTENTPROVIDER_MSG, operations.size())); - try { if (getContentResolver() != null) { - results = getContentResolver().applyBatch(MainApp.getAuthority(), operations); - + getContentResolver().applyBatch(MainApp.getAuthority(), operations); } else { - results = getContentProviderClient().applyBatch(operations); + getContentProviderClient().applyBatch(operations); } - } catch (OperationApplicationException | RemoteException e) { Log_OC.e(TAG, EXCEPTION_MSG + e.getMessage(), e); } - - // update new id in file objects for insertions - if (results != null) { - long newId; - Iterator fileIterator = updatedFiles.iterator(); - OCFile ocFile; - for (ContentProviderResult result : results) { - if (fileIterator.hasNext()) { - ocFile = fileIterator.next(); - } else { - ocFile = null; - } - if (result.uri != null) { - newId = Long.parseLong(result.uri.getPathSegments().get(1)); - if (ocFile != null) { - ocFile.setFileId(newId); - } - } - } - } } /** diff --git a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java index 03d85e22a902..cd262f375cae 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java +++ b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java @@ -630,7 +630,18 @@ public long getLocalId() { if (localId > 0) { return localId; } else if (remoteId != null && remoteId.length() > 8) { - return Long.parseLong(remoteId.substring(0, 8).replaceAll("^0*", "")); + try { + // Only try to parse as number if it looks like a number (starts with digit) + String potentialNumber = remoteId.substring(0, 8).replaceAll("^0*", ""); + if (potentialNumber.matches("\\d+")) { + return Long.parseLong(potentialNumber); + } else { + return -1; + } + } catch (NumberFormatException e) { + Log_OC.w("OCFile", "Failed to parse localId from remoteId: '" + remoteId + "', returning -1"); + return -1; + } } else { return -1; } diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index 08250553fd6c..cd652cc6266c 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -68,6 +68,7 @@ import java.io.FileNotFoundException; import java.io.InputStream; import java.lang.ref.WeakReference; +import java.net.URLEncoder; import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutorService; @@ -644,9 +645,24 @@ private Bitmap doThumbnailFromOCFileInBackground() { try { String uri; if (file instanceof OCFile) { - uri = mClient.getBaseUri() + "/index.php/core/preview?fileId=" - + file.getLocalId() - + "&x=" + pxW + "&y=" + pxH + "&a=1&mode=cover&forceIcon=0"; + long localId = file.getLocalId(); + if (localId > 0) { + // Use fileId if available + uri = mClient.getBaseUri() + "/index.php/core/preview?fileId=" + + localId + + "&x=" + pxW + "&y=" + pxH + "&a=1&mode=cover&forceIcon=0"; + } else { + // Try different API endpoints for Nextcloud 31 + String filePath = ((OCFile) file).getRemotePath(); + // Try Nextcloud 31 files API thumbnail endpoint + String cleanPath = filePath.startsWith("/") ? filePath.substring(1) : filePath; + try { + cleanPath = URLEncoder.encode(cleanPath, "UTF-8"); + } catch (Exception e) { + // Ignore encoding errors + } + uri = mClient.getBaseUri() + "/index.php/apps/files/api/v1/thumbnail/" + pxW + "/" + pxH + "/" + cleanPath; + } } else { uri = mClient.getBaseUri() + "/index.php/apps/files_trashbin/preview?fileId=" + file.getLocalId() + "&x=" + pxW + "&y=" + pxH; diff --git a/app/src/main/java/com/owncloud/android/lib/resources/files/PropFindSaxHandler.java b/app/src/main/java/com/owncloud/android/lib/resources/files/PropFindSaxHandler.java new file mode 100644 index 000000000000..f86159422ef3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/lib/resources/files/PropFindSaxHandler.java @@ -0,0 +1,605 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.lib.resources.files; + +import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.utils.MimeType; + +import org.xml.sax.Attributes; +import org.xml.sax.SAXException; +import org.xml.sax.helpers.DefaultHandler; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * SAX Handler for parsing WebDAV PROPFIND responses. + * Handles streaming XML parsing to avoid OutOfMemoryError for large folders. + */ +public class PropFindSaxHandler extends DefaultHandler { + private static final String TAG = PropFindSaxHandler.class.getSimpleName(); + + // XML Namespaces + private static final String NS_DAV = "DAV:"; + private static final String NS_OC = "http://owncloud.org/ns"; + private static final String NS_NC = "http://nextcloud.org/ns"; + + // Element names + private static final String ELEMENT_RESPONSE = "response"; + private static final String ELEMENT_HREF = "href"; + private static final String ELEMENT_PROP = "prop"; + private static final String ELEMENT_PROPSTAT = "propstat"; + private static final String ELEMENT_GETLASTMODIFIED = "getlastmodified"; + private static final String ELEMENT_GETETAG = "getetag"; + private static final String ELEMENT_RESOURCETYPE = "resourcetype"; + private static final String ELEMENT_COLLECTION = "collection"; + private static final String ELEMENT_GETCONTENTLENGTH = "getcontentlength"; + private static final String ELEMENT_GETCONTENTTYPE = "getcontenttype"; + private static final String ELEMENT_ID = "id"; + private static final String ELEMENT_FILEID = "fileid"; + private static final String ELEMENT_PERMISSIONS = "permissions"; + private static final String ELEMENT_SIZE = "size"; + private static final String ELEMENT_FAVORITE = "favorite"; + private static final String ELEMENT_OWNER_ID = "owner-id"; + private static final String ELEMENT_OWNER_DISPLAY_NAME = "owner-display-name"; + private static final String ELEMENT_HAS_PREVIEW = "has-preview"; + private static final String ELEMENT_MOUNT_TYPE = "mount-type"; + private static final String ELEMENT_IS_ENCRYPTED = "is-encrypted"; + private static final String ELEMENT_NOTE = "note"; + private static final String ELEMENT_LOCK = "lock"; + private static final String ELEMENT_RICH_WORKSPACE = "rich-workspace"; + private static final String ELEMENT_COMMENTS_UNREAD = "comments-unread"; + private static final String ELEMENT_STATUS = "status"; + + // Debug constants + private static final boolean DEBUG_XML = true; + + private final List files = new ArrayList<>(); + private RemoteFile currentFile; + private final StringBuilder currentText = new StringBuilder(); + private boolean inResponse = false; + private boolean inProp = false; + private boolean inPropstat = false; + private boolean inResourcetype = false; + private boolean isCollection = false; + private String currentHref; + private String davBasePath; + // Track if we've determined the resource type (file or folder) + private boolean resourceTypeDetermined = false; + // Store the determined resource type for this file (default to file) + private boolean currentResourceIsCollection = false; + // Track HTTP status in current propstat (only process properties if status is 200 OK) + private boolean propstatStatusOk = false; + + // Lock information + private boolean inLock = false; + private StringBuilder lockText = new StringBuilder(); + + public PropFindSaxHandler() { + this(null); + } + + public PropFindSaxHandler(String davBasePath) { + this.davBasePath = davBasePath; + } + + @Override + public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException { + currentText.setLength(0); + + if (NS_DAV.equals(uri) && ELEMENT_RESPONSE.equals(localName)) { + inResponse = true; + currentFile = new RemoteFile("/"); // Temporary path, will be set from href + currentHref = null; + isCollection = false; // Reset for each response - assume file by default + resourceTypeDetermined = false; // Will be set when we process resourcetype + currentResourceIsCollection = false; // Default to file, will be set to true if found + } else if (NS_DAV.equals(uri) && ELEMENT_PROPSTAT.equals(localName)) { + inPropstat = true; + // Default to true - assume properties are valid unless status says otherwise + // Status might come before or after prop, so we'll update this when we see status + propstatStatusOk = true; + } else if (NS_DAV.equals(uri) && ELEMENT_PROP.equals(localName)) { + inProp = true; + } else if (NS_DAV.equals(uri) && ELEMENT_RESOURCETYPE.equals(localName)) { + inResourcetype = true; + // Reset collection flag when starting resourcetype - assume it's a file + isCollection = false; + } else if (NS_DAV.equals(uri) && ELEMENT_COLLECTION.equals(localName)) { + isCollection = true; + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_LOCK.equals(localName)) { + inLock = true; + lockText.setLength(0); + } + } + + @Override + public void characters(char[] ch, int start, int length) throws SAXException { + if (inLock) { + lockText.append(ch, start, length); + } else { + currentText.append(ch, start, length); + } + } + + @Override + public void endElement(String uri, String localName, String qName) throws SAXException { + String text = currentText.toString().trim(); + + // Debug: Always log OC/NC elements + if (NS_OC.equals(uri) || NS_NC.equals(uri)) { + } + + // Debug: Log all DAV elements that might contain size/preview info + if (NS_DAV.equals(uri) && ( + "quota-used-bytes".equals(localName) || + "quota-available-bytes".equals(localName) || + "getcontentlength".equals(localName) || + "getcontenttype".equals(localName) + )) { + } + + if (NS_DAV.equals(uri) && ELEMENT_RESPONSE.equals(localName)) { + if (currentFile != null && currentHref != null) { + String remotePath = extractPathFromHref(currentHref); + currentFile.setRemotePath(remotePath); + + // Ensure MimeType is properly set based on resource type determination + String finalMimeType = currentFile.getMimeType(); + if (resourceTypeDetermined) { + // Resource type was determined from resourcetype element + if (currentResourceIsCollection) { + // Make sure folder has DIRECTORY mime type + if (!MimeType.DIRECTORY.equals(finalMimeType)) { + currentFile.setMimeType(MimeType.DIRECTORY); + } + } else { + // This is a file - ensure it has a proper mime type + if (finalMimeType == null || finalMimeType.isEmpty() || MimeType.DIRECTORY.equals(finalMimeType)) { + // No mime type set from getcontenttype, use default + currentFile.setMimeType(MimeType.FILE); + } + } + } else { + // Resource type was not determined - this shouldn't happen with proper XML + Log_OC.w(TAG, "WARNING: Resource type was not determined for path: " + currentFile.getRemotePath() + " - assuming it's a file"); + if (finalMimeType == null || finalMimeType.isEmpty()) { + currentFile.setMimeType(MimeType.FILE); + } + } + + // Ensure remoteId is always set, even if server doesn't provide it + String remoteId = currentFile.getRemoteId(); + if (remoteId == null || remoteId.isEmpty()) { + // Try to use fileid as fallback + Long localId = currentFile.getLocalId(); + if (localId != null && localId > 0) { + remoteId = String.valueOf(localId); + currentFile.setRemoteId(remoteId); + } else { + // Try to create a stable remoteId from path + String path = currentFile.getRemotePath(); + if (path != null && !path.isEmpty()) { + if ("/".equals(path)) { + // For root directory, use a fixed id + remoteId = "root"; + currentFile.setRemoteId(remoteId); + } else { + // For files and folders, create a stable id based on path + // Use a prefix to ensure it's not treated as a number + String normalizedPath = path.startsWith("/") ? path.substring(1) : path; + remoteId = "path_" + normalizedPath.replace("/", "_").replace(" ", "_").replace(".", "_").replaceAll("[^a-zA-Z0-9_]", "_"); + currentFile.setRemoteId(remoteId); + } + } else { + // Last resort: use path hash with prefix + remoteId = "hash_" + Math.abs((path != null ? path : "").hashCode()); + currentFile.setRemoteId(remoteId); + Log_OC.w(TAG, "WARNING: Using path hash as remoteId fallback: " + remoteId + " for path: " + (path != null ? path : "null")); + } + } + } + + files.add(currentFile); + } + inResponse = false; + currentFile = null; + currentHref = null; + isCollection = false; + resourceTypeDetermined = false; + } else if (NS_DAV.equals(uri) && ELEMENT_HREF.equals(localName)) { + currentHref = text; + } else if (NS_DAV.equals(uri) && ELEMENT_STATUS.equals(localName)) { + // Check if status is 200 OK + // Format: "HTTP/1.1 200 OK" or "HTTP/1.1 404 Not Found" + // Note: Status might come before or after prop in the XML + boolean statusIsOk = text != null && text.contains("200"); + if (statusIsOk) { + propstatStatusOk = true; + } else { + propstatStatusOk = false; + // If status came after prop, we've already processed some properties + // We can't undo that, but at least we know this propstat had an error + } + } else if (NS_DAV.equals(uri) && ELEMENT_PROPSTAT.equals(localName)) { + inPropstat = false; + propstatStatusOk = false; // Reset for next propstat + } else if (NS_DAV.equals(uri) && ELEMENT_PROP.equals(localName)) { + inProp = false; + } else if (NS_DAV.equals(uri) && ELEMENT_RESOURCETYPE.equals(localName)) { + inResourcetype = false; + if (currentFile != null) { + currentResourceIsCollection = isCollection; // Store the determined type + resourceTypeDetermined = true; // We've processed resourcetype, so type is determined + if (isCollection) { + currentFile.setMimeType(MimeType.DIRECTORY); + } else { + // This is a file - don't set MimeType here, wait for getcontenttype + } + } + // Don't reset isCollection here - it's still needed for other elements like oc:size + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_LOCK.equals(localName)) { + if (currentFile != null && lockText.length() > 0) { + parseLockInfo(lockText.toString()); + } + inLock = false; + lockText.setLength(0); + } else if (inProp && currentFile != null && inPropstat) { + // Process all properties regardless of status for debugging + if (!propstatStatusOk) { + Log_OC.w(TAG, "Processing properties for propstat with non-OK status: " + uri + ":" + localName + " = '" + text + "'"); + } + if (DEBUG_XML && (NS_OC.equals(uri) || NS_NC.equals(uri))) { + Log_OC.d(TAG, "FOUND OC/NC ELEMENT in prop: " + uri + ":" + localName + " = '" + text + "' (inProp=" + inProp + ", currentFile=" + (currentFile != null) + ", inPropstat=" + inPropstat + ")"); + } + // Parse DAV properties + if (NS_DAV.equals(uri) && ELEMENT_GETLASTMODIFIED.equals(localName)) { + currentFile.setModifiedTimestamp(parseLastModified(text)); + } else if (NS_DAV.equals(uri) && ELEMENT_GETETAG.equals(localName)) { + currentFile.setEtag(parseEtag(text)); + } else if (NS_DAV.equals(uri) && ELEMENT_GETCONTENTLENGTH.equals(localName)) { + try { + currentFile.setLength(Long.parseLong(text)); + } catch (NumberFormatException e) { + // Ignore invalid content length + } + } else if (NS_DAV.equals(uri) && ELEMENT_GETCONTENTTYPE.equals(localName)) { + // Handle getcontenttype - this can help determine file type + if (currentFile != null) { + // Special case: if getcontenttype is "httpd/unix-directory", this is definitely a folder + if ("httpd/unix-directory".equals(text)) { + currentFile.setMimeType(MimeType.DIRECTORY); + currentResourceIsCollection = true; + resourceTypeDetermined = true; + Log_OC.d(TAG, "Detected folder from getcontenttype 'httpd/unix-directory' for: " + currentFile.getRemotePath()); + } else if (currentResourceIsCollection) { + // Already determined this is a collection from resourcetype, keep DIRECTORY + Log_OC.d(TAG, "Skipping getcontenttype '" + text + "' for already identified folder: " + currentFile.getRemotePath()); + } else { + // This appears to be a file + String currentMimeType = currentFile.getMimeType(); + // Only set if MimeType is not already set to DIRECTORY or WEBDAV_FOLDER + if (currentMimeType == null || + (!MimeType.DIRECTORY.equals(currentMimeType) && + !MimeType.WEBDAV_FOLDER.equals(currentMimeType))) { + // This is a file, set its MimeType + if (text != null && !text.isEmpty()) { + currentFile.setMimeType(text); + if (!resourceTypeDetermined) { + resourceTypeDetermined = true; // We now know this is a file + } + } else { + // No content type specified or empty, will set default at end + Log_OC.d(TAG, "Empty getcontenttype for file, will use default later"); + } + } + } + } + } + // Parse ownCloud/Nextcloud properties + if (NS_OC.equals(uri) || NS_NC.equals(uri)) { + Log_OC.d(TAG, "Processing OC/NC element: " + uri + ":" + localName + " = '" + text + "'"); + } + // Handle various possible id element names - try all possible variations + // Nextcloud 31 might use different element names + if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && + ("id".equals(localName) || "fileid".equals(localName) || "file-id".equals(localName) || + "fileId".equals(localName) || "resource-id".equals(localName) || "file_id".equals(localName) || + "nc:id".equals(localName) || "oc:id".equals(localName))) { + Log_OC.d(TAG, "Processing id element: " + uri + ":" + localName + " = '" + text + "' for file: " + (currentFile != null && currentFile.getRemotePath() != null ? currentFile.getRemotePath() : "unknown")); + if (text != null && !text.isEmpty() && !"null".equals(text)) { + // Only set if not already set or if this is a more specific id + String currentRemoteId = currentFile.getRemoteId(); + if (currentRemoteId == null || currentRemoteId.isEmpty() || currentRemoteId.startsWith("/")) { + currentFile.setRemoteId(text); + Log_OC.d(TAG, "Set remoteId from " + uri + ":" + localName + ": " + text); + } else { + Log_OC.d(TAG, "remoteId already set to: " + currentRemoteId + ", skipping " + uri + ":" + localName); + } + } else { + Log_OC.d(TAG, localName + " element found but text is empty, null, or 'null': '" + text + "'"); + } + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && "fileid".equals(localName)) { + // Additional check for fileid element + Log_OC.d(TAG, "Found fileid element: " + uri + ":fileid = '" + text + "' for file: " + (currentFile != null && currentFile.getRemotePath() != null ? currentFile.getRemotePath() : "unknown")); + if (text != null && !text.isEmpty() && !"null".equals(text)) { + try { + long fileIdLong = Long.parseLong(text); + currentFile.setLocalId(fileIdLong); + // If remoteId was not set from other id elements, use fileid as fallback + String currentRemoteId = currentFile.getRemoteId(); + if (currentRemoteId == null || currentRemoteId.isEmpty() || currentRemoteId.startsWith("/")) { + String fileIdStr = String.valueOf(fileIdLong); + currentFile.setRemoteId(fileIdStr); + Log_OC.d(TAG, "Set remoteId from fileid fallback: " + fileIdStr + " for file: " + (currentFile.getRemotePath() != null ? currentFile.getRemotePath() : "unknown")); + } else { + Log_OC.d(TAG, "remoteId already set, skipping fileid fallback for: " + currentRemoteId); + } + } catch (NumberFormatException e) { + Log_OC.w(TAG, "Failed to parse fileid: '" + text + "'"); + } + } + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_FILEID.equals(localName)) { + Log_OC.d(TAG, "Processing " + uri + ":fileid element with text: '" + text + "'"); + try { + long fileIdLong = Long.parseLong(text); + currentFile.setLocalId(fileIdLong); + // If remoteId was not set from other id elements, use fileid as fallback + String currentRemoteId = currentFile.getRemoteId(); + if (currentRemoteId == null || currentRemoteId.isEmpty() || currentRemoteId.startsWith("/")) { + String fileIdStr = String.valueOf(fileIdLong); + currentFile.setRemoteId(fileIdStr); + Log_OC.d(TAG, "Set remoteId from " + uri + ":fileid (fallback): " + fileIdStr + " for file: " + (currentFile.getRemotePath() != null ? currentFile.getRemotePath() : "unknown")); + } else { + Log_OC.d(TAG, "remoteId already set, skipping fileid fallback for: " + currentRemoteId); + } + } catch (NumberFormatException e) { + Log_OC.w(TAG, "Failed to parse " + uri + ":fileid: '" + text + "'"); + } + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_PERMISSIONS.equals(localName)) { + currentFile.setPermissions(text); + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_SIZE.equals(localName)) { + try { + if (currentResourceIsCollection) { + currentFile.setSize(Long.parseLong(text)); + } + } catch (NumberFormatException e) { + } + } else if (NS_DAV.equals(uri) && "quota-used-bytes".equals(localName)) { + try { + if (currentResourceIsCollection) { + long quotaSize = Long.parseLong(text); + currentFile.setSize(quotaSize); + } + } catch (NumberFormatException e) { + } + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_FAVORITE.equals(localName)) { + currentFile.setFavorite("1".equals(text)); + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_OWNER_ID.equals(localName)) { + currentFile.setOwnerId(text); + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_OWNER_DISPLAY_NAME.equals(localName)) { + currentFile.setOwnerDisplayName(text); + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_HAS_PREVIEW.equals(localName)) { + boolean hasPreview = "true".equalsIgnoreCase(text); + currentFile.setHasPreview(hasPreview); + } else if (NS_DAV.equals(uri) && "getcontenttype".equals(localName)) { + // Set preview availability based on MIME type for Nextcloud 31 + if (text != null && currentFile != null && !currentResourceIsCollection) { + boolean hasPreview = isPreviewableMimeType(text); + if (hasPreview) { + currentFile.setHasPreview(true); + } + } + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_MOUNT_TYPE.equals(localName)) { + // Mount type parsing - using reflection since WebdavEntry is from library + try { + parseMountType(text); + } catch (Exception e) { + Log_OC.w(TAG, "Could not parse mount type: " + text); + } + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_IS_ENCRYPTED.equals(localName)) { + currentFile.setEncrypted("true".equalsIgnoreCase(text)); + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_NOTE.equals(localName)) { + currentFile.setNote(text); + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_RICH_WORKSPACE.equals(localName)) { + currentFile.setRichWorkspace(text); + } else if ((NS_OC.equals(uri) || NS_NC.equals(uri)) && ELEMENT_COMMENTS_UNREAD.equals(localName)) { + try { + currentFile.setUnreadCommentsCount(Integer.parseInt(text)); + } catch (NumberFormatException e) { + // Ignore invalid count + } + } + } + + currentText.setLength(0); + } + + /** + * Extract path from href. + * Href format: /remote.php/dav/files/{user}/{path} or http://server/remote.php/dav/files/{user}/{path} + * We need to extract {path} relative to DAV base path. + */ + private String extractPathFromHref(String href) { + if (href == null || href.isEmpty()) { + return "/"; + } + + // Remove protocol and domain if present (http://server/path -> /path) + String normalizedHref = href; + try { + java.net.URI uri = new java.net.URI(href); + String path = uri.getPath(); + if (path != null) { + normalizedHref = path; + } + } catch (Exception e) { + // If URI parsing fails, use href as-is + } + + // If we have davBasePath, use it to extract relative path + if (davBasePath != null) { + try { + java.net.URI baseUri = new java.net.URI(davBasePath); + String basePath = baseUri.getPath(); + if (basePath != null && normalizedHref.startsWith(basePath)) { + String path = normalizedHref.substring(basePath.length()); + // Ensure path starts with / + if (!path.startsWith("/")) { + path = "/" + path; + } + return path.isEmpty() ? "/" : path; + } + } catch (Exception e) { + // If URI parsing fails, try string matching + if (normalizedHref.startsWith(davBasePath)) { + String path = normalizedHref.substring(davBasePath.length()); + if (!path.startsWith("/")) { + path = "/" + path; + } + return path.isEmpty() ? "/" : path; + } + } + } + + // Fallback: try to extract path after /dav/files/ + int davFilesIndex = normalizedHref.indexOf("/dav/files/"); + if (davFilesIndex >= 0) { + String path = normalizedHref.substring(davFilesIndex + "/dav/files/".length()); + // Remove user name (everything before first /) + int firstSlash = path.indexOf('/'); + if (firstSlash >= 0) { + path = path.substring(firstSlash); + } else { + path = "/"; + } + return path; + } + + // If we can't extract, return normalized href as-is + return normalizedHref.isEmpty() ? "/" : normalizedHref; + } + + /** + * Parse RFC 1123 date format (e.g., "Mon, 01 Jan 2024 12:00:00 GMT") + */ + private long parseLastModified(String dateStr) { + if (dateStr == null || dateStr.isEmpty()) { + return 0; + } + try { + SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US); + return format.parse(dateStr).getTime(); + } catch (ParseException e) { + Log_OC.w(TAG, "Failed to parse date: " + dateStr + ", error: " + e.getMessage()); + return 0; + } + } + + /** + * Parse ETag, removing surrounding quotes if present + */ + private String parseEtag(String etag) { + if (etag == null || etag.isEmpty()) { + return ""; + } + // ETag format: "abc123" -> abc123 + if (etag.startsWith("\"") && etag.endsWith("\"") && etag.length() > 1) { + return etag.substring(1, etag.length() - 1); + } + return etag; + } + + /** + * Parse mount type string and set it on RemoteFile using reflection. + * This is needed because WebdavEntry.MountType is from the library. + */ + @SuppressWarnings("unchecked") + private void parseMountType(String mountType) { + if (mountType == null || mountType.isEmpty() || currentFile == null) { + return; + } + try { + // Use reflection to access WebdavEntry.MountType enum from library + Class webdavEntryClass = Class.forName("com.owncloud.android.lib.resources.files.model.WebdavEntry"); + Class mountTypeEnum = Class.forName("com.owncloud.android.lib.resources.files.model.WebdavEntry$MountType"); + Object[] enumValues = mountTypeEnum.getEnumConstants(); + + String mountTypeUpper = mountType.toUpperCase(Locale.US); + Object mountTypeValue = null; + + for (Object enumValue : enumValues) { + if (enumValue.toString().equals(mountTypeUpper)) { + mountTypeValue = enumValue; + break; + } + } + + if (mountTypeValue == null) { + // Try INTERNAL as default + for (Object enumValue : enumValues) { + if (enumValue.toString().equals("INTERNAL")) { + mountTypeValue = enumValue; + break; + } + } + } + + if (mountTypeValue != null) { + java.lang.reflect.Method setMountTypeMethod = currentFile.getClass().getMethod("setMountType", mountTypeEnum); + setMountTypeMethod.invoke(currentFile, mountTypeValue); + } + } catch (Exception e) { + // If reflection fails, mount type will remain unset (default value) + Log_OC.w(TAG, "Could not set mount type: " + mountType); + } + } + + /** + * Parse lock information from XML + */ + private void parseLockInfo(String lockXml) { + // Basic lock parsing - set locked flag if lock element exists + if (currentFile != null && lockXml != null && !lockXml.trim().isEmpty()) { + currentFile.setLocked(true); + // TODO: Parse detailed lock information if needed + } + } + + /** + * Check if MIME type supports preview generation in Nextcloud + */ + private boolean isPreviewableMimeType(String mimeType) { + if (mimeType == null) return false; + + // Images + if (mimeType.startsWith("image/")) return true; + + // Videos + if (mimeType.startsWith("video/")) return true; + + // PDFs + if ("application/pdf".equals(mimeType)) return true; + + // Office documents + if (mimeType.startsWith("application/vnd.openxmlformats-officedocument.") || + mimeType.startsWith("application/msword") || + mimeType.startsWith("application/vnd.ms-") || + mimeType.startsWith("application/vnd.oasis.opendocument.")) return true; + + return false; + } + + public List getFiles() { + return files; + } +} + diff --git a/app/src/main/java/com/owncloud/android/lib/resources/files/StreamingReadFolderRemoteOperation.java b/app/src/main/java/com/owncloud/android/lib/resources/files/StreamingReadFolderRemoteOperation.java new file mode 100644 index 000000000000..4c214f65bd95 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/lib/resources/files/StreamingReadFolderRemoteOperation.java @@ -0,0 +1,182 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.lib.resources.files; + +import android.net.Uri; + +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode; +import com.owncloud.android.lib.common.utils.Log_OC; + +import org.apache.commons.httpclient.HttpConnection; +import org.apache.commons.httpclient.HttpState; +import org.apache.commons.httpclient.HttpStatus; +import org.apache.jackrabbit.webdav.DavConstants; +import org.apache.jackrabbit.webdav.client.methods.PropFindMethod; +import org.apache.jackrabbit.webdav.property.DavPropertyNameSet; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +import org.xml.sax.SAXException; +import org.xml.sax.SAXParseException; + +/** + * Remote operation that reads a folder from the server using streaming XML parser. + * This implementation uses SAX parser to avoid loading the entire XML response into memory, + * preventing OutOfMemoryError for large folders with many files. + * + * @see ReadFolderRemoteOperation + */ +public class StreamingReadFolderRemoteOperation extends RemoteOperation> { + private static final String TAG = StreamingReadFolderRemoteOperation.class.getSimpleName(); + + private final String remotePath; + + public StreamingReadFolderRemoteOperation(String remotePath) { + this.remotePath = remotePath; + } + + @Override + protected RemoteOperationResult> run(OwnCloudClient client) { + if (remotePath == null) { + return new RemoteOperationResult(ResultCode.UNKNOWN_ERROR); + } + + String davPath; + String davBasePath; + try { + Uri davUri = client.getFilesDavUri(); + davBasePath = davUri.toString(); + + // Use Uri.Builder to properly encode path segments with special characters (e.g., Cyrillic) + // Uri.Builder.appendPath() automatically URL-encodes each segment + Uri.Builder builder = davUri.buildUpon(); + + // Normalize remotePath - ensure it starts with / + String normalizedRemotePath = remotePath.startsWith("/") ? remotePath : "/" + remotePath; + + // Split remotePath into segments and append each one + // This ensures proper URL encoding of each segment (including Cyrillic characters) + String[] segments = normalizedRemotePath.split("/"); + for (String segment : segments) { + if (!segment.isEmpty()) { + builder.appendPath(segment); + } + } + + // If path ends with /, append empty segment to preserve trailing slash + if (normalizedRemotePath.endsWith("/") && !normalizedRemotePath.equals("/")) { + builder.appendPath(""); + } + + Uri fullUri = builder.build(); + davPath = fullUri.toString(); + } catch (Exception e) { + Log_OC.e(TAG, "Error getting DAV path", e); + return new RemoteOperationResult(e); + } + + StreamingPropFindMethod method = null; + try { + // Create PROPFIND request with depth 1 (folder + immediate children) + // Use StreamingPropFindMethod to prevent automatic response body processing + // Use ALL_PROP to get all available properties including OC/NC specific ones + method = new StreamingPropFindMethod(davPath, DavConstants.PROPFIND_ALL_PROP, DavConstants.DEPTH_1); + + int status = client.executeMethod(method); + + Log_OC.d(TAG, "PROPFIND request status: " + status + " for path: " + remotePath); + + if (status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK) { + InputStream inputStream = method.getResponseBodyAsStream(); + + if (inputStream == null) { + Log_OC.e(TAG, "Response body stream is null"); + return new RemoteOperationResult(ResultCode.UNKNOWN_ERROR); + } + + // Parse XML using SAX parser + PropFindSaxHandler handler = new PropFindSaxHandler(davBasePath); + SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setNamespaceAware(true); + + try { + SAXParser parser = factory.newSAXParser(); + parser.parse(inputStream, handler); + + List filesList = handler.getFiles(); + Log_OC.d(TAG, "Parsed " + filesList.size() + " files from folder: " + remotePath); + + // Convert List to ArrayList as required by setData() + ArrayList files = new ArrayList<>(filesList); + + RemoteOperationResult result = new RemoteOperationResult(true, method); + result.setData(files); + return result; + } catch (SAXParseException e) { + Log_OC.e(TAG, "XML parsing error at line " + e.getLineNumber() + + ", column " + e.getColumnNumber() + " for path: " + remotePath, e); + return new RemoteOperationResult(ResultCode.UNKNOWN_ERROR); + } catch (SAXException e) { + Log_OC.e(TAG, "SAX parsing error for path: " + remotePath, e); + return new RemoteOperationResult(ResultCode.UNKNOWN_ERROR); + } catch (ParserConfigurationException e) { + Log_OC.e(TAG, "SAX parser configuration error", e); + return new RemoteOperationResult(ResultCode.UNKNOWN_ERROR); + } catch (Exception e) { + Log_OC.e(TAG, "Error parsing XML response for path: " + remotePath, e); + return new RemoteOperationResult(e); + } finally { + try { + inputStream.close(); + } catch (Exception e) { + Log_OC.w(TAG, "Error closing input stream: " + e.getMessage()); + } + } + } else { + Log_OC.e(TAG, "PROPFIND request failed with status: " + status + " for path: " + remotePath); + return new RemoteOperationResult(false, method); + } + } catch (OutOfMemoryError e) { + Log_OC.e(TAG, "OutOfMemoryError while fetching folder " + remotePath, e); + return new RemoteOperationResult(ResultCode.UNKNOWN_ERROR); + } catch (Exception e) { + Log_OC.e(TAG, "Exception while fetching folder " + remotePath, e); + return new RemoteOperationResult(e); + } finally { + if (method != null) { + method.releaseConnection(); + } + } + } + + /** + * Custom PropFindMethod that doesn't automatically process the response body. + * This allows us to get the raw InputStream for streaming XML parsing. + */ + private static class StreamingPropFindMethod extends PropFindMethod { + StreamingPropFindMethod(String uri, int propfindType, int depth) throws IOException { + super(uri, propfindType, new DavPropertyNameSet(), depth); + } + + @Override + protected void processResponseBody(HttpState httpState, HttpConnection httpConnection) { + // Do not process the response body here. + // We need the raw stream for SAX parsing to avoid loading entire XML into memory. + } + } +} + diff --git a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java index f760af5e0538..84d869ae5eb7 100644 --- a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java @@ -35,6 +35,7 @@ import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation; import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation; +import com.owncloud.android.lib.resources.files.StreamingReadFolderRemoteOperation; import com.owncloud.android.lib.resources.files.model.RemoteFile; import com.owncloud.android.lib.resources.status.E2EVersion; import com.owncloud.android.lib.resources.users.GetPredefinedStatusesRemoteOperation; @@ -456,8 +457,38 @@ private RemoteOperationResult checkForChanges(OwnCloudClient client) { private RemoteOperationResult fetchAndSyncRemoteFolder(OwnCloudClient client) { String remotePath = mLocalFolder.getRemotePath(); - RemoteOperationResult result = new ReadFolderRemoteOperation(remotePath).execute(client); Log_OC.d(TAG, "⬇ eTag is changed or ignored, fetching folder: " + user.getAccountName() + remotePath); + + RemoteOperationResult result; + try { + // Try using streaming parser first (handles large folders without OOM) + result = new StreamingReadFolderRemoteOperation(remotePath).execute(client); + Log_OC.d(TAG, "Streaming parser completed for folder: " + remotePath); + } catch (OutOfMemoryError e) { + // Should not happen with streaming parser, but handle it just in case + Log_OC.e(TAG, "OutOfMemoryError with streaming parser for folder " + remotePath, e); + // Try fallback to legacy method + try { + Log_OC.w(TAG, "Falling back to legacy ReadFolderRemoteOperation: " + e.getMessage()); + result = new ReadFolderRemoteOperation(remotePath).execute(client); + } catch (OutOfMemoryError e2) { + Log_OC.e(TAG, "OutOfMemoryError with legacy parser as well for folder " + remotePath + + ". The folder is too large to parse XML response in memory. " + + "This is a limitation of ReadFolderRemoteOperation which parses entire XML response at once. " + + "The folder contains too many files to be processed in a single request. " + + "Consider splitting the folder into smaller subfolders.", e2); + return new RemoteOperationResult(ResultCode.SYNC_CONFLICT); + } + } catch (Exception e) { + // For other exceptions, try fallback to legacy method + Log_OC.w(TAG, "Streaming parser failed for folder " + remotePath + ", trying legacy method: " + e.getMessage()); + try { + result = new ReadFolderRemoteOperation(remotePath).execute(client); + } catch (Exception e2) { + Log_OC.e(TAG, "Both streaming and legacy parsers failed for folder " + remotePath, e2); + return new RemoteOperationResult(e2); + } + } if (result.isSuccess()) { synchronizeData(result.getData()); @@ -510,7 +541,7 @@ private void synchronizeData(List folderAndFiles) { Log_OC.d(TAG, "Remote folder path: " + mLocalFolder.getRemotePath() + " changed - starting update of local data "); - List updatedFiles = new ArrayList<>(folderAndFiles.size() - 1); + int totalFiles = folderAndFiles.size() - 1; mFilesToSyncContents.clear(); // if local folder is encrypted, download fresh metadata @@ -560,13 +591,78 @@ private void synchronizeData(List folderAndFiles) { } } - // loop to update every child + // Process files in batches to avoid memory issues with large folders + final int BATCH_SIZE = 500; // Same as FileDataStorageManager.BATCH_SIZE + List allUpdatedFiles = new ArrayList<>(); + + // update file name for encrypted files (before processing batches) + if (e2EVersion == E2EVersion.V1_2) { + updateFileNameForEncryptedFileV1(fileDataStorageManager, + (DecryptedFolderMetadataFileV1) object, + mLocalFolder); + } else { + updateFileNameForEncryptedFile(fileDataStorageManager, + (DecryptedFolderMetadataFile) object, + mLocalFolder); + } + + if (totalFiles > BATCH_SIZE) { + Log_OC.d(TAG, "Large folder detected (" + totalFiles + " files). Processing in batches of " + BATCH_SIZE); + + // Process files in batches + int batchIndex = 0; + for (int batchStart = 1; batchStart < folderAndFiles.size(); batchStart += BATCH_SIZE) { + int batchEnd = Math.min(batchStart + BATCH_SIZE, folderAndFiles.size()); + List batchFiles = processFileBatch(folderAndFiles, batchStart, batchEnd, + localFilesMap, e2EVersion, object); + allUpdatedFiles.addAll(batchFiles); + + // Save batch immediately to free memory (without updating folder metadata) + fileDataStorageManager.saveFolderBatchOnly(remoteFolder, batchFiles, batchIndex * BATCH_SIZE); + batchIndex++; + } + } else { + // Small folder - process normally + List updatedFiles = processFileBatch(folderAndFiles, 1, folderAndFiles.size(), + localFilesMap, e2EVersion, object); + allUpdatedFiles.addAll(updatedFiles); + + // Save all files at once for small folders + fileDataStorageManager.saveFolderBatchOnly(remoteFolder, updatedFiles, 0); + } + + // Process deletions separately + if (!localFilesMap.values().isEmpty()) { + fileDataStorageManager.processFileRemovals(remoteFolder, localFilesMap.values()); + } + + // Update folder metadata (always last, only once) + fileDataStorageManager.updateFolderMetadata(remoteFolder); + + mChildren = allUpdatedFiles; + } + + /** + * Processes a batch of files from the server response. + * + * @param folderAndFiles The full list of folder and files from server + * @param startIndex Starting index in folderAndFiles (1-based, 0 is the folder itself) + * @param endIndex Ending index (exclusive) + * @param localFilesMap Map of local files for matching + * @param e2EVersion E2E encryption version + * @param object Decrypted metadata object (if encrypted) + * @return List of processed OCFile objects + */ + private List processFileBatch(List folderAndFiles, int startIndex, int endIndex, + Map localFilesMap, E2EVersion e2EVersion, Object object) { + List batchFiles = new ArrayList<>(endIndex - startIndex); + OCFile remoteFile; OCFile localFile; OCFile updatedFile; RemoteFile remote; - for (int i = 1; i < folderAndFiles.size(); i++) { + for (int i = startIndex; i < endIndex; i++) { /// new OCFile instance with the data from the server remote = (RemoteFile) folderAndFiles.get(i); remoteFile = FileStorageUtils.fillOCFile(remote); @@ -615,24 +711,10 @@ private void synchronizeData(List folderAndFiles) { boolean encrypted = updatedFile.isEncrypted() || mLocalFolder.isEncrypted(); updatedFile.setEncrypted(encrypted); - updatedFiles.add(updatedFile); - } - - - // save updated contents in local database - // update file name for encrypted files - if (e2EVersion == E2EVersion.V1_2) { - updateFileNameForEncryptedFileV1(fileDataStorageManager, - (DecryptedFolderMetadataFileV1) object, - mLocalFolder); - } else { - updateFileNameForEncryptedFile(fileDataStorageManager, - (DecryptedFolderMetadataFile) object, - mLocalFolder); + batchFiles.add(updatedFile); } - fileDataStorageManager.saveFolder(remoteFolder, updatedFiles, localFilesMap.values()); - - mChildren = updatedFiles; + + return batchFiles; } @Nullable 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 f13c73704962..3475b8614cd3 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -251,7 +251,7 @@ public UploadFileOperation(UploadsStorageManager uploadsStorageManager, this.user = user; mUpload = upload; if (file == null) { - Log_OC.w(TAG, "UploadFileOperation file is null, obtaining from upload"); + Log_OC.i(TAG, "UploadFileOperation file is null, obtaining from upload"); mFile = obtainNewOCFileToUpload( upload.getRemotePath(), upload.getLocalPath(), @@ -1232,7 +1232,31 @@ private RemoteOperationResult checkNameCollision(OCFile parentFile, switch (mNameCollisionPolicy) { case SKIP: Log_OC.d(TAG, "user choose to skip upload if same file exists"); - return new RemoteOperationResult<>(ResultCode.OK); + // For encrypted files, we can't easily compare content, so skip based on name only + // For non-encrypted files, check if it's actually the same file by content + if (!encrypted && mContext != null && user != null && mOriginalStoragePath != null) { + File localFile = new File(mOriginalStoragePath); + if (localFile.exists()) { + boolean isSameFile = FileUploadHelper.Companion.instance().isSameFileOnRemote( + user, localFile, mRemotePath, mContext); + if (isSameFile) { + Log_OC.d(TAG, "File is the same on remote, skipping upload"); + return new RemoteOperationResult<>(ResultCode.OK); + } else { + Log_OC.d(TAG, "File with same name exists but content is different, reporting conflict"); + // File exists but is different, return conflict so system can handle it + return new RemoteOperationResult(ResultCode.SYNC_CONFLICT); + } + } else { + Log_OC.w(TAG, "Local file does not exist, cannot compare content"); + // If local file doesn't exist, skip based on name only (old behavior) + return new RemoteOperationResult<>(ResultCode.OK); + } + } else { + // For encrypted files or when context/user/file path is unavailable, + // skip based on name only (preserve old behavior) + return new RemoteOperationResult<>(ResultCode.OK); + } case RENAME: mRemotePath = getNewAvailableRemotePath(client, mRemotePath, fileNames, encrypted); mWasRenamed = true; diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java index b9988cbdba1f..afa1ff1c2d2b 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java @@ -28,6 +28,8 @@ import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.lib.resources.files.model.RemoteFile; +import com.owncloud.android.lib.resources.shares.ShareeUser; +import com.owncloud.android.lib.resources.tags.Tag; import com.owncloud.android.ui.helpers.FileOperationsHelper; import org.apache.commons.io.FilenameUtils; @@ -284,7 +286,7 @@ public static String getInstantUploadFilePath(File file, if (subfolderByDate) { subfolderByDatePath = getSubPathFromDate(dateTaken, current, subFolderRule); } - Log_OC.w(TAG, "FileStorageUtils:getInstantUploadFilePath subfolderByDate: " + subfolderByDate); + Log_OC.i(TAG, "FileStorageUtils:getInstantUploadFilePath subfolderByDate: " + subfolderByDate); File parentFile = new File(file.getAbsolutePath().replace(syncedFolderLocalPath, "")).getParentFile(); @@ -347,7 +349,13 @@ public static OCFile fillOCFile(RemoteFile remote) { file.setOwnerId(remote.getOwnerId()); file.setOwnerDisplayName(remote.getOwnerDisplayName()); file.setNote(remote.getNote()); - file.setSharees(new ArrayList<>(Arrays.asList(remote.getSharees()))); + // Handle null sharees to avoid NullPointerException + ShareeUser[] sharees = remote.getSharees(); + if (sharees != null) { + file.setSharees(new ArrayList<>(Arrays.asList(sharees))); + } else { + file.setSharees(new ArrayList<>()); + } file.setRichWorkspace(remote.getRichWorkspace()); file.setLocked(remote.isLocked()); file.setLockType(remote.getLockType()); @@ -357,7 +365,13 @@ public static OCFile fillOCFile(RemoteFile remote) { file.setLockTimestamp(remote.getLockTimestamp()); file.setLockTimeout(remote.getLockTimeout()); file.setLockToken(remote.getLockToken()); - file.setTags(new ArrayList<>(Arrays.asList(remote.getTags()))); + // Handle null tags to avoid NullPointerException + Tag[] tags = remote.getTags(); + if (tags != null) { + file.setTags(new ArrayList<>(Arrays.asList(tags))); + } else { + file.setTags(new ArrayList<>()); + } file.setImageDimension(remote.getImageDimension()); file.setGeoLocation(remote.getGeoLocation()); file.setLivePhoto(remote.getLivePhoto());