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,52 @@ 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? {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this folder date extraction logic to the DateFormatter object and add logs for exceptions cases.

try {
val matcher = path?.let { FOLDER_DATE_PATTERN.matcher(it) }
val year = matcher?.takeIf { it.find() }?.group(YEAR_GROUP)?.toIntOrNull()

return year?.let { y ->
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(y, month, day)
?: tryCreateDate(y, month, FIRST_DAY_OF_MONTH)
?: tryCreateDate(y, FIRST_MONTH, FIRST_DAY_OF_MONTH)

localDate?.takeIf { !it.isAfter(java.time.LocalDate.now()) }
?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
}
} catch (e: Exception) {
return null
}
}

private fun tryCreateDate(year: Int, month: Int, day: Int): java.time.LocalDate? = try {
java.time.LocalDate.of(year, month, day)
} catch (e: java.time.DateTimeException) {
null
}
}

// fileId -> (section, row)
Expand Down Expand Up @@ -256,8 +303,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 @@ -370,12 +417,43 @@ 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 =
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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may introduce unforeseen side effects on slow and very large instances, as forcing SearchRemoteOperation to fetch everything could cause performance issues.

@tobiasKaminsky Please share your thoughts as well.

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,194 @@
/*
* 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 invalid paths`() {
assertNull(GalleryAdapter.extractFolderDate(null))
assertNull(GalleryAdapter.extractFolderDate("/Photos/vacation/image.jpg"))
assertNull(GalleryAdapter.extractFolderDate("/Documents/file.pdf"))
assertNull(GalleryAdapter.extractFolderDate(""))
assertNull(GalleryAdapter.extractFolderDate("/Photos/2025image.jpg"))
}

@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 components as partial match`() {
// Single digit month doesn't match pattern, so only year is captured
val monthResult = GalleryAdapter.extractFolderDate("/Photos/2025/3/image.jpg")
assertNotNull(monthResult)
var cal = Calendar.getInstance().apply { timeInMillis = monthResult!! }
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

// /2025/03/5/ matches YYYY/MM only, day defaults to 1
val dayResult = GalleryAdapter.extractFolderDate("/Photos/2025/03/5/image.jpg")
assertNotNull(dayResult)
cal = Calendar.getInstance().apply { timeInMillis = dayResult!! }
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")!!
val y2020 = GalleryAdapter.extractFolderDate("/Photos/2020/06/image.jpg")!!
val y2025 = GalleryAdapter.extractFolderDate("/Photos/2025/06/image.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" }
assert(y2025 > y2020) { "2025 should be after 2020" }
}

@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 values`() {
// 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 valid month boundaries`() {
// Month 12 (December)
val decResult = GalleryAdapter.extractFolderDate("/Photos/2025/12/image.jpg")
assertNotNull(decResult)
var cal = Calendar.getInstance().apply { timeInMillis = decResult!! }
assertEquals(2025, cal.get(Calendar.YEAR))
assertEquals(11, cal.get(Calendar.MONTH)) // December is 11

// Day 31
val day31Result = GalleryAdapter.extractFolderDate("/Photos/2025/01/31/image.jpg")
assertNotNull(day31Result)
cal = Calendar.getInstance().apply { timeInMillis = day31Result!! }
assertEquals(31, cal.get(Calendar.DAY_OF_MONTH))
}

@Test
fun `extractFolderDate handles invalid day values`() {
// Feb 30 is invalid, so day defaults to 1
val feb30Result = GalleryAdapter.extractFolderDate("/Photos/2025/02/30/image.jpg")
assertNotNull(feb30Result)
var cal = Calendar.getInstance().apply { timeInMillis = feb30Result!! }
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

// Day 00 is invalid, so day defaults to 1
val day00Result = GalleryAdapter.extractFolderDate("/Photos/2025/03/00/image.jpg")
assertNotNull(day00Result)
cal = Calendar.getInstance().apply { timeInMillis = day00Result!! }
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 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))
}
}