From 849f107e9949085be59c928b33750b40f30c7ec7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 28 Jul 2026 14:24:48 +0200 Subject: [PATCH 1/4] 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 1e05819f60b5e6663917a31441d8f8ade3a96d06 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 28 Jul 2026 16:29:08 +0200 Subject: [PATCH 2/4] 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 +} From 063de6148a314798a9bdbca0acfab8af76d74974 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 4 Aug 2026 09:27:00 +0200 Subject: [PATCH 3/4] wip Signed-off-by: alperozturk96 --- .../android/utils/sort/AlphanumericComparator.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 7e03e5214b67..2af919bc0b69 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 @@ -1,8 +1,16 @@ /* - * Nextcloud - Android Client - * * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * 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 com.owncloud.android.utils.sort From 480f1ee5dffe5e41e98672f2360c8d5fd63e6177 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 4 Aug 2026 09:27:04 +0200 Subject: [PATCH 4/4] wip wip Signed-off-by: alperozturk96 [skip ci] --- .../com/owncloud/android/utils/sort/AlphanumericComparator.kt | 1 - 1 file changed, 1 deletion(-) 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 2af919bc0b69..5c581c1a1cc3 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 @@ -19,7 +19,6 @@ import java.text.Collator /** * Sorts names the way people read them, so `abc2` comes before `abc10` instead of after it. - * */ class AlphanumericComparator : Comparator {