From 95eed0771943a324c58fcbdf2e942f3cc51095f3 Mon Sep 17 00:00:00 2001 From: Hardcore Sushi Date: Thu, 13 Jan 2022 19:23:37 +0100 Subject: [PATCH] Natural file name sorting --- README.md | 3 +- .../hardcore/droidfs/collation/ByteString.kt | 72 +++++++++++ .../droidfs/collation/ByteStringBuilder.kt | 47 +++++++ .../collation/CollatorFileNameExtensions.kt | 115 ++++++++++++++++++ .../droidfs/explorers/ExplorerElement.kt | 7 +- 5 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/sushi/hardcore/droidfs/collation/ByteString.kt create mode 100644 app/src/main/java/sushi/hardcore/droidfs/collation/ByteStringBuilder.kt create mode 100644 app/src/main/java/sushi/hardcore/droidfs/collation/CollatorFileNameExtensions.kt diff --git a/README.md b/README.md index a3e198e..0473e3c 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ Thanks to these open source projects that DroidFS uses: ### Modified code: - [gocryptfs](https://github.com/rfjakob/gocryptfs) to encrypt your data +### Borrowed code: +- [MaterialFiles](https://github.com/zhanghai/MaterialFiles) for kotlin natural sorting implementation ### Libraries: -- [Cyanea](https://github.com/jaredrummler/Cyanea) to customize UI - [Glide](https://github.com/bumptech/glide/) to display pictures - [ExoPlayer](https://github.com/google/ExoPlayer) to play media files diff --git a/app/src/main/java/sushi/hardcore/droidfs/collation/ByteString.kt b/app/src/main/java/sushi/hardcore/droidfs/collation/ByteString.kt new file mode 100644 index 0000000..e2b9837 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/collation/ByteString.kt @@ -0,0 +1,72 @@ +/* + * Code borrowed from the awesome Material Files app (https://github.com/zhanghai/MaterialFiles) + * + * Copyright (c) 2019 Hai Zhang + * All Rights Reserved. + */ + +package sushi.hardcore.droidfs.collation + +import kotlin.math.min + +class ByteString internal constructor( + private val bytes: ByteArray +) : Comparable { + + fun borrowBytes(): ByteArray = bytes + + private var stringCache: String? = null + + override fun toString(): String { + // We are okay with the potential race condition here. + var string = stringCache + if (string == null) { + // String() uses replacement char instead of throwing exception. + string = String(bytes) + stringCache = string + } + return string + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (javaClass != other?.javaClass) { + return false + } + other as ByteString + return bytes contentEquals other.bytes + } + + override fun hashCode(): Int = bytes.contentHashCode() + + override fun compareTo(other: ByteString): Int = bytes.compareTo(other.bytes) + + private fun ByteArray.compareTo(other: ByteArray): Int { + val size = size + val otherSize = other.size + for (index in 0 until min(size, otherSize)) { + val byte = this[index] + val otherByte = other[index] + val result = byte - otherByte + if (result != 0) { + return result + } + } + return size - otherSize + } + + companion object { + fun fromBytes(bytes: ByteArray, start: Int = 0, end: Int = bytes.size): ByteString = + ByteString(bytes.copyOfRange(start, end)) + + fun fromString(string: String): ByteString = + ByteString(string.toByteArray()).apply { stringCache = string } + } +} + +fun ByteArray.toByteString(start: Int = 0, end: Int = size): ByteString = + ByteString.fromBytes(this, start, end) + +fun String.toByteString(): ByteString = ByteString.fromString(this) diff --git a/app/src/main/java/sushi/hardcore/droidfs/collation/ByteStringBuilder.kt b/app/src/main/java/sushi/hardcore/droidfs/collation/ByteStringBuilder.kt new file mode 100644 index 0000000..a0c3510 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/collation/ByteStringBuilder.kt @@ -0,0 +1,47 @@ +/* + * Code borrowed from the awesome Material Files app (https://github.com/zhanghai/MaterialFiles) + * + * Copyright (c) 2019 Hai Zhang + * All Rights Reserved. + */ + +package sushi.hardcore.droidfs.collation + +class ByteStringBuilder(capacity: Int = 16) { + private var bytes = ByteArray(capacity) + + var length = 0 + private set + + fun append(byte: Byte): ByteStringBuilder { + ensureCapacity(length + 1) + bytes[length] = byte + ++length + return this + } + + fun append(bytes: ByteArray, start: Int = 0, end: Int = bytes.size): ByteStringBuilder { + val newLength = length + (end - start) + ensureCapacity(newLength) + bytes.copyInto(this.bytes, length, start, end) + length = newLength + return this + } + + fun append(byteString: ByteString): ByteStringBuilder = append(byteString.borrowBytes()) + + private fun ensureCapacity(minimumCapacity: Int) { + val capacity = bytes.size + if (minimumCapacity > capacity) { + var newCapacity = (capacity shl 1) + 2 + if (newCapacity < minimumCapacity) { + newCapacity = minimumCapacity + } + bytes = bytes.copyOf(newCapacity) + } + } + + fun toByteString(): ByteString = bytes.toByteString(0, length) + + override fun toString(): String = String(bytes, 0, length) +} diff --git a/app/src/main/java/sushi/hardcore/droidfs/collation/CollatorFileNameExtensions.kt b/app/src/main/java/sushi/hardcore/droidfs/collation/CollatorFileNameExtensions.kt new file mode 100644 index 0000000..53fa99c --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/collation/CollatorFileNameExtensions.kt @@ -0,0 +1,115 @@ +/* + * Code borrowed from the awesome Material Files app (https://github.com/zhanghai/MaterialFiles) + * + * Copyright (c) 2020 Hai Zhang + * All Rights Reserved. + */ + +package sushi.hardcore.droidfs.collation + +import java.text.CollationKey +import java.text.Collator +import kotlin.math.min + +private val COLLATION_SENTINEL = byteArrayOf(1, 1, 1) + +// @see https://github.com/GNOME/glib/blob/mainline/glib/gunicollate.c +// g_utf8_collate_key_for_filename() +fun Collator.getCollationKeyForFileName(source: String): CollationKey { + val result = ByteStringBuilder() + val suffix = ByteStringBuilder() + var previousIndex = 0 + var index = 0 + val endIndex = source.length + while (index < endIndex) { + when { + source[index] == '.' -> { + if (previousIndex != index) { + val collationKey = getCollationKey(source.substring(previousIndex, index)) + result.append(collationKey.toByteArray()) + } + result.append(COLLATION_SENTINEL).append(1) + previousIndex = index + 1 + } + source[index].isAsciiDigit() -> { + if (previousIndex != index) { + val collationKey = getCollationKey(source.substring(previousIndex, index)) + result.append(collationKey.toByteArray()) + } + result.append(COLLATION_SENTINEL).append(2) + previousIndex = index + var leadingZeros: Int + var digits: Int + if (source[index] == '0') { + leadingZeros = 1 + digits = 0 + } else { + leadingZeros = 0 + digits = 1 + } + while (++index < endIndex) { + if (source[index] == '0' && digits == 0) { + ++leadingZeros + } else if (source[index].isAsciiDigit()) { + ++digits + } else { + if (digits == 0) { + ++digits + --leadingZeros + } + break + } + } + while (digits > 1) { + result.append(':'.code.toByte()) + --digits + } + if (leadingZeros > 0) { + suffix.append(leadingZeros.toByte()) + previousIndex += leadingZeros + } + result.append(source.substring(previousIndex, index).toByteString()) + previousIndex = index + --index + } + else -> {} + } + ++index + } + if (previousIndex != index) { + val collationKey = getCollationKey(source.substring(previousIndex, index)) + result.append(collationKey.toByteArray()) + } + result.append(suffix.toByteString()) + return ByteArrayCollationKey(source, result.toByteString().borrowBytes()) +} + +private fun Char.isAsciiDigit(): Boolean = this in '0'..'9' + +private class ByteArrayCollationKey( + @Suppress("CanBeParameter") + private val source: String, + private val bytes: ByteArray +) : CollationKey(source) { + override fun compareTo(other: CollationKey): Int { + other as ByteArrayCollationKey + return bytes.unsignedCompareTo(other.bytes) + } + + override fun toByteArray(): ByteArray = bytes.copyOf() +} + +private fun ByteArray.unsignedCompareTo(other: ByteArray): Int { + val size = size + val otherSize = other.size + for (index in 0 until min(size, otherSize)) { + val byte = this[index].toInt() and 0xFF + val otherByte = other[index].toInt() and 0xFF + if (byte < otherByte) { + return -1 + } else if (byte > otherByte) { + return 1 + } + } + return size - otherSize +} diff --git a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerElement.kt b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerElement.kt index 0470d96..8b1fc91 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerElement.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerElement.kt @@ -1,11 +1,14 @@ package sushi.hardcore.droidfs.explorers +import sushi.hardcore.droidfs.collation.getCollationKeyForFileName import sushi.hardcore.droidfs.util.PathUtils +import java.text.Collator import java.util.* class ExplorerElement(val name: String, val elementType: Short, var size: Long = -1, mTime: Long = -1, val parentPath: String) { val mTime = Date((mTime * 1000).toString().toLong()) val fullPath: String = PathUtils.pathJoin(parentPath, name) + val collationKey = Collator.getInstance().getCollationKeyForFileName(fullPath) val isDirectory: Boolean get() = elementType.toInt() == DIRECTORY_TYPE @@ -53,7 +56,7 @@ class ExplorerElement(val name: String, val elementType: Short, var size: Long = when (sortOrder) { "name" -> { explorerElements.sortWith { a, b -> - doSort(a, b, foldersFirst) { a.fullPath.compareTo(b.fullPath, true) } + doSort(a, b, foldersFirst) { a.collationKey.compareTo(b.collationKey) } } } "size" -> { @@ -68,7 +71,7 @@ class ExplorerElement(val name: String, val elementType: Short, var size: Long = } "name_desc" -> { explorerElements.sortWith { a, b -> - doSort(a, b, foldersFirst) { b.fullPath.compareTo(a.fullPath, true) } + doSort(a, b, foldersFirst) { b.collationKey.compareTo(a.collationKey) } } } "size_desc" -> {