Skip to content

Commit 66753a0

Browse files
committed
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 <leograntberman@gmail.com>
1 parent c73d9c4 commit 66753a0

File tree

3 files changed

+212
-3
lines changed

3 files changed

+212
-3
lines changed

app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils
4242
import me.zhanghai.android.fastscroll.PopupTextProvider
4343
import java.util.Calendar
4444
import java.util.Date
45+
import java.util.regex.Pattern
4546

4647
@Suppress("LongParameterList", "TooManyFunctions")
4748
class GalleryAdapter(
@@ -59,6 +60,33 @@ class GalleryAdapter(
5960

6061
companion object {
6162
private const val TAG = "GalleryAdapter"
63+
// Pattern to extract YYYY/MM or YYYY/MM/DD from file path (requires zero-padded month/day)
64+
private val FOLDER_DATE_PATTERN: Pattern = Pattern.compile("/(\\d{4})/(\\d{2})(?:/(\\d{2}))?/")
65+
66+
/**
67+
* Extract folder date from path (YYYY/MM or YYYY/MM/DD).
68+
* @return timestamp or null if no folder date found
69+
*/
70+
@VisibleForTesting
71+
fun extractFolderDate(path: String?): Long? {
72+
if (path == null) return null
73+
val matcher = FOLDER_DATE_PATTERN.matcher(path)
74+
if (matcher.find()) {
75+
val year = matcher.group(1)?.toIntOrNull() ?: return null
76+
val month = matcher.group(2)?.toIntOrNull() ?: return null
77+
val day = matcher.group(3)?.toIntOrNull() ?: 1
78+
return Calendar.getInstance().apply {
79+
set(Calendar.YEAR, year)
80+
set(Calendar.MONTH, month - 1)
81+
set(Calendar.DAY_OF_MONTH, day)
82+
set(Calendar.HOUR_OF_DAY, 0)
83+
set(Calendar.MINUTE, 0)
84+
set(Calendar.SECOND, 0)
85+
set(Calendar.MILLISECOND, 0)
86+
}.timeInMillis
87+
}
88+
return null
89+
}
6290
}
6391

6492
// fileId -> (section, row)
@@ -256,8 +284,8 @@ class GalleryAdapter(
256284
private fun transformToRows(list: List<OCFile>): List<GalleryRow> {
257285
if (list.isEmpty()) return emptyList()
258286

287+
// List is already sorted by toGalleryItems(), just chunk into rows
259288
return list
260-
.sortedByDescending { it.modificationTimestamp }
261289
.chunked(columns)
262290
.map { chunk -> GalleryRow(chunk, defaultThumbnailSize, defaultThumbnailSize) }
263291
}
@@ -349,12 +377,36 @@ class GalleryAdapter(
349377
}
350378
}
351379

380+
/**
381+
* Get the grouping date for a file: use folder date from path if present,
382+
* otherwise fall back to modification timestamp month.
383+
*/
384+
private fun getGroupingDate(file: OCFile): Long {
385+
return firstOfMonth(extractFolderDate(file.remotePath) ?: file.modificationTimestamp)
386+
}
387+
352388
private fun List<OCFile>.toGalleryItems(): List<GalleryItems> {
353389
if (isEmpty()) return emptyList()
354390

355-
return groupBy { firstOfMonth(it.modificationTimestamp) }
391+
return groupBy { getGroupingDate(it) }
356392
.map { (date, filesList) ->
357-
GalleryItems(date, transformToRows(filesList))
393+
// Sort files within group: by folder day desc, then by modification timestamp desc
394+
val sortedFiles = filesList.sortedWith { a, b ->
395+
val aFolderDate = extractFolderDate(a.remotePath)
396+
val bFolderDate = extractFolderDate(b.remotePath)
397+
when {
398+
aFolderDate != null && bFolderDate != null -> {
399+
// Both have folder dates - compare by folder day first (desc)
400+
val dayCompare = bFolderDate.compareTo(aFolderDate)
401+
if (dayCompare != 0) dayCompare
402+
else b.modificationTimestamp.compareTo(a.modificationTimestamp)
403+
}
404+
aFolderDate != null -> -1 // a has folder date, comes first
405+
bFolderDate != null -> 1 // b has folder date, comes first
406+
else -> b.modificationTimestamp.compareTo(a.modificationTimestamp)
407+
}
408+
}
409+
GalleryItems(date, transformToRows(sortedFiles))
358410
}
359411
.sortedByDescending { it.date }
360412
}

app/src/main/java/com/owncloud/android/ui/asynctasks/GallerySearchTask.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@
2525
import java.lang.ref.WeakReference;
2626
import java.text.SimpleDateFormat;
2727
import java.util.ArrayList;
28+
import java.util.Collections;
2829
import java.util.Date;
2930
import java.util.List;
3031
import java.util.Locale;
3132
import java.util.Map;
33+
import java.util.regex.Matcher;
34+
import java.util.regex.Pattern;
3235

3336
public class GallerySearchTask extends AsyncTask<Void, Void, GallerySearchTask.Result> {
3437

@@ -137,6 +140,32 @@ private boolean parseMedia(long startDate, long endDate, List<Object> remoteFile
137140
}
138141
}
139142

143+
// Sort by folder-date (YYYY/MM or YYYY/MM/DD in path) first, then by timestamp.
144+
// Files with folder-dates come first (newest folder → newest files).
145+
Collections.sort(localFiles, (a, b) -> {
146+
String pa = a.getRemotePath() == null ? "" : a.getRemotePath();
147+
String pb = b.getRemotePath() == null ? "" : b.getRemotePath();
148+
149+
int[] da = extractYmdFromPath(pa);
150+
int[] db = extractYmdFromPath(pb);
151+
152+
if (da != null && db != null) {
153+
// compare folder date descending (newest folder first)
154+
if (da[0] != db[0]) return Integer.compare(db[0], da[0]); // year
155+
if (da[1] != db[1]) return Integer.compare(db[1], da[1]); // month
156+
if (da[2] != db[2]) return Integer.compare(db[2], da[2]); // day (0 if absent)
157+
// same folder -> newest file first
158+
return Long.compare(b.getModificationTimestamp(), a.getModificationTimestamp());
159+
} else if (da != null) {
160+
return -1; // a has folder-date => comes before b
161+
} else if (db != null) {
162+
return 1; // b has folder-date => comes before a
163+
} else {
164+
// neither has folder-date => newest first by timestamp
165+
return Long.compare(b.getModificationTimestamp(), a.getModificationTimestamp());
166+
}
167+
});
168+
140169
Map<String, OCFile> localFilesMap = RefreshFolderOperation.prefillLocalFilesMap(null, localFiles);
141170

142171
long filesAdded = 0, filesUpdated = 0, unchangedFiles = 0;
@@ -210,6 +239,23 @@ private boolean parseMedia(long startDate, long endDate, List<Object> remoteFile
210239
return filesAdded <= 0 && filesUpdated <= 0 && filesDeleted <= 0;
211240
}
212241

242+
/**
243+
* Extract YYYY/MM or YYYY/MM/DD from a file path.
244+
* @return int[]{year, month, day} where day=0 if only YYYY/MM present, or null if no match.
245+
*/
246+
private static final Pattern FOLDER_DATE_PATTERN = Pattern.compile("/(\\d{4})/(\\d{1,2})(?:/(\\d{1,2}))?/");
247+
248+
private static int[] extractYmdFromPath(String path) {
249+
Matcher m = FOLDER_DATE_PATTERN.matcher(path);
250+
if (m.find()) {
251+
int y = Integer.parseInt(m.group(1));
252+
int mo = Integer.parseInt(m.group(2));
253+
int d = m.group(3) != null ? Integer.parseInt(m.group(3)) : 0;
254+
return new int[]{y, mo, d};
255+
}
256+
return null;
257+
}
258+
213259
public static class Result {
214260
public boolean success;
215261
public boolean emptySearch;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH
5+
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
6+
*/
7+
package com.owncloud.android.ui.adapter
8+
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertNotNull
11+
import org.junit.Assert.assertNull
12+
import org.junit.Test
13+
import java.util.Calendar
14+
15+
class GalleryAdapterFolderDateTest {
16+
17+
@Test
18+
fun `extractFolderDate returns null for null path`() {
19+
assertNull(GalleryAdapter.extractFolderDate(null))
20+
}
21+
22+
@Test
23+
fun `extractFolderDate returns null for path without date pattern`() {
24+
assertNull(GalleryAdapter.extractFolderDate("/Photos/vacation/image.jpg"))
25+
assertNull(GalleryAdapter.extractFolderDate("/Documents/file.pdf"))
26+
assertNull(GalleryAdapter.extractFolderDate(""))
27+
}
28+
29+
@Test
30+
fun `extractFolderDate extracts YYYY MM pattern`() {
31+
val result = GalleryAdapter.extractFolderDate("/Photos/2025/01/image.jpg")
32+
assertNotNull(result)
33+
34+
val cal = Calendar.getInstance().apply { timeInMillis = result!! }
35+
assertEquals(2025, cal.get(Calendar.YEAR))
36+
assertEquals(0, cal.get(Calendar.MONTH)) // January is 0
37+
assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1
38+
}
39+
40+
@Test
41+
fun `extractFolderDate extracts YYYY MM DD pattern`() {
42+
val result = GalleryAdapter.extractFolderDate("/Photos/2025/01/15/image.jpg")
43+
assertNotNull(result)
44+
45+
val cal = Calendar.getInstance().apply { timeInMillis = result!! }
46+
assertEquals(2025, cal.get(Calendar.YEAR))
47+
assertEquals(0, cal.get(Calendar.MONTH)) // January is 0
48+
assertEquals(15, cal.get(Calendar.DAY_OF_MONTH))
49+
}
50+
51+
@Test
52+
fun `extractFolderDate rejects single digit month`() {
53+
assertNull(GalleryAdapter.extractFolderDate("/Photos/2025/3/image.jpg"))
54+
}
55+
56+
@Test
57+
fun `extractFolderDate ignores single digit day and defaults to 1`() {
58+
// /2025/03/5/ matches YYYY/MM only, day defaults to 1
59+
val result = GalleryAdapter.extractFolderDate("/Photos/2025/03/5/image.jpg")
60+
assertNotNull(result)
61+
62+
val cal = Calendar.getInstance().apply { timeInMillis = result!! }
63+
assertEquals(2025, cal.get(Calendar.YEAR))
64+
assertEquals(2, cal.get(Calendar.MONTH)) // March is 2
65+
assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) // defaults to 1
66+
}
67+
68+
@Test
69+
fun `extractFolderDate works with nested paths`() {
70+
val result = GalleryAdapter.extractFolderDate("/InstantUpload/Camera/2024/12/25/IMG_001.jpg")
71+
assertNotNull(result)
72+
73+
val cal = Calendar.getInstance().apply { timeInMillis = result!! }
74+
assertEquals(2024, cal.get(Calendar.YEAR))
75+
assertEquals(11, cal.get(Calendar.MONTH)) // December is 11
76+
assertEquals(25, cal.get(Calendar.DAY_OF_MONTH))
77+
}
78+
79+
@Test
80+
fun `extractFolderDate finds first match in path with multiple date patterns`() {
81+
val result = GalleryAdapter.extractFolderDate("/2023/06/backup/2024/12/25/image.jpg")
82+
assertNotNull(result)
83+
84+
val cal = Calendar.getInstance().apply { timeInMillis = result!! }
85+
assertEquals(2023, cal.get(Calendar.YEAR))
86+
assertEquals(5, cal.get(Calendar.MONTH)) // June is 5
87+
}
88+
89+
@Test
90+
fun `extractFolderDate returns midnight timestamp`() {
91+
val result = GalleryAdapter.extractFolderDate("/Photos/2025/01/15/image.jpg")
92+
assertNotNull(result)
93+
94+
val cal = Calendar.getInstance().apply { timeInMillis = result!! }
95+
assertEquals(0, cal.get(Calendar.HOUR_OF_DAY))
96+
assertEquals(0, cal.get(Calendar.MINUTE))
97+
assertEquals(0, cal.get(Calendar.SECOND))
98+
assertEquals(0, cal.get(Calendar.MILLISECOND))
99+
}
100+
101+
@Test
102+
fun `folder date ordering - newer dates should be greater`() {
103+
val jan15 = GalleryAdapter.extractFolderDate("/Photos/2025/01/15/a.jpg")!!
104+
val jan20 = GalleryAdapter.extractFolderDate("/Photos/2025/01/20/b.jpg")!!
105+
val feb01 = GalleryAdapter.extractFolderDate("/Photos/2025/02/01/c.jpg")!!
106+
107+
assert(jan20 > jan15) { "Jan 20 should be after Jan 15" }
108+
assert(feb01 > jan20) { "Feb 1 should be after Jan 20" }
109+
assert(feb01 > jan15) { "Feb 1 should be after Jan 15" }
110+
}
111+
}

0 commit comments

Comments
 (0)