First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("java-library")
id("com.android.lint")
alias(libs.plugins.kotlin.jvm)
}
java {
sourceCompatibility = Versions.javaVersion
targetCompatibility = Versions.javaVersion
}
kotlin {
jvmToolchain {
languageVersion = Versions.javaLanguageVersion
}
}
dependencies {
implementation(libs.coroutines.core)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.bool
fun Boolean?.orTrue() = this ?: true
fun Boolean?.orFalse() = this ?: false
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.cache
/**
* A FIFO circular buffer of T.
* This class is not thread safe.
*/
class CircularCache<T : Any>(cacheSize: Int, factory: (Int) -> Array<T?>) {
companion object {
inline fun <reified T : Any> create(cacheSize: Int) = CircularCache(cacheSize) { Array<T?>(cacheSize) { null } }
}
private val cache = factory(cacheSize)
private var writeIndex = 0
fun contains(value: T): Boolean = cache.contains(value)
fun put(value: T) {
if (writeIndex == cache.size) {
writeIndex = 0
}
cache[writeIndex] = value
writeIndex++
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.job
import kotlinx.coroutines.plus
/**
* Create a child scope of the current scope.
* The child scope will be cancelled if the parent scope is cancelled.
* The child scope will be cancelled if an exception is thrown in the parent scope.
* The parent scope won't be cancelled when an exception is thrown in the child scope.
*
* @param dispatcher the dispatcher to use for this scope.
* @param name the name of the coroutine.
*/
fun CoroutineScope.childScope(
dispatcher: CoroutineDispatcher,
name: String,
): CoroutineScope = run {
val supervisorJob = SupervisorJob(parent = coroutineContext.job)
this + dispatcher + supervisorJob + CoroutineName(name)
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
data class CoroutineDispatchers(
val io: CoroutineDispatcher,
val computation: CoroutineDispatcher,
val main: CoroutineDispatcher,
) {
companion object {
val Default = CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
)
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* A [StateFlow] that derives its value from a [Flow].
* Useful when you want to apply transformations to a [Flow] and expose it as a [StateFlow].
*/
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
class DerivedStateFlow<T>(
private val getValue: () -> T,
private val flow: Flow<T>
) : StateFlow<T> {
override val replayCache: List<T>
get() = listOf(value)
override val value: T
get() = getValue()
override suspend fun collect(collector: FlowCollector<T>): Nothing {
coroutineScope { flow.distinctUntilChanged().stateIn(this).collect(collector) }
}
}
/**
* Maps the value of a [StateFlow] to a new value and returns a new [StateFlow] with the mapped value.
*/
fun <T1, R> StateFlow<T1>.mapState(transform: (a: T1) -> R): StateFlow<R> {
return DerivedStateFlow(
getValue = { transform(this.value) },
flow = this.map { a -> transform(a) }
)
}
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.flow.flow
/** Create a Flow emitting a single error event. It should be useful for tests. */
fun <T> errorFlow(throwable: Throwable) = flow<T> { throw throwable }
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.runningFold
/**
* Returns the first element of the flow that is an instance of [T], waiting for it if necessary.
*/
suspend inline fun <reified T> Flow<*>.firstInstanceOf(): T {
return first { it is T } as T
}
/**
* Returns a flow that emits pairs of the previous and current values.
* The first emission will be a pair of `null` and the first value emitted by the source flow.
*/
fun <T> Flow<T>.withPreviousValue(): Flow<Pair<T?, T>> {
return runningFold(null) { prev: Pair<T?, T>?, current ->
prev?.second to current
}
.filterNotNull()
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
// https://jivimberg.io/blog/2018/05/04/parallel-map-in-kotlin/
suspend fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> = coroutineScope {
map { async { f(it) } }.awaitAll()
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.delay
import kotlin.system.measureTimeMillis
fun suspendWithMinimumDuration(
minimumDurationMillis: Long = 500,
block: suspend () -> Unit
) = suspend {
val duration = measureTimeMillis {
block()
}
delay(minimumDurationMillis - duration)
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
fun <T> suspendLazy(coroutineContext: CoroutineContext = EmptyCoroutineContext, block: suspend () -> T): Lazy<Deferred<T>> {
return lazy(LazyThreadSafetyMode.NONE) {
val deferred = CompletableDeferred<T>()
CoroutineScope(coroutineContext).launch {
deferred.complete(block())
}
deferred
}
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.data
enum class ByteUnit(val bitShift: Int) {
BYTES(0),
KB(10),
MB(20),
GB(30)
}
class ByteSize internal constructor(val value: Long, val unit: ByteUnit) {
fun to(dest: ByteUnit): Long {
if (unit == dest) return value
return value shl unit.bitShift shr dest.bitShift
}
}
val Number.gigaBytes get() = ByteSize(toLong(), ByteUnit.GB)
val Number.megaBytes get() = ByteSize(toLong(), ByteUnit.MB)
val Number.kiloBytes get() = ByteSize(toLong(), ByteUnit.KB)
val Number.bytes get() = ByteSize(toLong(), ByteUnit.BYTES)
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.data
/**
* Returns a list containing first [count] elements matching the given [predicate].
* If the list contains less elements matching the [predicate], then all of them are returned.
*
* @param T the type of elements contained in the list.
* @param count the maximum number of elements to take.
* @param predicate the predicate used to match elements.
* @return a list containing first [count] elements matching the given [predicate].
*/
inline fun <T> Iterable<T>.filterUpTo(count: Int, predicate: (T) -> Boolean): List<T> {
val result = mutableListOf<T>()
for (element in this) {
if (predicate(element)) {
result.add(element)
if (result.size == count) {
break
}
}
}
return result
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.data
import kotlin.coroutines.cancellation.CancellationException
/**
* Can be used to catch [Exception]s in a block of code, returning `null` if an exception occurs.
*
* If the block throws a [CancellationException], it will be rethrown.
*/
inline fun <A> tryOrNull(onException: ((Exception) -> Unit) = { }, operation: () -> A): A? {
return try {
operation()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
onException.invoke(e)
null
}
}
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.extensions
import java.text.Normalizer
import java.util.Locale
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
fun Boolean.to01() = if (this) "1" else "0"
inline fun <T> T.ooi(block: (T) -> Unit): T = also(block)
/**
* Return empty CharSequence if the CharSequence is null.
*/
fun CharSequence?.orEmpty() = this ?: ""
/**
* Useful to append a String at the end of a filename but before the extension if any
* Ex:
* - "file.txt".insertBeforeLast("_foo") will return "file_foo.txt"
* - "file".insertBeforeLast("_foo") will return "file_foo"
* - "fi.le.txt".insertBeforeLast("_foo") will return "fi.le_foo.txt"
* - null.insertBeforeLast("_foo") will return "_foo".
*/
fun String?.insertBeforeLast(insert: String, delimiter: String = "."): String {
if (this == null) return insert
val idx = lastIndexOf(delimiter)
return if (idx == -1) {
this + insert
} else {
replaceRange(idx, idx, insert)
}
}
/**
* Truncate and ellipsize text if it exceeds the given length.
*
* Throws if length is < 1.
*/
fun String.ellipsize(length: Int): String {
require(length >= 1)
if (this.length <= length) {
return this
}
return "${this.take(length)}"
}
/**
* Replace the old prefix with the new prefix.
* If the string does not start with the old prefix, the string is returned as is.
*/
fun String.replacePrefix(oldPrefix: String, newPrefix: String): String {
return if (startsWith(oldPrefix)) {
newPrefix + substring(oldPrefix.length)
} else {
this
}
}
/**
* Surround with brackets.
*/
fun String.withBrackets(prefix: String = "(", suffix: String = ")"): String {
return "$prefix$this$suffix"
}
/**
* Capitalize the string.
*/
fun String.safeCapitalize(): String {
return replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(Locale.getDefault())
} else {
it.toString()
}
}
}
fun String.withoutAccents(): String {
return Normalizer.normalize(this, Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "")
}
private const val RTL_OVERRIDE_CHAR = '\u202E'
private const val LTR_OVERRIDE_CHAR = '\u202D'
fun String.ensureEndsLeftToRight() = if (containsRtLOverride()) "$this$LTR_OVERRIDE_CHAR" else this
fun String.containsRtLOverride() = contains(RTL_OVERRIDE_CHAR)
fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || it == LTR_OVERRIDE_CHAR }
/**
* This works around https://github.com/element-hq/element-x-android/issues/2105.
* @param maxLength Max characters to retrieve. Defaults to `500`.
* @param ellipsize Whether to add an ellipsis (`…`) char at the end or not. Defaults to `false`.
* @return The string truncated to [maxLength] characters, with an optional ellipsis if larger.
*/
fun String.toSafeLength(
maxLength: Int = 500,
ellipsize: Boolean = false,
): String {
return if (ellipsize) {
ellipsize(maxLength)
} else if (length > maxLength) {
take(maxLength)
} else {
this
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.extensions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
fun BuildMeta.isElement(): Boolean {
return when (buildType) {
BuildType.RELEASE -> applicationId == "io.element.android.x"
BuildType.NIGHTLY -> applicationId == "io.element.android.x.nightly"
BuildType.DEBUG -> applicationId == "io.element.android.x.debug"
}
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.extensions
import kotlin.coroutines.cancellation.CancellationException
/**
* Can be used to catch exceptions in a block of code and return a [Result].
* If the block throws a [CancellationException], it will be rethrown.
* If it throws any other exception, it will be wrapped in a [Result.failure].
*
* [Error]s are not caught by this function, as they are not meant to be caught in normal application flow.
*/
inline fun <T> runCatchingExceptions(
block: () -> T
): Result<T> {
return try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Can be used to catch exceptions in a block of code and return a [Result].
* If the block throws a [CancellationException], it will be rethrown.
* If it throws any other exception, it will be wrapped in a [Result.failure].
*
* [Error]s are not caught by this function, as they are not meant to be caught in normal application flow.
*/
inline fun <T, R> T.runCatchingExceptions(
block: T.() -> R
): Result<R> {
return try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Can be used to transform a [Result] into another [Result] by applying a [block] to the value if it is successful.
* If the original [Result] is a failure, the exception will be wrapped in a new [Result.failure].
*
* This is a safer version of [Result.mapCatching].
*/
inline fun <R, T> Result<T>.mapCatchingExceptions(
block: (T) -> R,
): Result<R> {
return fold(
onSuccess = { value -> runCatchingExceptions { block(value) } },
onFailure = { exception -> Result.failure(exception) }
)
}
/**
* Can be used to transform some Throwable into some other.
*/
inline fun <R, T : R> Result<T>.mapFailure(transform: (exception: Throwable) -> Throwable): Result<R> {
return when (val exception = exceptionOrNull()) {
null -> this
else -> Result.failure(transform(exception))
}
}
/**
* Can be used to apply a [transform] that returns a [Result] to a base [Result] and get another [Result].
* @return The result of the transform as a [Result].
*/
inline fun <R, T> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> {
return map(transform).fold(
onSuccess = { it },
onFailure = { Result.failure(it) }
)
}
/**
* Can be used to apply a [transform] that returns a [Result] to a base [Result] and get another [Result], catching any exception.
* @return The result of the transform or a caught exception wrapped in a [Result].
*/
inline fun <R, T> Result<T>.flatMapCatching(transform: (T) -> Result<R>): Result<R> {
return mapCatchingExceptions(transform).fold(
onSuccess = { it },
onFailure = { Result.failure(it) }
)
}
/**
* Can be used to execute a block of code after the [Result] has been processed, regardless of whether it was successful or not.
* The block receives the exception if there was one, or `null` if the result was successful.
*/
inline fun <T> Result<T>.finally(block: (exception: Throwable?) -> Unit): Result<T> {
onSuccess { block(null) }
onFailure(block)
return this
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.hash
import java.security.MessageDigest
import java.util.Locale
/**
* Compute a Hash of a String, using md5 algorithm.
*/
fun String.md5() = try {
val digest = MessageDigest.getInstance("md5")
val locale = Locale.ROOT
digest.update(toByteArray())
digest.digest()
.joinToString("") { String.format(locale, "%02X", it) }
.lowercase(locale)
} catch (exc: Exception) {
// Should not happen, but just in case
hashCode().toString()
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.log.logger
/**
* Parent class for custom logger tags. Can be used with Timber :
*
* val loggerTag = LoggerTag("MyTag", LoggerTag.VOIP)
* Timber.tag(loggerTag.value).v("My log message")
*/
open class LoggerTag(name: String, parentTag: LoggerTag? = null) {
object PushLoggerTag : LoggerTag("Push")
object NotificationLoggerTag : LoggerTag("Notification", PushLoggerTag)
val value: String = if (parentTag == null) {
name
} else {
"${parentTag.value}/$name"
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.meta
data class BuildMeta(
val buildType: BuildType,
val isDebuggable: Boolean,
val applicationName: String,
val productionApplicationName: String,
val desktopApplicationName: String,
val applicationId: String,
val isEnterpriseBuild: Boolean,
val lowPrivacyLoggingEnabled: Boolean,
val versionName: String,
val versionCode: Long,
val gitRevision: String,
val gitBranchName: String,
val flavorDescription: String,
val flavorShortDescription: String,
)
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.meta
enum class BuildType {
RELEASE,
NIGHTLY,
DEBUG
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.mimetype
import io.element.android.libraries.core.bool.orFalse
// The Android SDK does not provide constant for mime type, add some of them here
@Suppress("ktlint:standard:property-naming")
object MimeTypes {
const val Any: String = "*/*"
const val Json = "application/json"
const val OctetStream = "application/octet-stream"
const val Apk = "application/vnd.android.package-archive"
const val Pdf = "application/pdf"
const val Images = "image/*"
const val Png = "image/png"
const val BadJpg = "image/jpg"
const val Jpeg = "image/jpeg"
const val Gif = "image/gif"
const val WebP = "image/webp"
const val Svg = "image/svg+xml"
const val Videos = "video/*"
const val Mp4 = "video/mp4"
const val Audio = "audio/*"
const val Ogg = "audio/ogg"
const val Mp3 = "audio/mp3"
const val PlainText = "text/plain"
fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this
fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse()
fun String?.isMimeTypeAnimatedImage() = this == Gif || this == WebP
fun String?.isMimeTypeVideo() = this?.startsWith("video/").orFalse()
fun String?.isMimeTypeAudio() = this?.startsWith("audio/").orFalse()
fun String?.isMimeTypeApplication() = this?.startsWith("application/").orFalse()
fun String?.isMimeTypeFile() = this?.startsWith("file/").orFalse()
fun String?.isMimeTypeText() = this?.startsWith("text/").orFalse()
fun String?.isMimeTypeAny() = this?.startsWith("*/").orFalse()
fun fromFileExtension(fileExtension: String): String {
return when (fileExtension.lowercase()) {
"apk" -> Apk
"pdf" -> Pdf
else -> OctetStream
}
}
fun hasSubtype(mimeType: String): Boolean {
val components = mimeType.split("/")
if (components.size != 2) return false
val subType = components.last()
return subType.isNotBlank() && subType != "*"
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.preview
val loremIpsum = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut la
bore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate v
elit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proide
nt, sunt in culpa qui officia deserunt mollit anim id est laborum.
""".trimIndent()
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.uri
import java.net.URI
fun String.isValidUrl(): Boolean {
return try {
URI(this).toURL()
true
} catch (t: Throwable) {
false
}
}
/**
* Ensure string starts with "http". If it is not the case, "https://" is added, only if the String is not empty
*/
fun String.ensureProtocol(): String {
return when {
isEmpty() -> this
!startsWith("http") -> "https://$this"
else -> this
}
}
fun String.ensureTrailingSlash(): String {
return when {
isEmpty() -> this
!endsWith("/") -> "$this/"
else -> this
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.cache
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class CircularCacheTest {
@Test
fun `when putting more than cache size then cache is limited to cache size`() {
val (cache, internalData) = createIntCache(cacheSize = 3)
cache.putInOrder(1, 1, 1, 1, 1, 1)
assertThat(internalData).isEqualTo(arrayOf(1, 1, 1))
}
@Test
fun `when putting more than cache then acts as FIFO`() {
val (cache, internalData) = createIntCache(cacheSize = 3)
cache.putInOrder(1, 2, 3, 4)
assertThat(internalData).isEqualTo(arrayOf(4, 2, 3))
}
@Test
fun `given empty cache when checking if contains key then is false`() {
val (cache, _) = createIntCache(cacheSize = 3)
val result = cache.contains(1)
assertThat(result).isFalse()
}
@Test
fun `given cached key when checking if contains key then is true`() {
val (cache, _) = createIntCache(cacheSize = 3)
cache.put(1)
val result = cache.contains(1)
assertThat(result).isTrue()
}
private fun createIntCache(cacheSize: Int): Pair<CircularCache<Int>, Array<Int?>> {
var internalData: Array<Int?>? = null
val factory: (Int) -> Array<Int?> = {
Array<Int?>(it) { null }.also { array -> internalData = array }
}
return CircularCache(cacheSize, factory) to internalData!!
}
private fun CircularCache<Int>.putInOrder(vararg values: Int) {
values.forEach { put(it) }
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.data
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class ByteSizeTest {
@Test
fun testSizeConversions() {
// Check bytes to other units
val bytes = 10_000_000.bytes
assertThat(bytes.to(ByteUnit.BYTES)).isEqualTo(bytes.value)
assertThat(bytes.to(ByteUnit.KB)).isEqualTo(bytes.value / 1024L)
assertThat(bytes.to(ByteUnit.MB)).isEqualTo(bytes.value / 1024L / 1024L)
assertThat(bytes.to(ByteUnit.GB)).isEqualTo(bytes.value / 1024L / 1024L / 1024L)
// Now check for values too small to be converted
assertThat(100.bytes.to(ByteUnit.KB)).isEqualTo(0)
assertThat(100.bytes.to(ByteUnit.MB)).isEqualTo(0)
assertThat(100.bytes.to(ByteUnit.GB)).isEqualTo(0)
// Check for KBs
val kiloBytes = 10_000.kiloBytes
assertThat(kiloBytes.to(ByteUnit.BYTES)).isEqualTo(kiloBytes.value * 1024L)
assertThat(kiloBytes.to(ByteUnit.KB)).isEqualTo(kiloBytes.value)
assertThat(kiloBytes.to(ByteUnit.MB)).isEqualTo(kiloBytes.value / 1024L)
assertThat(kiloBytes.to(ByteUnit.GB)).isEqualTo(kiloBytes.value / 1024L / 1024L)
// Check for MBs
val megaBytes = 10_000.megaBytes
assertThat(megaBytes.to(ByteUnit.BYTES)).isEqualTo(megaBytes.value * 1024L * 1024L)
assertThat(megaBytes.to(ByteUnit.KB)).isEqualTo(megaBytes.value * 1024L)
assertThat(megaBytes.to(ByteUnit.MB)).isEqualTo(megaBytes.value)
assertThat(megaBytes.to(ByteUnit.GB)).isEqualTo(megaBytes.value / 1024L)
// Check for GBs
val gigaBytes = 10.gigaBytes
assertThat(gigaBytes.to(ByteUnit.BYTES)).isEqualTo(gigaBytes.value * 1024L * 1024L * 1024L)
assertThat(gigaBytes.to(ByteUnit.KB)).isEqualTo(gigaBytes.value * 1024L * 1024L)
assertThat(gigaBytes.to(ByteUnit.MB)).isEqualTo(gigaBytes.value * 1024L)
assertThat(gigaBytes.to(ByteUnit.GB)).isEqualTo(gigaBytes.value)
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.extensions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BasicExtensionsTest {
@Test(expected = IllegalArgumentException::class)
fun `test ellipsize at 0`() {
"1234567890".ellipsize(0)
}
@Test
fun `test ellipsize at 1`() {
assertEquals(
"1…",
"1234567890".ellipsize(1)
)
}
@Test
fun `test ellipsize at 5`() {
val output = "1234567890".ellipsize(5)
assertEquals("12345…", output)
}
@Test
fun `test ellipsize noop 1`() {
val input = "12345"
val output = input.ellipsize(5)
assertEquals(input, output)
}
@Test
fun `test ellipsize noop 2`() {
val input = "123"
val output = input.ellipsize(5)
assertEquals(input, output)
}
@Test
fun `given text with RtL unicode override, when checking contains RtL Override, then returns true`() {
val textWithRtlOverride = "hello\u202Eworld"
val result = textWithRtlOverride.containsRtLOverride()
assertTrue(result)
}
@Test
fun `given text without RtL unicode override, when checking contains RtL Override, then returns false`() {
val textWithRtlOverride = "hello world"
val result = textWithRtlOverride.containsRtLOverride()
assertFalse(result)
}
@Test
fun `given text with RtL unicode override, when ensuring ends LtR, then appends a LtR unicode override`() {
val textWithRtlOverride = "123\u202E456"
val result = textWithRtlOverride.ensureEndsLeftToRight()
assertEquals("$textWithRtlOverride\u202D", result)
}
@Test
fun `given text with unicode direction overrides, when filtering direction overrides, then removes all overrides`() {
val textWithDirectionOverrides = "123\u202E456\u202d789"
val result = textWithDirectionOverrides.filterDirectionOverrides()
assertEquals("123456789", result)
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.extensions
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class ResultTest {
@Test
fun testFlatMap() {
val initial = Result.success("initial")
val otherResult = initial.flatMap { Result.success("other") }
val errorResult = initial.flatMap { Result.failure<String>(IllegalStateException("error")) }
assertThat(otherResult.getOrNull()).isEqualTo("other")
assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error")
try {
initial.flatMap<String, String> { error("caught error") }
} catch (e: IllegalStateException) {
assertThat(e.message).isEqualTo("caught error")
}
val initialError = Result.failure<String>(IllegalStateException("initial error"))
val mapErrorToSuccess = initialError.flatMap { Result.success("other") }
val mapErrorToError = initialError.flatMap { Result.failure<String>(IllegalStateException("error")) }
val mapErrorAndCatch: Result<String> = initialError.flatMap { error("error") }
assertThat(mapErrorToSuccess.exceptionOrNull()?.message).isEqualTo("initial error")
assertThat(mapErrorToError.exceptionOrNull()?.message).isEqualTo("initial error")
assertThat(mapErrorAndCatch.exceptionOrNull()?.message).isEqualTo("initial error")
}
@Test
fun testFlatMapCatching() {
val initial = Result.success("initial")
val otherResult = initial.flatMapCatching { Result.success("other") }
val errorResult = initial.flatMapCatching { Result.failure<String>(IllegalStateException("error")) }
val caughtExceptionResult: Result<String> = initial.flatMapCatching { error("caught error") }
assertThat(otherResult.getOrNull()).isEqualTo("other")
assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error")
assertThat(caughtExceptionResult.exceptionOrNull()?.message).isEqualTo("caught error")
val initialError = Result.failure<String>(IllegalStateException("initial error"))
val mapErrorToSuccess = initialError.flatMapCatching { Result.success("other") }
val mapErrorToError = initialError.flatMapCatching { Result.failure<String>(IllegalStateException("error")) }
val mapErrorAndCatch: Result<String> = initialError.flatMapCatching { error("error") }
assertThat(mapErrorToSuccess.exceptionOrNull()?.message).isEqualTo("initial error")
assertThat(mapErrorToError.exceptionOrNull()?.message).isEqualTo("initial error")
assertThat(mapErrorAndCatch.exceptionOrNull()?.message).isEqualTo("initial error")
}
}