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..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 @@ -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,53 @@ class GalleryAdapter( companion object { private const val TAG = "GalleryAdapter" + private const val FIRST_DAY_OF_MONTH = 1 + 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, 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? { + 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 + } + } } // fileId -> (section, row) @@ -256,8 +304,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 +397,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/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; 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..6ad1873f773a --- /dev/null +++ b/app/src/test/java/com/owncloud/android/ui/adapter/GalleryAdapterFolderDateTest.kt @@ -0,0 +1,221 @@ +/* + * 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 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 + 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" } + } + + @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" } + } +}