From e157a5691f8a0e3ff44fa6a08d1b93e33f14756a Mon Sep 17 00:00:00 2001 From: dmiales Date: Fri, 2 Jan 2026 06:34:36 +0300 Subject: [PATCH] feat: enhance file upload collision handling and add streaming folder operations - Add PropFindSaxHandler for parsing PROPFIND responses - Add StreamingReadFolderRemoteOperation for efficient folder reading - Improve file collision detection in UploadFileOperation: * For non-encrypted files, compare content instead of just names * Skip upload only when files are actually identical * Return conflict when same-named files have different content * Preserve original behavior for encrypted files --- .../resources/files/PropFindSaxHandler.java | 581 ++++++++++++++++++ .../StreamingReadFolderRemoteOperation.java | 183 ++++++ .../operations/UploadFileOperation.java | 28 +- 3 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/lib/resources/files/PropFindSaxHandler.java create mode 100644 app/src/main/java/com/owncloud/android/lib/resources/files/StreamingReadFolderRemoteOperation.java 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..0d4942dd9d3a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/lib/resources/files/PropFindSaxHandler.java @@ -0,0 +1,581 @@ +/* + * 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 + Log_OC.d(TAG, "Created new RemoteFile for response: " + currentFile.getRemotePath()); + 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; + Log_OC.d(TAG, "Starting new propstat element"); + // 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; + Log_OC.d(TAG, "Set propstatStatusOk = true for new propstat"); + } 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; + Log_OC.d(TAG, "Found collection element - this is a folder"); + } 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: log all elements being processed (only in prop for less spam) + if (DEBUG_XML && inProp && inPropstat) { + Log_OC.d(TAG, "Processing element in prop: " + uri + ":" + localName + " = '" + text + "' (inProp=" + inProp + ", inPropstat=" + inPropstat + ", propstatStatusOk=" + propstatStatusOk + ")"); + } + // Special debug for OC/NC elements + if (DEBUG_XML && (NS_OC.equals(uri) || NS_NC.equals(uri)) && inProp && inPropstat) { + Log_OC.d(TAG, "OC/NC ELEMENT in prop: " + uri + ":" + localName + " = '" + text + "' (inProp=" + inProp + ", inPropstat=" + inPropstat + ", propstatStatusOk=" + propstatStatusOk + ")"); + } + + if (NS_DAV.equals(uri) && ELEMENT_RESPONSE.equals(localName)) { + if (currentFile != null && currentHref != null) { + String remotePath = extractPathFromHref(currentHref); + Log_OC.d(TAG, "Setting remotePath for file: " + remotePath + " (was: " + currentFile.getRemotePath() + ")"); + 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); + Log_OC.d(TAG, "Final check: Corrected MimeType to DIRECTORY for folder (path: " + currentFile.getRemotePath() + ")"); + } else { + Log_OC.d(TAG, "Final check: MimeType already correctly set to DIRECTORY for folder (path: " + currentFile.getRemotePath() + ")"); + } + } 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); + Log_OC.d(TAG, "Final check: Set default MimeType FILE for file (path: " + currentFile.getRemotePath() + ")"); + } else { + Log_OC.d(TAG, "Final check: MimeType already set to " + finalMimeType + " for file (path: " + currentFile.getRemotePath() + ")"); + } + } + } 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; + Log_OC.d(TAG, "Set currentHref: " + currentHref); + } 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"); + Log_OC.d(TAG, "Processing status element: '" + text + "' -> statusIsOk=" + statusIsOk); + if (statusIsOk) { + propstatStatusOk = true; + Log_OC.d(TAG, "propstat status OK: " + text); + } else { + propstatStatusOk = false; + Log_OC.w(TAG, "propstat status NOT OK (will ignore properties): " + text); + // 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)) { + Log_OC.d(TAG, "Ending propstat element, resetting propstatStatusOk to false"); + 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); + Log_OC.d(TAG, "Set MimeType to DIRECTORY for folder: " + currentFile.getRemotePath()); + } else { + // This is a file - don't set MimeType here, wait for getcontenttype + Log_OC.d(TAG, "Resource type determined as FILE (not collection) for: " + currentFile.getRemotePath()); + } + } + // 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 + } + Log_OC.d(TAG, "Set MimeType for file: " + text + " path: " + currentFile.getRemotePath()); + } 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) { + // Ignore invalid size + } + } 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)) { + currentFile.setHasPreview("true".equalsIgnoreCase(text)); + } 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 + } + } + + 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..e66ac4e50ac6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/lib/resources/files/StreamingReadFolderRemoteOperation.java @@ -0,0 +1,183 @@ +/* + * 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 org.apache.jackrabbit.webdav.property.DavPropertyName; + +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/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;