Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -256,8 +304,8 @@ class GalleryAdapter(
private fun transformToRows(list: List<OCFile>): List<GalleryRow> {
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) }
}
Expand Down Expand Up @@ -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<OCFile>.toGalleryItems(): List<GalleryItems> {
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 }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ public class GalleryFragment extends OCFileListFragment implements GalleryFragme
private boolean photoSearchQueryRunning = false;
private AsyncTask<Void, Void, GallerySearchTask.Result> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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" }
}
}
Loading