From 66753a0605694b9ffe99ab2c09ca20d036a7f765 Mon Sep 17 00:00:00 2001 From: Leo Berman Date: Tue, 3 Feb 2026 14:03:11 -0500 Subject: [PATCH 1/6] feat(gallery): Sort by folder date structure (YYYY/MM/DD) then timestamp - Add extractFolderDate to parse YYYY/MM or YYYY/MM/DD from file paths - Sort gallery items by folder date first, then modification timestamp - Group gallery sections by month using folder date when available - Add unit tests for folder date extraction Signed-off-by: Leo Berman --- .../android/ui/adapter/GalleryAdapter.kt | 58 ++++++++- .../ui/asynctasks/GallerySearchTask.java | 46 ++++++++ .../adapter/GalleryAdapterFolderDateTest.kt | 111 ++++++++++++++++++ 3 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterFolderDateTest.kt diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt index edbbe0d4a19d..b3a447aceed5 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -42,6 +42,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import me.zhanghai.android.fastscroll.PopupTextProvider import java.util.Calendar import java.util.Date +import java.util.regex.Pattern @Suppress("LongParameterList", "TooManyFunctions") class GalleryAdapter( @@ -59,6 +60,33 @@ class GalleryAdapter( companion object { private const val TAG = "GalleryAdapter" + // Pattern to extract YYYY/MM or YYYY/MM/DD from file path (requires zero-padded month/day) + private val FOLDER_DATE_PATTERN: Pattern = Pattern.compile("/(\\d{4})/(\\d{2})(?:/(\\d{2}))?/") + + /** + * Extract folder date from path (YYYY/MM or YYYY/MM/DD). + * @return timestamp or null if no folder date found + */ + @VisibleForTesting + fun extractFolderDate(path: String?): Long? { + if (path == null) return null + val matcher = FOLDER_DATE_PATTERN.matcher(path) + if (matcher.find()) { + val year = matcher.group(1)?.toIntOrNull() ?: return null + val month = matcher.group(2)?.toIntOrNull() ?: return null + val day = matcher.group(3)?.toIntOrNull() ?: 1 + return Calendar.getInstance().apply { + set(Calendar.YEAR, year) + set(Calendar.MONTH, month - 1) + set(Calendar.DAY_OF_MONTH, day) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + return null + } } // fileId -> (section, row) @@ -256,8 +284,8 @@ class GalleryAdapter( private fun transformToRows(list: List): List { if (list.isEmpty()) return emptyList() + // List is already sorted by toGalleryItems(), just chunk into rows return list - .sortedByDescending { it.modificationTimestamp } .chunked(columns) .map { chunk -> GalleryRow(chunk, defaultThumbnailSize, defaultThumbnailSize) } } @@ -349,12 +377,36 @@ class GalleryAdapter( } } + /** + * Get the grouping date for a file: use folder date from path if present, + * otherwise fall back to modification timestamp month. + */ + private fun getGroupingDate(file: OCFile): Long { + return firstOfMonth(extractFolderDate(file.remotePath) ?: file.modificationTimestamp) + } + private fun List.toGalleryItems(): List { if (isEmpty()) return emptyList() - return groupBy { firstOfMonth(it.modificationTimestamp) } + return groupBy { getGroupingDate(it) } .map { (date, filesList) -> - GalleryItems(date, transformToRows(filesList)) + // Sort files within group: by folder day desc, then by modification timestamp desc + val sortedFiles = filesList.sortedWith { a, b -> + val aFolderDate = extractFolderDate(a.remotePath) + val bFolderDate = extractFolderDate(b.remotePath) + when { + aFolderDate != null && bFolderDate != null -> { + // Both have folder dates - compare by folder day first (desc) + val dayCompare = bFolderDate.compareTo(aFolderDate) + if (dayCompare != 0) dayCompare + else b.modificationTimestamp.compareTo(a.modificationTimestamp) + } + aFolderDate != null -> -1 // a has folder date, comes first + bFolderDate != null -> 1 // b has folder date, comes first + else -> b.modificationTimestamp.compareTo(a.modificationTimestamp) + } + } + GalleryItems(date, transformToRows(sortedFiles)) } .sortedByDescending { it.date } } diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java b/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java index 183d828c16f6..9ab8a0968ebf 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java @@ -25,10 +25,13 @@ import java.lang.ref.WeakReference; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class GallerySearchTask extends AsyncTask { @@ -137,6 +140,32 @@ private boolean parseMedia(long startDate, long endDate, List remoteFile } } + // Sort by folder-date (YYYY/MM or YYYY/MM/DD in path) first, then by timestamp. + // Files with folder-dates come first (newest folder → newest files). + Collections.sort(localFiles, (a, b) -> { + String pa = a.getRemotePath() == null ? "" : a.getRemotePath(); + String pb = b.getRemotePath() == null ? "" : b.getRemotePath(); + + int[] da = extractYmdFromPath(pa); + int[] db = extractYmdFromPath(pb); + + if (da != null && db != null) { + // compare folder date descending (newest folder first) + if (da[0] != db[0]) return Integer.compare(db[0], da[0]); // year + if (da[1] != db[1]) return Integer.compare(db[1], da[1]); // month + if (da[2] != db[2]) return Integer.compare(db[2], da[2]); // day (0 if absent) + // same folder -> newest file first + return Long.compare(b.getModificationTimestamp(), a.getModificationTimestamp()); + } else if (da != null) { + return -1; // a has folder-date => comes before b + } else if (db != null) { + return 1; // b has folder-date => comes before a + } else { + // neither has folder-date => newest first by timestamp + return Long.compare(b.getModificationTimestamp(), a.getModificationTimestamp()); + } + }); + Map localFilesMap = RefreshFolderOperation.prefillLocalFilesMap(null, localFiles); long filesAdded = 0, filesUpdated = 0, unchangedFiles = 0; @@ -210,6 +239,23 @@ private boolean parseMedia(long startDate, long endDate, List remoteFile return filesAdded <= 0 && filesUpdated <= 0 && filesDeleted <= 0; } + /** + * Extract YYYY/MM or YYYY/MM/DD from a file path. + * @return int[]{year, month, day} where day=0 if only YYYY/MM present, or null if no match. + */ + private static final Pattern FOLDER_DATE_PATTERN = Pattern.compile("/(\\d{4})/(\\d{1,2})(?:/(\\d{1,2}))?/"); + + private static int[] extractYmdFromPath(String path) { + Matcher m = FOLDER_DATE_PATTERN.matcher(path); + if (m.find()) { + int y = Integer.parseInt(m.group(1)); + int mo = Integer.parseInt(m.group(2)); + int d = m.group(3) != null ? Integer.parseInt(m.group(3)) : 0; + return new int[]{y, mo, d}; + } + return null; + } + public static class Result { public boolean success; public boolean emptySearch; diff --git a/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterFolderDateTest.kt b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterFolderDateTest.kt new file mode 100644 index 000000000000..d858dd8cb886 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterFolderDateTest.kt @@ -0,0 +1,111 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.Calendar + +class GalleryAdapterFolderDateTest { + + @Test + fun `extractFolderDate returns null for null path`() { + assertNull(GalleryAdapter.extractFolderDate(null)) + } + + @Test + fun `extractFolderDate returns null for path without date pattern`() { + assertNull(GalleryAdapter.extractFolderDate("/Photos/vacation/image.jpg")) + assertNull(GalleryAdapter.extractFolderDate("/Documents/file.pdf")) + assertNull(GalleryAdapter.extractFolderDate("")) + } + + @Test + fun `extractFolderDate extracts YYYY MM pattern`() { + val result = GalleryAdapter.extractFolderDate("/Photos/2025/01/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(0, cal.get(Calendar.MONTH)) // January is 0 + assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1 + } + + @Test + fun `extractFolderDate extracts YYYY MM DD pattern`() { + val result = GalleryAdapter.extractFolderDate("/Photos/2025/01/15/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(0, cal.get(Calendar.MONTH)) // January is 0 + assertEquals(15, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun `extractFolderDate rejects single digit month`() { + assertNull(GalleryAdapter.extractFolderDate("/Photos/2025/3/image.jpg")) + } + + @Test + fun `extractFolderDate ignores single digit day and defaults to 1`() { + // /2025/03/5/ matches YYYY/MM only, day defaults to 1 + val result = GalleryAdapter.extractFolderDate("/Photos/2025/03/5/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(2, cal.get(Calendar.MONTH)) // March is 2 + assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1 + } + + @Test + fun `extractFolderDate works with nested paths`() { + val result = GalleryAdapter.extractFolderDate("/InstantUpload/Camera/2024/12/25/IMG_001.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2024, cal.get(Calendar.YEAR)) + assertEquals(11, cal.get(Calendar.MONTH)) // December is 11 + assertEquals(25, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun `extractFolderDate finds first match in path with multiple date patterns`() { + val result = GalleryAdapter.extractFolderDate("/2023/06/backup/2024/12/25/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2023, cal.get(Calendar.YEAR)) + assertEquals(5, cal.get(Calendar.MONTH)) // June is 5 + } + + @Test + fun `extractFolderDate returns midnight timestamp`() { + val result = GalleryAdapter.extractFolderDate("/Photos/2025/01/15/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(0, cal.get(Calendar.HOUR_OF_DAY)) + assertEquals(0, cal.get(Calendar.MINUTE)) + assertEquals(0, cal.get(Calendar.SECOND)) + assertEquals(0, cal.get(Calendar.MILLISECOND)) + } + + @Test + fun `folder date ordering - newer dates should be greater`() { + val jan15 = GalleryAdapter.extractFolderDate("/Photos/2025/01/15/a.jpg")!! + val jan20 = GalleryAdapter.extractFolderDate("/Photos/2025/01/20/b.jpg")!! + val feb01 = GalleryAdapter.extractFolderDate("/Photos/2025/02/01/c.jpg")!! + + assert(jan20 > jan15) { "Jan 20 should be after Jan 15" } + assert(feb01 > jan20) { "Feb 1 should be after Jan 20" } + assert(feb01 > jan15) { "Feb 1 should be after Jan 15" } + } +} From 4df09a5d3f6ba5980587447d8f1cafdb632a52df Mon Sep 17 00:00:00 2001 From: Leo Berman Date: Tue, 3 Feb 2026 14:20:17 -0500 Subject: [PATCH 2/6] Resolving codacy stuff Signed-off-by: Leo Berman --- .../android/ui/adapter/GalleryAdapter.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt index b3a447aceed5..227f55b34fbc 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -60,6 +60,7 @@ class GalleryAdapter( companion object { private const val TAG = "GalleryAdapter" + private const val FIRST_DAY_OF_MONTH = 1 // Pattern to extract YYYY/MM or YYYY/MM/DD from file path (requires zero-padded month/day) private val FOLDER_DATE_PATTERN: Pattern = Pattern.compile("/(\\d{4})/(\\d{2})(?:/(\\d{2}))?/") @@ -69,13 +70,15 @@ class GalleryAdapter( */ @VisibleForTesting fun extractFolderDate(path: String?): Long? { - if (path == null) return null - val matcher = FOLDER_DATE_PATTERN.matcher(path) - if (matcher.find()) { - val year = matcher.group(1)?.toIntOrNull() ?: return null - val month = matcher.group(2)?.toIntOrNull() ?: return null - val day = matcher.group(3)?.toIntOrNull() ?: 1 - return Calendar.getInstance().apply { + val matcher = path?.let { FOLDER_DATE_PATTERN.matcher(it) } ?: return null + if (!matcher.find()) return null + + val year = matcher.group(1)?.toIntOrNull() + val month = matcher.group(2)?.toIntOrNull() + val day = matcher.group(3)?.toIntOrNull() ?: FIRST_DAY_OF_MONTH + + return if (year != null && month != null) { + Calendar.getInstance().apply { set(Calendar.YEAR, year) set(Calendar.MONTH, month - 1) set(Calendar.DAY_OF_MONTH, day) @@ -84,8 +87,9 @@ class GalleryAdapter( set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) }.timeInMillis + } else { + null } - return null } } From 41a30daa9e4c9bc7b2e6b30cbbd5677a21fcd051 Mon Sep 17 00:00:00 2001 From: Leo Berman Date: Tue, 3 Feb 2026 15:23:39 -0500 Subject: [PATCH 3/6] Some more tests and date validation Signed-off-by: Leo Berman --- .../android/ui/adapter/GalleryAdapter.kt | 60 +++++---- .../adapter/GalleryAdapterFolderDateTest.kt | 114 +++++++++++++++++- 2 files changed, 150 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt index 227f55b34fbc..a2d4ec437b74 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt @@ -61,33 +61,49 @@ class GalleryAdapter( companion object { private const val TAG = "GalleryAdapter" private const val FIRST_DAY_OF_MONTH = 1 - // Pattern to extract YYYY/MM or YYYY/MM/DD from file path (requires zero-padded month/day) - private val FOLDER_DATE_PATTERN: Pattern = Pattern.compile("/(\\d{4})/(\\d{2})(?:/(\\d{2}))?/") + private const val FIRST_MONTH = 1 + private const val YEAR_GROUP = 1 + private const val MONTH_GROUP = 2 + private const val DAY_GROUP = 3 + + // Pattern to extract YYYY, YYYY/MM, or YYYY/MM/DD from file path (requires zero-padded month/day) + private val FOLDER_DATE_PATTERN: Pattern = Pattern.compile("/(\\d{4})(?:/(\\d{2}))?(?:/(\\d{2}))?/") /** - * Extract folder date from path (YYYY/MM or YYYY/MM/DD). - * @return timestamp or null if no folder date found + * Extract folder date from path (YYYY, YYYY/MM, or YYYY/MM/DD). + * Uses LocalDate for calendar-aware validation (leap years, days per month). + * Invalid month/day values fall back to defaults. Future dates are rejected. + * @return timestamp or null if no folder date found or date is in the future */ @VisibleForTesting + @Suppress("TooGenericExceptionCaught") fun extractFolderDate(path: String?): Long? { - val matcher = path?.let { FOLDER_DATE_PATTERN.matcher(it) } ?: return null - if (!matcher.find()) return null - - val year = matcher.group(1)?.toIntOrNull() - val month = matcher.group(2)?.toIntOrNull() - val day = matcher.group(3)?.toIntOrNull() ?: FIRST_DAY_OF_MONTH - - return if (year != null && month != null) { - Calendar.getInstance().apply { - set(Calendar.YEAR, year) - set(Calendar.MONTH, month - 1) - set(Calendar.DAY_OF_MONTH, day) - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - }.timeInMillis - } else { + return try { + val matcher = path?.let { FOLDER_DATE_PATTERN.matcher(it) } + if (matcher?.find() != true) return null + val year = matcher.group(YEAR_GROUP)?.toIntOrNull() ?: return null + val rawMonth = matcher.group(MONTH_GROUP)?.toIntOrNull() + val rawDay = matcher.group(DAY_GROUP)?.toIntOrNull() + + val month = rawMonth ?: FIRST_MONTH + val day = rawDay ?: FIRST_DAY_OF_MONTH + + val localDate = tryCreateDate(year, month, day) + ?: tryCreateDate(year, month, FIRST_DAY_OF_MONTH) + ?: tryCreateDate(year, FIRST_MONTH, FIRST_DAY_OF_MONTH) + + if (localDate?.isAfter(java.time.LocalDate.now()) == true) return null + + localDate?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + } catch (e: Exception) { + null + } + } + + private fun tryCreateDate(year: Int, month: Int, day: Int): java.time.LocalDate? { + return try { + java.time.LocalDate.of(year, month, day) + } catch (e: java.time.DateTimeException) { null } } diff --git a/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterFolderDateTest.kt b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterFolderDateTest.kt index d858dd8cb886..6ad1873f773a 100644 --- a/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterFolderDateTest.kt +++ b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterFolderDateTest.kt @@ -49,8 +49,15 @@ class GalleryAdapterFolderDateTest { } @Test - fun `extractFolderDate rejects single digit month`() { - assertNull(GalleryAdapter.extractFolderDate("/Photos/2025/3/image.jpg")) + fun `extractFolderDate handles single digit month as year only`() { + // Single digit month doesn't match pattern, so only year is captured + val result = GalleryAdapter.extractFolderDate("/Photos/2025/3/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(0, cal.get(Calendar.MONTH)) // defaults to January (0) + assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1 } @Test @@ -108,4 +115,107 @@ class GalleryAdapterFolderDateTest { assert(feb01 > jan20) { "Feb 1 should be after Jan 20" } assert(feb01 > jan15) { "Feb 1 should be after Jan 15" } } + + @Test + fun `extractFolderDate handles year only path`() { + val result = GalleryAdapter.extractFolderDate("/Photos/2025/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(0, cal.get(Calendar.MONTH)) // defaults to January (0) + assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1 + } + + @Test + fun `extractFolderDate handles invalid month 00 as year only`() { + // Month 00 is invalid, so it defaults to January + val result = GalleryAdapter.extractFolderDate("/Photos/2025/00/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(0, cal.get(Calendar.MONTH)) // defaults to January (0) + assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1 + } + + @Test + fun `extractFolderDate handles month 12`() { + val result = GalleryAdapter.extractFolderDate("/Photos/2025/12/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(11, cal.get(Calendar.MONTH)) // December is 11 + } + + @Test + fun `extractFolderDate handles day 31`() { + val result = GalleryAdapter.extractFolderDate("/Photos/2025/01/31/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(31, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun `extractFolderDate handles invalid day Feb 30 as Feb 1`() { + // Feb 30 is invalid, so day defaults to 1 + val result = GalleryAdapter.extractFolderDate("/Photos/2025/02/30/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(1, cal.get(Calendar.MONTH)) // February is 1 + assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1 + } + + @Test + fun `extractFolderDate handles invalid day 00 as day 1`() { + // Day 00 is invalid, so day defaults to 1 + val result = GalleryAdapter.extractFolderDate("/Photos/2025/03/00/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(2, cal.get(Calendar.MONTH)) // March is 2 + assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1 + } + + @Test + fun `extractFolderDate requires trailing slash after date components`() { + // No trailing slash after month, so only year is captured + val result = GalleryAdapter.extractFolderDate("/Photos/2025/03image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(0, cal.get(Calendar.MONTH)) // defaults to January (0) + assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1 + } + + @Test + fun `extractFolderDate returns null when no trailing slash after year`() { + // Pattern requires trailing slash after year at minimum + assertNull(GalleryAdapter.extractFolderDate("/Photos/2025image.jpg")) + } + + @Test + fun `extractFolderDate works at start of path`() { + val result = GalleryAdapter.extractFolderDate("/2025/06/15/image.jpg") + assertNotNull(result) + + val cal = Calendar.getInstance().apply { timeInMillis = result!! } + assertEquals(2025, cal.get(Calendar.YEAR)) + assertEquals(5, cal.get(Calendar.MONTH)) // June is 5 + assertEquals(15, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun `extractFolderDate handles different years`() { + val y2020 = GalleryAdapter.extractFolderDate("/Photos/2020/06/image.jpg")!! + val y2025 = GalleryAdapter.extractFolderDate("/Photos/2025/06/image.jpg")!! + + assert(y2025 > y2020) { "2025 should be after 2020" } + } } From f26630872d23ed8d7e60660f7187c9a3582f3c67 Mon Sep 17 00:00:00 2001 From: Leo Berman Date: Tue, 3 Feb 2026 15:49:54 -0500 Subject: [PATCH 4/6] Removing unecessary sorting Signed-off-by: Leo Berman --- .../ui/asynctasks/GallerySearchTask.java | 48 +------------------ 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java b/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java index 9ab8a0968ebf..ebeba759d94f 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java @@ -25,13 +25,10 @@ import java.lang.ref.WeakReference; import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; public class GallerySearchTask extends AsyncTask { @@ -140,32 +137,8 @@ private boolean parseMedia(long startDate, long endDate, List remoteFile } } - // Sort by folder-date (YYYY/MM or YYYY/MM/DD in path) first, then by timestamp. - // Files with folder-dates come first (newest folder → newest files). - Collections.sort(localFiles, (a, b) -> { - String pa = a.getRemotePath() == null ? "" : a.getRemotePath(); - String pb = b.getRemotePath() == null ? "" : b.getRemotePath(); - - int[] da = extractYmdFromPath(pa); - int[] db = extractYmdFromPath(pb); - - if (da != null && db != null) { - // compare folder date descending (newest folder first) - if (da[0] != db[0]) return Integer.compare(db[0], da[0]); // year - if (da[1] != db[1]) return Integer.compare(db[1], da[1]); // month - if (da[2] != db[2]) return Integer.compare(db[2], da[2]); // day (0 if absent) - // same folder -> newest file first - return Long.compare(b.getModificationTimestamp(), a.getModificationTimestamp()); - } else if (da != null) { - return -1; // a has folder-date => comes before b - } else if (db != null) { - return 1; // b has folder-date => comes before a - } else { - // neither has folder-date => newest first by timestamp - return Long.compare(b.getModificationTimestamp(), a.getModificationTimestamp()); - } - }); - + // Sorting for display is handled by GalleryAdapter. + // Just build a map for sync comparison. Map localFilesMap = RefreshFolderOperation.prefillLocalFilesMap(null, localFiles); long filesAdded = 0, filesUpdated = 0, unchangedFiles = 0; @@ -239,23 +212,6 @@ private boolean parseMedia(long startDate, long endDate, List remoteFile return filesAdded <= 0 && filesUpdated <= 0 && filesDeleted <= 0; } - /** - * Extract YYYY/MM or YYYY/MM/DD from a file path. - * @return int[]{year, month, day} where day=0 if only YYYY/MM present, or null if no match. - */ - private static final Pattern FOLDER_DATE_PATTERN = Pattern.compile("/(\\d{4})/(\\d{1,2})(?:/(\\d{1,2}))?/"); - - private static int[] extractYmdFromPath(String path) { - Matcher m = FOLDER_DATE_PATTERN.matcher(path); - if (m.find()) { - int y = Integer.parseInt(m.group(1)); - int mo = Integer.parseInt(m.group(2)); - int d = m.group(3) != null ? Integer.parseInt(m.group(3)) : 0; - return new int[]{y, mo, d}; - } - return null; - } - public static class Result { public boolean success; public boolean emptySearch; From aa0afd8c87afa140584744105196c1ccd9293256 Mon Sep 17 00:00:00 2001 From: Leo Berman Date: Tue, 3 Feb 2026 15:50:29 -0500 Subject: [PATCH 5/6] Cleanup Signed-off-by: Leo Berman --- .../com/owncloud/android/ui/asynctasks/GallerySearchTask.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java b/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java index ebeba759d94f..183d828c16f6 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java @@ -137,8 +137,6 @@ private boolean parseMedia(long startDate, long endDate, List remoteFile } } - // Sorting for display is handled by GalleryAdapter. - // Just build a map for sync comparison. Map localFilesMap = RefreshFolderOperation.prefillLocalFilesMap(null, localFiles); long filesAdded = 0, filesUpdated = 0, unchangedFiles = 0; From 9bcb0838305728e278d1e116ceca80e2e3d02d6e Mon Sep 17 00:00:00 2001 From: Leo Berman Date: Tue, 3 Feb 2026 16:22:47 -0500 Subject: [PATCH 6/6] Set it up so the categories load correctly. Signed-off-by: Leo Berman --- .../java/com/owncloud/android/ui/fragment/GalleryFragment.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java index cf1a649c2dc0..3c8fcc690813 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java @@ -59,7 +59,8 @@ public class GalleryFragment extends OCFileListFragment implements GalleryFragme private boolean photoSearchQueryRunning = false; private AsyncTask photoSearchTask; private long endDate; - private int limit = 150; + // Use 0 for unlimited - fetch all metadata at once; thumbnails load lazily + private int limit = 0; private GalleryAdapter mAdapter; private static final int SELECT_LOCATION_REQUEST_CODE = 212;