Skip to content

Commit 80e5ba0

Browse files
committed
fix(sort): alphanumeric comparator
Signed-off-by: alperozturk96 <alper_ozturk@proton.me>
1 parent c84ac3f commit 80e5ba0

1 file changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.owncloud.android.utils.sort
8+
9+
import com.owncloud.android.lib.resources.files.model.ServerFileInterface
10+
import java.math.BigInteger
11+
import java.text.Collator
12+
13+
/**
14+
* Sorts names the way people read them, so `abc2` comes before `abc10` instead of after it.
15+
*/
16+
class AlphanumericComparator<T : Any> : Comparator<T> {
17+
18+
override fun compare(first: T, second: T): Int = compare(first.toString(), second.toString())
19+
20+
companion object {
21+
22+
@JvmStatic
23+
fun compare(first: ServerFileInterface, second: ServerFileInterface): Int =
24+
compare(first.fileName, second.fileName)
25+
26+
@JvmStatic
27+
fun compare(first: String, second: String): Int {
28+
if (first == second) {
29+
return 0
30+
}
31+
32+
val ourChunks = chunks(first)
33+
val theirChunks = chunks(second)
34+
35+
val byChunk = ourChunks.asSequence()
36+
.zip(theirChunks.asSequence()) { ours, theirs -> compareChunks(ours, theirs) }
37+
.firstOrNull { it != 0 }
38+
39+
return byChunk
40+
?: ourChunks.size.compareTo(theirChunks.size).takeIf { it != 0 }
41+
?: first.compareTo(second)
42+
}
43+
44+
private val collator = ThreadLocal.withInitial { Collator.getInstance() }
45+
46+
private fun chunks(name: String): List<String> {
47+
val chunks = mutableListOf<String>()
48+
var start = 0
49+
50+
while (start < name.length) {
51+
val end = chunkEnd(name, start)
52+
chunks += name.substring(start, end)
53+
start = end
54+
}
55+
56+
return chunks
57+
}
58+
59+
private fun chunkEnd(name: String, start: Int): Int {
60+
if (name[start].isSeparator()) {
61+
return start + 1
62+
}
63+
64+
val digitRun = name[start].isAsciiDigit()
65+
var end = start + 1
66+
while (end < name.length && !name[end].isSeparator() && name[end].isAsciiDigit() == digitRun) {
67+
end++
68+
}
69+
return end
70+
}
71+
72+
private fun compareChunks(ours: String, theirs: String): Int {
73+
val ourRank = rankOf(ours)
74+
val byRank = ourRank.compareTo(rankOf(theirs))
75+
76+
return when {
77+
byRank != 0 -> byRank
78+
ourRank == RANK_SEPARATOR -> compareSeparators(ours[0], theirs[0])
79+
ourRank == RANK_NUMBER -> compareNumbers(ours, theirs)
80+
else -> compareText(ours, theirs)
81+
}
82+
}
83+
84+
private fun rankOf(chunk: String): Int = when {
85+
chunk[0].isSeparator() -> RANK_SEPARATOR
86+
chunk[0].isAsciiDigit() -> RANK_NUMBER
87+
else -> RANK_TEXT
88+
}
89+
90+
private fun compareSeparators(ours: Char, theirs: Char): Int = when {
91+
ours == theirs -> 0
92+
ours == DOT -> -1
93+
theirs == DOT -> 1
94+
else -> ours.compareTo(theirs)
95+
}
96+
97+
private fun compareNumbers(ours: String, theirs: String): Int =
98+
when (val byValue = BigInteger(ours).compareTo(BigInteger(theirs))) {
99+
0 -> leadingZeroes(ours).compareTo(leadingZeroes(theirs))
100+
else -> byValue
101+
}
102+
103+
private fun leadingZeroes(digits: String): Int = digits.takeWhile { it == ZERO }.length
104+
105+
private fun compareText(ours: String, theirs: String): Int {
106+
val byCollation = collator.get()?.compare(ours, theirs) ?: 0
107+
if (byCollation != 0) {
108+
return byCollation
109+
}
110+
111+
return when (val byLength = ours.length.compareTo(theirs.length)) {
112+
0 -> ours.compareTo(theirs)
113+
else -> byLength
114+
}
115+
}
116+
117+
private fun Char.isAsciiDigit(): Boolean = this in ZERO..NINE
118+
119+
private fun Char.isSeparator(): Boolean = this <= LAST_CONTROL_OR_PUNCTUATION ||
120+
this in FIRST_PUNCTUATION_AFTER_DIGITS..LAST_PUNCTUATION_BEFORE_UPPERCASE ||
121+
this in FIRST_PUNCTUATION_AFTER_UPPERCASE..LAST_PUNCTUATION_BEFORE_LOWERCASE ||
122+
this in FIRST_PUNCTUATION_AFTER_LOWERCASE..LAST_ASCII_PUNCTUATION
123+
124+
private const val RANK_SEPARATOR = 0
125+
private const val RANK_NUMBER = 1
126+
private const val RANK_TEXT = 2
127+
128+
private const val DOT = '.'
129+
private const val ZERO = '0'
130+
private const val NINE = '9'
131+
132+
private const val LAST_CONTROL_OR_PUNCTUATION = '/'
133+
private const val FIRST_PUNCTUATION_AFTER_DIGITS = ':'
134+
private const val LAST_PUNCTUATION_BEFORE_UPPERCASE = '@'
135+
private const val FIRST_PUNCTUATION_AFTER_UPPERCASE = '['
136+
private const val LAST_PUNCTUATION_BEFORE_LOWERCASE = '`'
137+
private const val FIRST_PUNCTUATION_AFTER_LOWERCASE = '{'
138+
private const val LAST_ASCII_PUNCTUATION = '~'
139+
}
140+
}

0 commit comments

Comments
 (0)