|
| 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