From c84ac3f08c1f76ef525ab6abfb4acc4a132c1f1e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 28 Jul 2026 14:17:15 +0200 Subject: [PATCH 1/3] fix(sort): alphanumeric comparator Signed-off-by: alperozturk96 --- .../owncloud/android/datamodel/OCFile.java | 6 +- .../fragment/contactsbackup/BackupFragment.kt | 4 +- .../android/utils/FileSortOrderByName.kt | 10 +- .../daveKoeller/AlphanumComparator.java | 187 -------------- .../nextcloud/client/utils/NaturalSortTest.kt | 240 +++++++++++++----- 5 files changed, 188 insertions(+), 259 deletions(-) delete mode 100644 app/src/main/java/thirdparties/daveKoeller/AlphanumComparator.java diff --git a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java index f42feb760680..b2e8a8b344d2 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java +++ b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java @@ -42,7 +42,7 @@ import androidx.annotation.Nullable; import androidx.core.content.FileProvider; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import thirdparties.daveKoeller.AlphanumComparator; +import com.owncloud.android.utils.sort.AlphanumericComparator; public class OCFile implements Parcelable, Comparable, ServerFileInterface { @@ -554,13 +554,13 @@ public int describeContents() { @Override public int compareTo(@NonNull OCFile another) { if (isFolder() && another.isFolder()) { - return AlphanumComparator.compare(this, another); + return AlphanumericComparator.compare(this, another); } else if (isFolder()) { return -1; } else if (another.isFolder()) { return 1; } - return AlphanumComparator.compare(this, another); + return AlphanumericComparator.compare(this, another); } @Override diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupFragment.kt index 1d932709cb6f..35e41faffa8d 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/contactsbackup/BackupFragment.kt @@ -51,7 +51,7 @@ import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import thirdparties.daveKoeller.AlphanumComparator +import com.owncloud.android.utils.sort.AlphanumericComparator import java.util.Calendar import java.util.GregorianCalendar import javax.inject.Inject @@ -328,7 +328,7 @@ class BackupFragment : val backupFiles = listOf(calendarBackupFolderPath, contactsBackupFolderPath) .mapNotNull { path -> storageManager.getFileByDecryptedRemotePath(path) } .flatMap { folder -> fetchBackupFiles(folder, storageManager) } - .sortedWith(AlphanumComparator()) + .sortedWith(AlphanumericComparator()) withContext(Dispatchers.Main) { binding.contactsDatepicker.setVisibleIf(backupFiles.isNotEmpty()) } diff --git a/app/src/main/java/com/owncloud/android/utils/FileSortOrderByName.kt b/app/src/main/java/com/owncloud/android/utils/FileSortOrderByName.kt index ec64befc14bc..a07ca5dee615 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileSortOrderByName.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileSortOrderByName.kt @@ -11,7 +11,7 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.resources.files.model.ServerFileInterface import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile import edu.umd.cs.findbugs.annotations.SuppressFBWarnings -import thirdparties.daveKoeller.AlphanumComparator +import com.owncloud.android.utils.sort.AlphanumericComparator import java.io.File import java.util.Locale @@ -46,17 +46,17 @@ class FileSortOrderByName internal constructor(name: String?, ascending: Boolean private fun sortServerFiles(files: MutableList): MutableList { files.sortWith { o1: ServerFileInterface, o2: ServerFileInterface -> when { - o1.isFolder && o2.isFolder -> sortMultiplier * AlphanumComparator.compare(o1, o2) + o1.isFolder && o2.isFolder -> sortMultiplier * AlphanumericComparator.compare(o1, o2) o1.isFolder -> -1 o2.isFolder -> 1 - else -> sortMultiplier * AlphanumComparator.compare(o1, o2) + else -> sortMultiplier * AlphanumericComparator.compare(o1, o2) } } return files } private fun sortOnlyByName(files: MutableList): MutableList { - files.sortWith { o1: OCFile, o2: OCFile -> sortMultiplier * AlphanumComparator.compare(o1, o2) } + files.sortWith { o1: OCFile, o2: OCFile -> sortMultiplier * AlphanumericComparator.compare(o1, o2) } return files } @@ -75,7 +75,7 @@ class FileSortOrderByName internal constructor(name: String?, ascending: Boolean o2.isDirectory -> 1 - else -> sortMultiplier * AlphanumComparator.compare( + else -> sortMultiplier * AlphanumericComparator.compare( o1.path.lowercase(Locale.getDefault()), o2.path.lowercase(Locale.getDefault()) ) diff --git a/app/src/main/java/thirdparties/daveKoeller/AlphanumComparator.java b/app/src/main/java/thirdparties/daveKoeller/AlphanumComparator.java deleted file mode 100644 index a8f6939754d9..000000000000 --- a/app/src/main/java/thirdparties/daveKoeller/AlphanumComparator.java +++ /dev/null @@ -1,187 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017 Tobias Kaminsky - * SPDX-FileCopyrightText: 2012 Daniel Migowski - * SPDX-FileCopyrightText: 2012 Andre Bogus - * SPDX-FileCopyrightText: 2012 David Koelle - * SPDX-License-Identifier: LGPL-2.1-or-later - * - * The Alphanum Algorithm is an improved sorting algorithm for strings - * containing numbers. Instead of sorting numbers in ASCII order like - * a standard sort, this algorithm sorts numbers in numeric order. - * - * The Alphanum Algorithm is discussed at http://www.DaveKoelle.com - */ - -package thirdparties.daveKoeller; - -import com.owncloud.android.lib.resources.files.model.ServerFileInterface; - -import java.io.File; -import java.io.Serializable; -import java.math.BigInteger; -import java.text.Collator; -import java.util.Comparator; - -/* - * This is an updated version with enhancements made by Daniel Migowski, Andre Bogus, and David Koelle - * * - * To convert to use Templates (Java 1.5+): - * - Change "implements Comparator" to "implements Comparator" - * - Change "compare(Object o1, Object o2)" to "compare(String s1, String s2)" - * - Remove the type checking and casting in compare(). - * - * To use this class: - * Use the static "sort" method from the java.util.Collections class: - * Collections.sort(your list, new AlphanumComparator()); - * - * Adapted to fit - * https://github.com/nextcloud/server/blob/9a4253ef7c34f9dc71a6a9f7828a10df769f0c32/tests/lib/NaturalSortTest.php - * by Tobias Kaminsky - */ -public class AlphanumComparator implements Comparator, Serializable { - private static boolean isDigit(char ch) { - return ch >= 48 && ch <= 57; - } - - private static boolean isSpecialChar(char ch) { - return ch <= 47 || ch >= 58 && ch <= 64 || ch >= 91 && ch <= 96 || ch >= 123 && ch <= 126; - } - - /** - * Length of string is passed in for improved efficiency (only need to calculate it once) - **/ - private static String getChunk(String string, int stringLength, int marker) { - StringBuilder chunk = new StringBuilder(); - char c = string.charAt(marker); - chunk.append(c); - marker++; - if (isDigit(c)) { - while (marker < stringLength) { - c = string.charAt(marker); - if (!isDigit(c)) { - break; - } - chunk.append(c); - marker++; - } - } else if (!isSpecialChar(c)) { - while (marker < stringLength) { - c = string.charAt(marker); - if (isDigit(c) || isSpecialChar(c)) { - break; - } - chunk.append(c); - marker++; - } - } - return chunk.toString(); - } - - public static int compare(ServerFileInterface o1, ServerFileInterface o2) { - String s1 = o1.getFileName(); - String s2 = o2.getFileName(); - - return compare(s1, s2); - } - - public static int compare(File f1, File f2) { - String s1 = f1.getPath(); - String s2 = f2.getPath(); - - return compare(s1, s2); - } - - public int compare(T t1, T t2) { - return compare(t1.toString(), t2.toString()); - } - - public static int compare(String s1, String s2) { - int thisMarker = 0; - int thatMarker = 0; - int s1Length = s1.length(); - int s2Length = s2.length(); - - while (thisMarker < s1Length && thatMarker < s2Length) { - String thisChunk = getChunk(s1, s1Length, thisMarker); - thisMarker += thisChunk.length(); - - String thatChunk = getChunk(s2, s2Length, thatMarker); - thatMarker += thatChunk.length(); - - // If both chunks contain numeric characters, sort them numerically - int result = 0; - if (isDigit(thisChunk.charAt(0)) && isDigit(thatChunk.charAt(0))) { - // extract digits - int thisChunkZeroCount = 0; - boolean zero = true; - int countThis = 0; - while (countThis < (thisChunk.length()) && isDigit(thisChunk.charAt(countThis))) { - if (zero) { - if (Character.getNumericValue(thisChunk.charAt(countThis)) == 0) { - thisChunkZeroCount++; - } else { - zero = false; - } - } - countThis++; - } - - - int thatChunkZeroCount = 0; - int countThat = 0; - zero = true; - while (countThat < (thatChunk.length()) && isDigit(thatChunk.charAt(countThat))) { - if (zero) { - if (Character.getNumericValue(thatChunk.charAt(countThat)) == 0) { - thatChunkZeroCount++; - } else { - zero = false; - } - } - countThat++; - } - - BigInteger thisChunkValue = new BigInteger(thisChunk.substring(0, countThis)); - BigInteger thatChunkValue = new BigInteger(thatChunk.substring(0, countThat)); - - result = thisChunkValue.compareTo(thatChunkValue); - - if (result == 0) { - // value is equal, compare leading zeros - result = Integer.compare(thisChunkZeroCount, thatChunkZeroCount); - - if (result != 0) { - return result; - } - } else { - return result; - } - } else if (isSpecialChar(thisChunk.charAt(0)) && isSpecialChar(thatChunk.charAt(0))) { - for (int i = 0; i < thisChunk.length(); i++) { - if (thisChunk.charAt(i) == '.' && thatChunk.charAt(i) != '.') { - return -1; - } else if (thatChunk.charAt(i) == '.' && thisChunk.charAt(i) != '.') { - return 1; - } else { - result = thisChunk.charAt(i) - thatChunk.charAt(i); - if (result != 0) { - return result; - } - } - } - } else if (isSpecialChar(thisChunk.charAt(0)) && !isSpecialChar(thatChunk.charAt(0))) { - return -1; - } else if (!isSpecialChar(thisChunk.charAt(0)) && isSpecialChar(thatChunk.charAt(0))) { - return 1; - } else { - result = Collator.getInstance().compare(thisChunk, thatChunk); - } - - if (result != 0) { - return result; - } - } - - return s1Length - s2Length; - } -} diff --git a/app/src/test/java/com/nextcloud/client/utils/NaturalSortTest.kt b/app/src/test/java/com/nextcloud/client/utils/NaturalSortTest.kt index bfd6b5e6b527..d3e90ccb73ed 100644 --- a/app/src/test/java/com/nextcloud/client/utils/NaturalSortTest.kt +++ b/app/src/test/java/com/nextcloud/client/utils/NaturalSortTest.kt @@ -1,89 +1,205 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.nextcloud.client.utils -import org.junit.Assert +import com.owncloud.android.utils.sort.AlphanumericComparator +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import thirdparties.daveKoeller.AlphanumComparator +import java.util.Locale +import kotlin.random.Random /** - * Adapted on 2022/02/04 from https://github.com/nextcloud/server/blob/caff1023ea72bb2ea94130e18a2a6e2ccf819e5f/tests/lib/NaturalSortTest.php + * Adapted from https://github.com/nextcloud/server/blob/master/tests/lib/NaturalSortTest.php + * */ -@RunWith(Parameterized::class) +@Suppress("TooManyFunctions") class NaturalSortTest { - @Parameterized.Parameter(0) - lateinit var title: String + private lateinit var defaultLocale: Locale + + @Before + fun setUp() { + defaultLocale = Locale.getDefault() + Locale.setDefault(Locale.US) + } + + @After + fun tearDown() { + Locale.setDefault(defaultLocale) + } + + @Test + fun everyStartingOrderReachesTheExpectedOrder() { + val comparator = AlphanumericComparator() + + expectedOrders.forEach { (title, expected) -> + repeat(SHUFFLE_COUNT) { seed -> + val sorted = expected.shuffled(Random(seed)).sortedWith(comparator) + assertEquals("Wrong sort for \"$title\", shuffle seed $seed", expected, sorted) + } + } + } + + @Test + fun namesDifferingOnlyByIgnorableCharacterKeepTransitivity() { + val withSeparatorAndMark = "é-0$COMBINING_DIAERESIS" + val withDigit = "é9" + val withJoiner = "é$ZERO_WIDTH_JOINER" + + val firstToSecond = compare(withSeparatorAndMark, withDigit) + val secondToThird = compare(withDigit, withJoiner) + val firstToThird = compare(withSeparatorAndMark, withJoiner) + + assertTrue( + "${withSeparatorAndMark.readable()} <= ${withDigit.readable()} ($firstToSecond) and " + + "${withDigit.readable()} <= ${withJoiner.readable()} ($secondToThird) must imply " + + "${withSeparatorAndMark.readable()} <= ${withJoiner.readable()}, but was $firstToThird", + !(firstToSecond <= 0 && secondToThird <= 0) || firstToThird <= 0 + ) + } + + @Test + fun comparatorIsAntisymmetric() { + val names = namesWithIgnorableCharacters() - @Parameterized.Parameter(1) - lateinit var expected: Array + val violations = names.flatMap { first -> + names.filter { second -> compare(first, second).compareTo(0) != -compare(second, first).compareTo(0) } + .map { second -> "${first.readable()} vs ${second.readable()}" } + } + + assertEquals( + "compare(a, b) must be the opposite sign of compare(b, a)", + emptyList(), + violations.take(FAILURES_TO_REPORT) + ) + } + + @Test + fun comparatorIsTransitive() { + val names = namesWithIgnorableCharacters() + + val violations = names.flatMap { first -> + names.filter { second -> compare(first, second) <= 0 } + .flatMap { second -> brokenChains(first, second, names) } + } + + assertEquals("a <= b and b <= c must imply a <= c", emptyList(), violations.take(FAILURES_TO_REPORT)) + } + + @Test + fun sortingNeverThrowsRegardlessOfInputOrder() { + val comparator = AlphanumericComparator() + val names = largeNameListWithIgnorableCharacters() + + val failures = (0 until SORT_ATTEMPTS).mapNotNull { seed -> + runCatching { names.shuffled(Random(seed)).sortedWith(comparator) } + .exceptionOrNull() + ?.let { "seed $seed: ${it.message}" } + } + + assertTrue("Sorting must never throw, but failed for ${failures.take(FAILURES_TO_REPORT)}", failures.isEmpty()) + } + + private fun brokenChains(first: String, second: String, names: List): List = names + .filter { third -> compare(second, third) <= 0 && compare(first, third) > 0 } + .map { third -> "${first.readable()} <= ${second.readable()} <= ${third.readable()}" } + + private fun compare(first: String, second: String): Int = AlphanumericComparator.compare(first, second) + + private fun String.readable(): String = buildString { + append('"') + this@readable.forEach { character -> + if (character.code in PRINTABLE_RANGE) append(character) else append("\\u%04X".format(character.code)) + } + append('"') + } + + private fun namesWithIgnorableCharacters(): List = buildNames( + prefixes = listOf("é", "a"), + infixes = listOf( + "-0$COMBINING_DIAERESIS", + "9", + ZERO_WIDTH_JOINER, + "", + "-1", + SOFT_HYPHEN, + "8$COMBINING_DIAERESIS" + ), + suffixes = listOf("", ".jpg1") + ) + + private fun largeNameListWithIgnorableCharacters(): List = buildNames( + prefixes = listOf("é", "a", "ñ"), + infixes = listOf( + "-0$COMBINING_DIAERESIS", + "9", + ZERO_WIDTH_JOINER, + "", + "-1", + SOFT_HYPHEN, + "8$COMBINING_DIAERESIS", + "-", + "0" + ), + suffixes = listOf("", ".jpg1", ".jpg2") + ) + + private fun buildNames(prefixes: List, infixes: List, suffixes: List): List = + prefixes.flatMap { prefix -> + infixes.flatMap { infix -> suffixes.map { suffix -> prefix + infix + suffix } } + }.distinct() companion object { - @Parameterized.Parameters(name = "{0}") - @JvmStatic - @Suppress("LongMethod") - fun data(): Iterable> = listOf( - arrayOf( - "Different casing", - arrayOf("aaa", "AAA", "bbb", "BBB") - ), - arrayOf( - "Numbers", - arrayOf( - "15.txt", "15b.txt", "123.txt", "124.txt", "abc", "abc1", "abc2", "abc10", "abc12", "def.txt", - "def (1).txt", "def (2).txt", "def (10).txt", "def (12).txt", "ghi.txt", "ghi 1.txt", "ghi 2.txt", - "ghi 10.txt", "ghi 12.txt", "z", "za", "zz" - ) + private const val SHUFFLE_COUNT = 50 + private const val SORT_ATTEMPTS = 3000 + private const val FAILURES_TO_REPORT = 5 + private val PRINTABLE_RANGE = 32..126 + + private const val ZERO_WIDTH_JOINER = "\u200D" + private const val SOFT_HYPHEN = "\u00AD" + private const val COMBINING_DIAERESIS = "\u0308" + + private val expectedOrders = listOf( + "Different casing" to listOf("aaa", "AAA", "bbb", "BBB"), + "Numbers" to listOf( + "15.txt", "15b.txt", "123.txt", "124.txt", "abc", "abc1", "abc2", "abc10", "abc12", "def.txt", + "def (1).txt", "def (2).txt", "def (10).txt", "def (12).txt", "ghi.txt", "ghi 1.txt", "ghi 2.txt", + "ghi 10.txt", "ghi 12.txt", "z", "za", "zz" ), - arrayOf( - "Chinese characters", - arrayOf( - "123.txt", "abc.txt", "一.txt", "七.txt", "三.txt", "九.txt", "二.txt", "五.txt", "八.txt", "六.txt", - "十.txt", "十 2.txt", "十一.txt", "啊.txt", "四.txt", "波.txt", "破.txt", "莫.txt" - ) + "Chinese characters" to listOf( + "123.txt", "abc.txt", "一.txt", "七.txt", "三.txt", "九.txt", "二.txt", "五.txt", "八.txt", "六.txt", + "十.txt", "十 2.txt", "十一.txt", "啊.txt", "四.txt", "波.txt", "破.txt", "莫.txt" ), - arrayOf( - "With umlauts", - arrayOf( - "ah.txt", "äh.txt", "Äh.txt", "oh.txt", "öh.txt", "Öh.txt", "uh.txt", "üh.txt", "Üh.txt", - "Üh 2.txt" - ) + "With umlauts" to listOf( + "ah.txt", "äh.txt", "Äh.txt", "oh.txt", "öh.txt", "Öh.txt", "uh.txt", "üh.txt", "Üh.txt", "Üh 2.txt" ), - arrayOf( - "Leading zeroes", - arrayOf( - "2012-09-15 22.50.37.jpg", "2012-Card.jpg", "1584164_460s_v1.jpg", "08082008.jpg", - "02122011150.jpg", "03122011151.jpg", "9999999999999999999999999999991.jpg", - "9999999999999999999999999999992.jpg", "T 0 abc", "T 00 abc", "T 000 abc", "T 1 abc", "T 01 abc", - "T 001 abc", "T 2 abc", "T 02 abc", "T 3 abc", "T 03 abc" - ) + "Leading zeroes" to listOf( + "2012-09-15 22.50.37.jpg", "2012-Card.jpg", "1584164_460s_v1.jpg", "08082008.jpg", + "02122011150.jpg", "03122011151.jpg", "9999999999999999999999999999991.jpg", + "9999999999999999999999999999992.jpg", "T 0 abc", "T 00 abc", "T 000 abc", "T 1 abc", "T 01 abc", + "T 001 abc", "T 2 abc", "T 02 abc", "T 3 abc", "T 03 abc" ), - arrayOf( - "Trailing digits", - arrayOf("A", "T", "T 01", "T 2", "T 003", "Zeros", "Zeros 2") + "Trailing digits" to listOf("A", "T", "T 01", "T 2", "T 003", "Zeros", "Zeros 2"), + "Special chars" to listOf( + "[Test] Folder", "01 - January", "11 - November", "Ôle", "Test 1", "Test 01", "Test 04", "Üüü", + "z.[Test], z. Test" ), - arrayOf( - "Special chars", - arrayOf( - "[Test] Folder", "01 - January", "11 - November", "Ôle", "Test 1", "Test 01", "Test 04", "Üüü", - "z.[Test], z. Test" - ) + "Precomposed and decomposed accents" to listOf("Caf\u00E9.txt", "Cafe\u0301.txt"), + "Collation ignorable characters" to listOf("file.txt", "fi${SOFT_HYPHEN}le.txt"), + "Zero width joiner next to a number chunk" to listOf( + "photo-1.jpg", + "photo9.jpg", + "photo$ZERO_WIDTH_JOINER.jpg" ) ) } - - @Test - fun test() { - val sut = AlphanumComparator() - val shuffled = expected.clone().apply { shuffle() } - val sorted = shuffled.sortedWith(sut).toTypedArray() - Assert.assertArrayEquals("Wrong sort", expected, sorted) - } } From 80e5ba053581560c19a9bcefa43b045f3a6d1528 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 28 Jul 2026 14:24:48 +0200 Subject: [PATCH 2/3] fix(sort): alphanumeric comparator Signed-off-by: alperozturk96 --- .../utils/sort/AlphanumericComparator.kt | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 app/src/main/java/com/owncloud/android/utils/sort/AlphanumericComparator.kt diff --git a/app/src/main/java/com/owncloud/android/utils/sort/AlphanumericComparator.kt b/app/src/main/java/com/owncloud/android/utils/sort/AlphanumericComparator.kt new file mode 100644 index 000000000000..5f6626f24b74 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/sort/AlphanumericComparator.kt @@ -0,0 +1,140 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.utils.sort + +import com.owncloud.android.lib.resources.files.model.ServerFileInterface +import java.math.BigInteger +import java.text.Collator + +/** + * Sorts names the way people read them, so `abc2` comes before `abc10` instead of after it. + */ +class AlphanumericComparator : Comparator { + + override fun compare(first: T, second: T): Int = compare(first.toString(), second.toString()) + + companion object { + + @JvmStatic + fun compare(first: ServerFileInterface, second: ServerFileInterface): Int = + compare(first.fileName, second.fileName) + + @JvmStatic + fun compare(first: String, second: String): Int { + if (first == second) { + return 0 + } + + val ourChunks = chunks(first) + val theirChunks = chunks(second) + + val byChunk = ourChunks.asSequence() + .zip(theirChunks.asSequence()) { ours, theirs -> compareChunks(ours, theirs) } + .firstOrNull { it != 0 } + + return byChunk + ?: ourChunks.size.compareTo(theirChunks.size).takeIf { it != 0 } + ?: first.compareTo(second) + } + + private val collator = ThreadLocal.withInitial { Collator.getInstance() } + + private fun chunks(name: String): List { + val chunks = mutableListOf() + var start = 0 + + while (start < name.length) { + val end = chunkEnd(name, start) + chunks += name.substring(start, end) + start = end + } + + return chunks + } + + private fun chunkEnd(name: String, start: Int): Int { + if (name[start].isSeparator()) { + return start + 1 + } + + val digitRun = name[start].isAsciiDigit() + var end = start + 1 + while (end < name.length && !name[end].isSeparator() && name[end].isAsciiDigit() == digitRun) { + end++ + } + return end + } + + private fun compareChunks(ours: String, theirs: String): Int { + val ourRank = rankOf(ours) + val byRank = ourRank.compareTo(rankOf(theirs)) + + return when { + byRank != 0 -> byRank + ourRank == RANK_SEPARATOR -> compareSeparators(ours[0], theirs[0]) + ourRank == RANK_NUMBER -> compareNumbers(ours, theirs) + else -> compareText(ours, theirs) + } + } + + private fun rankOf(chunk: String): Int = when { + chunk[0].isSeparator() -> RANK_SEPARATOR + chunk[0].isAsciiDigit() -> RANK_NUMBER + else -> RANK_TEXT + } + + private fun compareSeparators(ours: Char, theirs: Char): Int = when { + ours == theirs -> 0 + ours == DOT -> -1 + theirs == DOT -> 1 + else -> ours.compareTo(theirs) + } + + private fun compareNumbers(ours: String, theirs: String): Int = + when (val byValue = BigInteger(ours).compareTo(BigInteger(theirs))) { + 0 -> leadingZeroes(ours).compareTo(leadingZeroes(theirs)) + else -> byValue + } + + private fun leadingZeroes(digits: String): Int = digits.takeWhile { it == ZERO }.length + + private fun compareText(ours: String, theirs: String): Int { + val byCollation = collator.get()?.compare(ours, theirs) ?: 0 + if (byCollation != 0) { + return byCollation + } + + return when (val byLength = ours.length.compareTo(theirs.length)) { + 0 -> ours.compareTo(theirs) + else -> byLength + } + } + + private fun Char.isAsciiDigit(): Boolean = this in ZERO..NINE + + private fun Char.isSeparator(): Boolean = this <= LAST_CONTROL_OR_PUNCTUATION || + this in FIRST_PUNCTUATION_AFTER_DIGITS..LAST_PUNCTUATION_BEFORE_UPPERCASE || + this in FIRST_PUNCTUATION_AFTER_UPPERCASE..LAST_PUNCTUATION_BEFORE_LOWERCASE || + this in FIRST_PUNCTUATION_AFTER_LOWERCASE..LAST_ASCII_PUNCTUATION + + private const val RANK_SEPARATOR = 0 + private const val RANK_NUMBER = 1 + private const val RANK_TEXT = 2 + + private const val DOT = '.' + private const val ZERO = '0' + private const val NINE = '9' + + private const val LAST_CONTROL_OR_PUNCTUATION = '/' + private const val FIRST_PUNCTUATION_AFTER_DIGITS = ':' + private const val LAST_PUNCTUATION_BEFORE_UPPERCASE = '@' + private const val FIRST_PUNCTUATION_AFTER_UPPERCASE = '[' + private const val LAST_PUNCTUATION_BEFORE_LOWERCASE = '`' + private const val FIRST_PUNCTUATION_AFTER_LOWERCASE = '{' + private const val LAST_ASCII_PUNCTUATION = '~' + } +} From c697d10d5db11eb0ddf3e79a5f9e19d90e87fa69 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 28 Jul 2026 16:29:08 +0200 Subject: [PATCH 3/3] simplify Signed-off-by: alperozturk96 --- .../utils/sort/AlphanumericComparator.kt | 127 ++++++++---------- .../owncloud/android/utils/sort/ChunkKind.kt | 13 ++ 2 files changed, 71 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/utils/sort/ChunkKind.kt diff --git a/app/src/main/java/com/owncloud/android/utils/sort/AlphanumericComparator.kt b/app/src/main/java/com/owncloud/android/utils/sort/AlphanumericComparator.kt index 5f6626f24b74..7e03e5214b67 100644 --- a/app/src/main/java/com/owncloud/android/utils/sort/AlphanumericComparator.kt +++ b/app/src/main/java/com/owncloud/android/utils/sort/AlphanumericComparator.kt @@ -7,11 +7,11 @@ package com.owncloud.android.utils.sort import com.owncloud.android.lib.resources.files.model.ServerFileInterface -import java.math.BigInteger import java.text.Collator /** * Sorts names the way people read them, so `abc2` comes before `abc10` instead of after it. + * */ class AlphanumericComparator : Comparator { @@ -24,67 +24,67 @@ class AlphanumericComparator : Comparator { compare(first.fileName, second.fileName) @JvmStatic - fun compare(first: String, second: String): Int { - if (first == second) { - return 0 - } - - val ourChunks = chunks(first) - val theirChunks = chunks(second) + fun compare(first: String, second: String): Int = if (first == second) 0 else compareByChunks(first, second) - val byChunk = ourChunks.asSequence() - .zip(theirChunks.asSequence()) { ours, theirs -> compareChunks(ours, theirs) } - .firstOrNull { it != 0 } + private fun compareByChunks(first: String, second: String): Int { + var ourStart = 0 + var theirStart = 0 - return byChunk - ?: ourChunks.size.compareTo(theirChunks.size).takeIf { it != 0 } - ?: first.compareTo(second) - } - - private val collator = ThreadLocal.withInitial { Collator.getInstance() } + while (ourStart < first.length && theirStart < second.length) { + val ours = chunkAt(first, ourStart) + val theirs = chunkAt(second, theirStart) - private fun chunks(name: String): List { - val chunks = mutableListOf() - var start = 0 + val byChunk = compareChunks(ours, theirs) + if (byChunk != 0) { + return byChunk + } - while (start < name.length) { - val end = chunkEnd(name, start) - chunks += name.substring(start, end) - start = end + ourStart += ours.length + theirStart += theirs.length } - return chunks + return when (val byLength = first.length.compareTo(second.length)) { + 0 -> first.compareTo(second) + else -> byLength + } } - private fun chunkEnd(name: String, start: Int): Int { - if (name[start].isSeparator()) { - return start + 1 - } + private val collators: ThreadLocal = ThreadLocal.withInitial { Collator.getInstance() } + + private val collator: Collator + get() = collators.get() ?: Collator.getInstance() - val digitRun = name[start].isAsciiDigit() + private fun chunkAt(name: String, start: Int): String { + val kind = kindOf(name[start]) var end = start + 1 - while (end < name.length && !name[end].isSeparator() && name[end].isAsciiDigit() == digitRun) { - end++ + + if (kind != ChunkKind.SEPARATOR) { + while (end < name.length && kindOf(name[end]) == kind) { + end++ + } } - return end + + return name.substring(start, end) } private fun compareChunks(ours: String, theirs: String): Int { - val ourRank = rankOf(ours) - val byRank = ourRank.compareTo(rankOf(theirs)) - - return when { - byRank != 0 -> byRank - ourRank == RANK_SEPARATOR -> compareSeparators(ours[0], theirs[0]) - ourRank == RANK_NUMBER -> compareNumbers(ours, theirs) - else -> compareText(ours, theirs) + val kind = kindOf(ours[0]) + val byKind = kind.compareTo(kindOf(theirs[0])) + if (byKind != 0) { + return byKind + } + + return when (kind) { + ChunkKind.SEPARATOR -> compareSeparators(ours[0], theirs[0]) + ChunkKind.NUMBER -> compareNumbers(ours, theirs) + ChunkKind.TEXT -> compareText(ours, theirs) } } - private fun rankOf(chunk: String): Int = when { - chunk[0].isSeparator() -> RANK_SEPARATOR - chunk[0].isAsciiDigit() -> RANK_NUMBER - else -> RANK_TEXT + private fun kindOf(char: Char): ChunkKind = when { + char <= LAST_ASCII_PUNCTUATION && !char.isLetterOrDigit() -> ChunkKind.SEPARATOR + char in ZERO..NINE -> ChunkKind.NUMBER + else -> ChunkKind.TEXT } private fun compareSeparators(ours: Char, theirs: Char): Int = when { @@ -94,16 +94,23 @@ class AlphanumericComparator : Comparator { else -> ours.compareTo(theirs) } - private fun compareNumbers(ours: String, theirs: String): Int = - when (val byValue = BigInteger(ours).compareTo(BigInteger(theirs))) { - 0 -> leadingZeroes(ours).compareTo(leadingZeroes(theirs)) - else -> byValue + private fun compareNumbers(ours: String, theirs: String): Int { + val ourValue = ours.trimStart(ZERO) + val theirValue = theirs.trimStart(ZERO) + + val byDigitCount = ourValue.length.compareTo(theirValue.length) + if (byDigitCount != 0) { + return byDigitCount } - private fun leadingZeroes(digits: String): Int = digits.takeWhile { it == ZERO }.length + return when (val byValue = ourValue.compareTo(theirValue)) { + 0 -> ours.length.compareTo(theirs.length) + else -> byValue + } + } private fun compareText(ours: String, theirs: String): Int { - val byCollation = collator.get()?.compare(ours, theirs) ?: 0 + val byCollation = collator.compare(ours, theirs) if (byCollation != 0) { return byCollation } @@ -114,27 +121,9 @@ class AlphanumericComparator : Comparator { } } - private fun Char.isAsciiDigit(): Boolean = this in ZERO..NINE - - private fun Char.isSeparator(): Boolean = this <= LAST_CONTROL_OR_PUNCTUATION || - this in FIRST_PUNCTUATION_AFTER_DIGITS..LAST_PUNCTUATION_BEFORE_UPPERCASE || - this in FIRST_PUNCTUATION_AFTER_UPPERCASE..LAST_PUNCTUATION_BEFORE_LOWERCASE || - this in FIRST_PUNCTUATION_AFTER_LOWERCASE..LAST_ASCII_PUNCTUATION - - private const val RANK_SEPARATOR = 0 - private const val RANK_NUMBER = 1 - private const val RANK_TEXT = 2 - private const val DOT = '.' private const val ZERO = '0' private const val NINE = '9' - - private const val LAST_CONTROL_OR_PUNCTUATION = '/' - private const val FIRST_PUNCTUATION_AFTER_DIGITS = ':' - private const val LAST_PUNCTUATION_BEFORE_UPPERCASE = '@' - private const val FIRST_PUNCTUATION_AFTER_UPPERCASE = '[' - private const val LAST_PUNCTUATION_BEFORE_LOWERCASE = '`' - private const val FIRST_PUNCTUATION_AFTER_LOWERCASE = '{' private const val LAST_ASCII_PUNCTUATION = '~' } } diff --git a/app/src/main/java/com/owncloud/android/utils/sort/ChunkKind.kt b/app/src/main/java/com/owncloud/android/utils/sort/ChunkKind.kt new file mode 100644 index 000000000000..9a521e1b13d3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/sort/ChunkKind.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.utils.sort + +internal enum class ChunkKind { + SEPARATOR, + NUMBER, + TEXT +}