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

1
libraries/androidutils/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,42 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 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("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.androidutils"
buildFeatures {
buildConfig = true
}
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.di)
implementation(projects.libraries.core)
implementation(projects.services.toolbox.api)
implementation(libs.timber)
implementation(libs.androidx.corektx)
implementation(libs.androidx.activity.activity)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json)
api(libs.androidx.browser)
testCommonDependencies(libs)
testImplementation(libs.coroutines.core)
testImplementation(projects.services.toolbox.test)
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2023 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View File

@@ -0,0 +1,46 @@
/*
* 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.androidutils.assets
import android.content.Context
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.annotations.ApplicationContext
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
/**
* Read asset files.
*/
@Inject
class AssetReader(
@ApplicationContext private val context: Context,
) {
private val cache = ConcurrentHashMap<String, String?>()
/**
* Read an asset from resource and return a String or null in case of error.
*
* @param assetFilename Asset filename
* @return the content of the asset file, or null in case of error
*/
fun readAssetFile(assetFilename: String): String? {
return cache.getOrPut(assetFilename, {
return try {
context.assets.open(assetFilename).use { it.bufferedReader().readText() }
} catch (e: Exception) {
Timber.e(e, "## readAssetFile() failed")
null
}
})
}
fun clearCache() {
cache.clear()
}
}

View File

@@ -0,0 +1,88 @@
/*
* 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.androidutils.bitmap
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import java.io.File
import kotlin.math.min
fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
outputStream().use { out ->
bitmap.compress(format, quality, out)
out.flush()
}
}
/**
* Scales the current [Bitmap] to fit the ([maxWidth], [maxHeight]) bounds while keeping aspect ratio.
* @throws IllegalStateException if [maxWidth] or [maxHeight] <= 0.
*/
fun Bitmap.resizeToMax(maxWidth: Int, maxHeight: Int): Bitmap {
// No need to resize
if (this.width == maxWidth && this.height == maxHeight) return this
val aspectRatio = this.width.toFloat() / this.height.toFloat()
val useWidth = aspectRatio >= 1
val calculatedMaxWidth = min(this.width, maxWidth)
val calculatedMinHeight = min(this.height, maxHeight)
val width = if (useWidth) calculatedMaxWidth else (calculatedMinHeight * aspectRatio).toInt()
val height = if (useWidth) (calculatedMaxWidth / aspectRatio).toInt() else calculatedMinHeight
return scale(width, height)
}
/**
* Calculates and returns [BitmapFactory.Options.inSampleSize] given a pair of [desiredWidth] & [desiredHeight]
* and the previously read [BitmapFactory.Options.outWidth] & [BitmapFactory.Options.outHeight].
*/
fun BitmapFactory.Options.calculateInSampleSize(desiredWidth: Int, desiredHeight: Int): Int {
var inSampleSize = 1
if (outWidth > desiredWidth || outHeight > desiredHeight) {
val halfHeight: Int = outHeight / 2
val halfWidth: Int = outWidth / 2
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while (halfHeight / inSampleSize >= desiredHeight && halfWidth / inSampleSize >= desiredWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
/**
* Decodes the [inputStream] into a [Bitmap] and applies the needed rotation based on [orientation].
* This orientation value must be one of `ExifInterface.ORIENTATION_*` constants.
*/
fun Bitmap.rotateToExifMetadataOrientation(orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.preRotate(-90f)
matrix.preScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.preRotate(90f)
matrix.preScale(-1f, 1f)
}
else -> return this
}
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
}

View File

@@ -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.androidutils.browser
import android.app.Activity
import android.content.ActivityNotFoundException
import android.os.Bundle
import android.provider.Browser
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsSession
import androidx.core.net.toUri
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import java.util.Locale
/**
* Open url in custom tab or, if not available, in the default browser.
* If several compatible browsers are installed, the user will be proposed to choose one.
* Ref: https://developer.chrome.com/multidevice/android/customtabs.
*/
fun Activity.openUrlInChromeCustomTab(
session: CustomTabsSession?,
darkTheme: Boolean,
url: String
) {
try {
CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
// TODO .setToolbarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground))
// TODO .setNavigationBarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground))
.build()
)
.setColorScheme(
when (darkTheme) {
false -> CustomTabsIntent.COLOR_SCHEME_LIGHT
true -> CustomTabsIntent.COLOR_SCHEME_DARK
}
)
.setShareIdentityEnabled(false)
// Note: setting close button icon does not work
// .setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_back_24dp))
// .setStartAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
// .setExitAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
.apply { session?.let { setSession(it) } }
.build()
.apply {
// Disable download button
intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_DOWNLOAD_BUTTON", true)
// Disable bookmark button
intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_STAR_BUTTON", true)
intent.putExtra(Browser.EXTRA_HEADERS, Bundle().apply {
putString("Accept-Language", Locale.getDefault().toLanguageTag())
})
}
.launchUrl(this, url.toUri())
} catch (activityNotFoundException: ActivityNotFoundException) {
openUrlInExternalApp(url)
}
}

View File

@@ -0,0 +1,60 @@
/*
* 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.androidutils.browser
import android.util.Log
import android.webkit.ConsoleMessage
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import timber.log.Timber
interface ConsoleMessageLogger {
fun log(
tag: String,
consoleMessage: ConsoleMessage,
)
}
@ContributesBinding(AppScope::class)
class DefaultConsoleMessageLogger : ConsoleMessageLogger {
override fun log(
tag: String,
consoleMessage: ConsoleMessage,
) {
val priority = when (consoleMessage.messageLevel()) {
ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
else -> Log.DEBUG
}
val message = buildString {
append(consoleMessage.sourceId())
append(":")
append(consoleMessage.lineNumber())
append(" ")
append(consoleMessage.message())
}
// Avoid logging any messages that contain "password" to prevent leaking sensitive information
if (message.contains("password=")) {
return
}
Timber.tag(tag).log(
priority = priority,
message = buildString {
append(consoleMessage.sourceId())
append(":")
append(consoleMessage.lineNumber())
append(" ")
append(consoleMessage.message())
},
)
}
}

View File

@@ -0,0 +1,30 @@
/*
* 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.androidutils.clipboard
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AndroidClipboardHelper(
@ApplicationContext private val context: Context,
) : ClipboardHelper {
private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>())
override fun copyPlainText(text: String) {
clipboardManager.setPrimaryClip(ClipData.newPlainText("", text))
}
}

View File

@@ -0,0 +1,16 @@
/*
* 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.androidutils.clipboard
/**
* Wrapper class for handling clipboard operations so it can be used in JVM environments.
*/
interface ClipboardHelper {
fun copyPlainText(text: String)
}

View File

@@ -0,0 +1,17 @@
/*
* 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.androidutils.clipboard
class FakeClipboardHelper : ClipboardHelper {
var clipboardContents: Any? = null
override fun copyPlainText(text: String) {
clipboardContents = text
}
}

View File

@@ -0,0 +1,35 @@
/*
* 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.androidutils.compat
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.os.Build
fun PackageManager.queryIntentActivitiesCompat(data: Intent, flags: Int): List<ResolveInfo> {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> queryIntentActivities(
data,
PackageManager.ResolveInfoFlags.of(flags.toLong())
)
else -> @Suppress("DEPRECATION") queryIntentActivities(data, flags)
}
}
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getApplicationInfo(
packageName,
PackageManager.ApplicationInfoFlags.of(flags.toLong())
)
else -> getApplicationInfo(packageName, flags)
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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.androidutils.diff
import androidx.recyclerview.widget.DiffUtil
/**
* Default implementation of [DiffUtil.Callback] that uses [areItemsTheSame] to compare items.
*/
internal class DefaultDiffCallback<T>(
private val oldList: List<T>,
private val newList: List<T>,
private val areItemsTheSame: (oldItem: T?, newItem: T?) -> Boolean,
) : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList.getOrNull(oldItemPosition)
val newItem = newList.getOrNull(newItemPosition)
return areItemsTheSame(oldItem, newItem)
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList.getOrNull(oldItemPosition)
val newItem = newList.getOrNull(newItemPosition)
return oldItem == newItem
}
}

View File

@@ -0,0 +1,58 @@
/*
* 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.androidutils.diff
/**
* A cache that can be used to store some data that can be invalidated when a diff is applied.
* The cache is invalidated by the [DiffCacheInvalidator].
*/
interface DiffCache<E> {
fun get(index: Int): E?
fun indices(): IntRange
fun isEmpty(): Boolean
}
/**
* A [DiffCache] that can be mutated by adding, removing or updating elements.
*/
interface MutableDiffCache<E> : DiffCache<E> {
fun removeAt(index: Int): E?
fun add(index: Int, element: E?)
operator fun set(index: Int, element: E?)
}
/**
* A [MutableDiffCache] backed by a [MutableList].
*
*/
class MutableListDiffCache<E>(private val mutableList: MutableList<E?> = ArrayList()) : MutableDiffCache<E> {
override fun removeAt(index: Int): E? {
return mutableList.removeAt(index)
}
override fun get(index: Int): E? {
return mutableList.getOrNull(index)
}
override fun indices(): IntRange {
return mutableList.indices
}
override fun isEmpty(): Boolean {
return mutableList.isEmpty()
}
override operator fun set(index: Int, element: E?) {
mutableList[index] = element
}
override fun add(index: Int, element: E?) {
mutableList.add(index, element)
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.androidutils.diff
/**
* [DiffCacheInvalidator] is used to invalidate the cache when the list is updated.
* It is used by [DiffCacheUpdater].
* Check the default implementation [DefaultDiffCacheInvalidator].
*/
interface DiffCacheInvalidator<T> {
fun onChanged(position: Int, count: Int, cache: MutableDiffCache<T>)
fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache<T>)
fun onInserted(position: Int, count: Int, cache: MutableDiffCache<T>)
fun onRemoved(position: Int, count: Int, cache: MutableDiffCache<T>)
}
/**
* Default implementation of [DiffCacheInvalidator].
* It invalidates the cache by setting values to null.
*/
class DefaultDiffCacheInvalidator<T> : DiffCacheInvalidator<T> {
override fun onChanged(position: Int, count: Int, cache: MutableDiffCache<T>) {
for (i in position until position + count) {
// Invalidate cache
cache[i] = null
}
}
override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache<T>) {
val model = cache.removeAt(fromPosition)
cache.add(toPosition, model)
}
override fun onInserted(position: Int, count: Int, cache: MutableDiffCache<T>) {
repeat(count) {
cache.add(position, null)
}
}
override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache<T>) {
repeat(count) {
cache.removeAt(position)
}
}
}

View File

@@ -0,0 +1,61 @@
/*
* 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.androidutils.diff
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import timber.log.Timber
import kotlin.system.measureTimeMillis
/**
* Class in charge of updating a [MutableDiffCache] according to the cache invalidation rules provided by the [DiffCacheInvalidator].
* @param ListItem the type of the items in the list
* @param CachedItem the type of the items in the cache
* @param diffCache the cache to update
* @param detectMoves true if DiffUtil should try to detect moved items, false otherwise
* @param cacheInvalidator the invalidator to use to update the cache
* @param areItemsTheSame the function to use to compare items
*/
class DiffCacheUpdater<ListItem, CachedItem>(
private val diffCache: MutableDiffCache<CachedItem>,
private val detectMoves: Boolean = false,
private val cacheInvalidator: DiffCacheInvalidator<CachedItem> = DefaultDiffCacheInvalidator(),
private val areItemsTheSame: (oldItem: ListItem?, newItem: ListItem?) -> Boolean,
) {
private val lock = Object()
private var prevOriginalList: List<ListItem> = emptyList()
private val listUpdateCallback = object : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
cacheInvalidator.onInserted(position, count, diffCache)
}
override fun onRemoved(position: Int, count: Int) {
cacheInvalidator.onRemoved(position, count, diffCache)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
cacheInvalidator.onMoved(fromPosition, toPosition, diffCache)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
cacheInvalidator.onChanged(position, count, diffCache)
}
}
fun updateWith(newOriginalList: List<ListItem>) = synchronized(lock) {
val timeToDiff = measureTimeMillis {
val diffCallback = DefaultDiffCallback(prevOriginalList, newOriginalList, areItemsTheSame)
val diffResult = DiffUtil.calculateDiff(diffCallback, detectMoves)
prevOriginalList = newOriginalList
diffResult.dispatchUpdatesTo(listUpdateCallback)
}
Timber.v("Time to apply diff on new list of ${newOriginalList.size} items: $timeToDiff ms")
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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.androidutils.file
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.core.net.toFile
import io.element.android.libraries.core.extensions.runCatchingExceptions
fun Context.getMimeType(uri: Uri): String? = when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> contentResolver.getType(uri)
else -> null
}
fun Context.getFileName(uri: Uri): String? = when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
ContentResolver.SCHEME_FILE -> uri.toFile().name
else -> null
}
fun Context.getFileSize(uri: Uri): Long {
return when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri)
ContentResolver.SCHEME_FILE -> uri.toFile().length()
else -> 0
} ?: 0
}
private fun Context.getContentFileSize(uri: Uri): Long? = runCatchingExceptions {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
return@use cursor.getColumnIndexOrThrow(OpenableColumns.SIZE).let(cursor::getLong)
}
}.getOrNull()
private fun Context.getContentFileName(uri: Uri): String? = runCatchingExceptions {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString)
}
}.getOrNull()

View File

@@ -0,0 +1,62 @@
/*
* 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.androidutils.file
import android.content.Context
import androidx.annotation.WorkerThread
import io.element.android.libraries.core.data.tryOrNull
import timber.log.Timber
import java.io.File
import java.util.UUID
fun File.safeDelete() {
if (exists().not()) return
tryOrNull(
onException = {
Timber.e(it, "Error, unable to delete file $path")
},
operation = {
if (delete().not()) {
Timber.w("Warning, unable to delete file $path")
}
}
)
}
fun File.safeRenameTo(dest: File) {
tryOrNull(
onException = {
Timber.e(it, "Error, unable to rename file $path to ${dest.path}")
},
operation = {
if (renameTo(dest).not()) {
Timber.w("Warning, unable to rename file $path to ${dest.path}")
}
}
)
}
fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File {
val suffix = extension?.let { ".$extension" }
return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
}
/* ==========================================================================================
* Size
* ========================================================================================== */
@WorkerThread
fun File.getSizeOfFiles(): Long {
return walkTopDown()
.onEnter {
Timber.v("Get size of ${it.absolutePath}")
true
}
.sumOf { it.length() }
}

View File

@@ -0,0 +1,43 @@
/*
* 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.androidutils.file
import timber.log.Timber
import java.io.File
import java.util.zip.GZIPOutputStream
/**
* GZip a file.
*
* @param file the input file
* @return the gzipped file
*/
fun compressFile(file: File): File? {
Timber.v("## compressFile() : compress ${file.name}")
val dstFile = file.resolveSibling(file.name + ".gz")
if (dstFile.exists()) {
dstFile.safeDelete()
}
return try {
GZIPOutputStream(dstFile.outputStream()).use { gos ->
file.inputStream().use {
it.copyTo(gos, 2048)
}
}
Timber.v("## compressFile() : ${file.length()} compressed to ${dstFile.length()} bytes")
dstFile
} catch (e: Exception) {
Timber.e(e, "## compressFile() failed")
null
}
}

View File

@@ -0,0 +1,39 @@
/*
* 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.androidutils.file
import android.content.Context
import android.net.Uri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
import timber.log.Timber
interface TemporaryUriDeleter {
/**
* Delete the Uri only if it is a temporary one.
*/
fun delete(uri: Uri?)
}
@ContributesBinding(AppScope::class)
class DefaultTemporaryUriDeleter(
@ApplicationContext private val context: Context,
) : TemporaryUriDeleter {
private val baseCacheUri = "content://${context.packageName}.fileprovider/cache"
override fun delete(uri: Uri?) {
uri ?: return
if (uri.toString().startsWith(baseCacheUri)) {
context.contentResolver.delete(uri, null, null)
} else {
Timber.d("Do not delete the uri")
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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.androidutils.filesize
import android.content.Context
import android.os.Build
import android.text.format.Formatter
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@ContributesBinding(AppScope::class)
class AndroidFileSizeFormatter(
@ApplicationContext private val context: Context,
private val sdkIntProvider: BuildVersionSdkIntProvider,
) : FileSizeFormatter {
override fun format(fileSize: Long, useShortFormat: Boolean): String {
// Since Android O, the system considers that 1kB = 1000 bytes instead of 1024 bytes.
// We want to avoid that.
val normalizedSize = if (sdkIntProvider.get() <= Build.VERSION_CODES.N) {
fileSize
} else {
// First convert the size
when {
fileSize < 1024 -> fileSize
fileSize < 1024 * 1024 -> fileSize * 1000 / 1024
fileSize < 1024 * 1024 * 1024 -> fileSize * 1000 / 1024 * 1000 / 1024
else -> fileSize * 1000 / 1024 * 1000 / 1024 * 1000 / 1024
}
}
return if (useShortFormat) {
Formatter.formatShortFileSize(context, normalizedSize)
} else {
Formatter.formatFileSize(context, normalizedSize)
}
}
}

View File

@@ -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.androidutils.filesize
class FakeFileSizeFormatter : FileSizeFormatter {
override fun format(fileSize: Long, useShortFormat: Boolean): String {
return "$fileSize Bytes"
}
}

View File

@@ -0,0 +1,16 @@
/*
* 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.androidutils.filesize
interface FileSizeFormatter {
/**
* Formats a content size to be in the form of bytes, kilobytes, megabytes, etc.
*/
fun format(fileSize: Long, useShortFormat: Boolean = true): String
}

View File

@@ -0,0 +1,25 @@
/*
* 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.androidutils.hardware
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.core.content.getSystemService
fun Context.vibrate(durationMillis: Long = 100) {
val vibrator = getSystemService<Vibrator>() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(durationMillis)
}
}

View File

@@ -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.androidutils.hash
import java.security.MessageDigest
import java.util.Locale
/**
* Compute a Hash of a String, using SHA-512 algorithm.
*/
fun String.hash() = try {
val digest = MessageDigest.getInstance("SHA-512")
digest.update(toByteArray())
digest.digest()
.joinToString("") { String.format(Locale.ROOT, "%02X", it) }
.lowercase(Locale.ROOT)
} catch (exc: Exception) {
// Should not happen, but just in case
hashCode().toString()
}

View File

@@ -0,0 +1,27 @@
/*
* 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.androidutils.json
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Provider
import dev.zacsweers.metro.SingleIn
import kotlinx.serialization.json.Json
/**
* Provides a Json instance configured to ignore unknown keys.
*/
fun interface JsonProvider : Provider<Json>
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultJsonProvider : JsonProvider {
private val json: Json by lazy { Json { ignoreUnknownKeys = true } }
override fun invoke() = json
}

View File

@@ -0,0 +1,20 @@
/*
* 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.androidutils.media
import android.media.MediaMetadataRetriever
/** [MediaMetadataRetriever] only implements `AutoClosable` since API 29, so we need to execute this to have the same in older APIs. */
inline fun <T> MediaMetadataRetriever.runAndRelease(block: MediaMetadataRetriever.() -> T): T {
return try {
block()
} finally {
release()
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.androidutils.media
import android.util.Size
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Helper class to calculate the resulting output size and optimal bitrate for video compression.
*/
class VideoCompressorHelper(
/**
* The maximum size (in pixels) for the output video.
* The output will maintain the aspect ratio of the input video.
*/
val maxSize: Int,
) {
/**
* Calculates the output size for video compression based on the input size and [maxSize].
*/
fun getOutputSize(inputSize: Size): Size {
val resultMajor = min(inputSize.major(), maxSize)
val aspectRatio = inputSize.major().toFloat() / inputSize.minor().toFloat()
return if (inputSize.isLandscape()) {
Size(resultMajor, (resultMajor / aspectRatio).roundToInt())
} else {
Size((resultMajor / aspectRatio).roundToInt(), resultMajor)
}
}
/**
* Calculates the optimal bitrate for video compression based on the input size and frame rate.
*/
fun calculateOptimalBitrate(inputSize: Size, frameRate: Int): Long {
val outputSize = getOutputSize(inputSize)
val pixelsPerFrame = outputSize.width * outputSize.height
// Apparently, 0.1 bits per pixel is a sweet spot for video compression
val bitsPerPixel = 0.1f
return (pixelsPerFrame * bitsPerPixel * frameRate).toLong()
}
}
private fun Size.isLandscape(): Boolean = width > height
private fun Size.major(): Int = if (isLandscape()) width else height
private fun Size.minor(): Int = if (isLandscape()) height else width

View File

@@ -0,0 +1,29 @@
/*
* 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.androidutils.metadata
import io.element.android.libraries.androidutils.BuildConfig
/**
* true if the app is built in debug mode.
* For testing purpose, this can be changed with [withReleaseBehavior].
*/
var isInDebug: Boolean = BuildConfig.DEBUG
private set
/**
* Run the lambda simulating the app is in release mode.
*
* **IMPORTANT**: this should **ONLY** be used for testing purposes.
*/
fun withReleaseBehavior(lambda: () -> Unit) {
isInDebug = false
lambda()
isInDebug = BuildConfig.DEBUG
}

View File

@@ -0,0 +1,27 @@
/*
* 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.androidutils.preferences
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
object DefaultPreferencesCorruptionHandlerFactory {
/**
* Creates a [ReplaceFileCorruptionHandler] that will replace the corrupted preferences file with an empty preferences object.
*/
fun replaceWithEmpty(): ReplaceFileCorruptionHandler<Preferences> {
return ReplaceFileCorruptionHandler(
produceNewData = {
// If the preferences file is corrupted, we return an empty preferences object
emptyPreferences()
},
)
}
}

View File

@@ -0,0 +1,20 @@
/*
* 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.androidutils.system
import android.content.Context
import android.provider.Settings
fun Context.getAnimationScale(): Float {
return Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f)
}
fun Context.areAnimationsEnabled(): Boolean {
return getAnimationScale() > 0f
}

View File

@@ -0,0 +1,23 @@
/*
* 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.androidutils.system
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
class CopyToClipboardUseCase(
private val context: Context,
) {
fun execute(text: CharSequence) {
context.getSystemService<ClipboardManager>()
?.setPrimaryClip(ClipData.newPlainText("", text))
}
}

View File

@@ -0,0 +1,61 @@
/*
* 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.androidutils.system
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.androidutils.system.DateTimeObserver.Event
import io.element.android.libraries.di.annotations.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.time.Instant
interface DateTimeObserver {
val changes: Flow<Event>
sealed interface Event {
data object TimeZoneChanged : Event
data class DateChanged(val previous: Instant, val new: Instant) : Event
}
}
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultDateTimeObserver(
@ApplicationContext context: Context
) : DateTimeObserver {
private val dateTimeReceiver = object : BroadcastReceiver() {
private var lastTime = Instant.now()
override fun onReceive(context: Context, intent: Intent) {
val newDate = Instant.now()
when (intent.action) {
Intent.ACTION_TIMEZONE_CHANGED -> changes.tryEmit(Event.TimeZoneChanged)
Intent.ACTION_DATE_CHANGED -> changes.tryEmit(Event.DateChanged(lastTime, newDate))
Intent.ACTION_TIME_CHANGED -> changes.tryEmit(Event.DateChanged(lastTime, newDate))
}
lastTime = newDate
}
}
override val changes = MutableSharedFlow<Event>(extraBufferCapacity = 10)
init {
context.registerReceiver(dateTimeReceiver, IntentFilter().apply {
addAction(Intent.ACTION_TIMEZONE_CHANGED)
addAction(Intent.ACTION_DATE_CHANGED)
addAction(Intent.ACTION_TIME_CHANGED)
})
}
}

View File

@@ -0,0 +1,207 @@
/*
* 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.androidutils.system
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.RequiresApi
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.net.toUri
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat
import io.element.android.libraries.core.mimetype.MimeTypes
/**
* Return the application label of the provided package. If not found, the package is returned.
*/
fun Context.getApplicationLabel(packageName: String): String {
return try {
val ai = packageManager.getApplicationInfoCompat(packageName, 0)
packageManager.getApplicationLabel(ai).toString()
} catch (e: PackageManager.NameNotFoundException) {
packageName
}
}
/**
* Retrieve the versionCode from the Manifest.
* The value is more accurate than BuildConfig.VERSION_CODE, as it is correct according to the
* computation in the `androidComponents` block of the app build.gradle.kts file.
* In other words, the last digit (for the architecture) will be set, whereas BuildConfig.VERSION_CODE
* last digit will always be 0.
*/
fun Context.getVersionCodeFromManifest(): Long {
return PackageInfoCompat.getLongVersionCode(
packageManager.getPackageInfo(packageName, 0)
)
}
// ==============================================================================================================
// Clipboard helper
// ==============================================================================================================
/**
* Copy a text to the clipboard, and display a Toast when done.
*
* @receiver the context
* @param text the text to copy
* @param toastMessage content of the toast message as a String resource. Null for no toast
*/
fun Context.copyToClipboard(
text: CharSequence,
toastMessage: String? = null
) {
CopyToClipboardUseCase(this).execute(text)
toastMessage?.let { toast(it) }
}
/**
* Shows notification settings for the current app.
* In android O will directly opens the notification settings, in lower version it will show the App settings
*/
fun Context.startNotificationSettingsIntent(
activityResultLauncher: ActivityResultLauncher<Intent>? = null,
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
if (this !is Activity && activityResultLauncher == null) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
} else {
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data = Uri.fromParts("package", packageName, null)
}
try {
if (activityResultLauncher != null) {
activityResultLauncher.launch(intent)
} else {
startActivity(intent)
}
} catch (activityNotFoundException: ActivityNotFoundException) {
toast(noActivityFoundMessage)
}
}
fun Context.openAppSettingsPage(
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
try {
startActivity(
Intent().apply {
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
data = Uri.fromParts("package", packageName, null)
}
)
} catch (activityNotFoundException: ActivityNotFoundException) {
toast(noActivityFoundMessage)
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun Context.startInstallFromSourceIntent(
activityResultLauncher: ActivityResultLauncher<Intent>,
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
.setData("package:$packageName".toUri())
try {
activityResultLauncher.launch(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
toast(noActivityFoundMessage)
}
}
fun Context.startSharePlainTextIntent(
activityResultLauncher: ActivityResultLauncher<Intent>?,
chooserTitle: String?,
text: String,
subject: String? = null,
extraTitle: String? = null,
noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val share = Intent(Intent.ACTION_SEND)
share.type = MimeTypes.PlainText
share.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
// Add data to the intent, the receiving app will decide what to do with it.
share.putExtra(Intent.EXTRA_SUBJECT, subject)
share.putExtra(Intent.EXTRA_TEXT, text)
extraTitle?.let {
share.putExtra(Intent.EXTRA_TITLE, it)
}
val intent = Intent.createChooser(share, chooserTitle)
try {
if (activityResultLauncher != null) {
activityResultLauncher.launch(intent)
} else {
startActivity(intent)
}
} catch (activityNotFoundException: ActivityNotFoundException) {
toast(noActivityFoundMessage)
}
}
@Suppress("SwallowedException")
fun Context.openUrlInExternalApp(
url: String,
errorMessage: String = getString(R.string.error_no_compatible_app_found),
throwInCaseOfError: Boolean = false,
) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
if (this !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
if (throwInCaseOfError) throw activityNotFoundException
toast(errorMessage)
}
}
/**
* Open Google Play on the provided application Id.
*/
fun Context.openGooglePlay(
appId: String,
) {
try {
openUrlInExternalApp(
url = "market://details?id=$appId",
throwInCaseOfError = true,
)
} catch (_: ActivityNotFoundException) {
openUrlInExternalApp("https://play.google.com/store/apps/details?id=$appId")
}
}
// Not in KTX anymore
fun Context.toast(resId: Int) {
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
}
// Not in KTX anymore
fun Context.toast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}

View File

@@ -0,0 +1,110 @@
/*
* 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.androidutils.text
import android.text.Spannable
import android.text.style.URLSpan
import android.text.util.Linkify
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import androidx.core.text.util.LinkifyCompat
import io.element.android.libraries.core.extensions.runCatchingExceptions
import timber.log.Timber
import kotlin.collections.component1
import kotlin.collections.component2
/**
* Helper class to linkify text while preserving existing URL spans.
*
* It also checks the linkified results to make sure URLs spans are not including trailing punctuation.
*/
object LinkifyHelper {
fun linkify(
text: CharSequence,
@LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES,
): CharSequence {
// Convert the text to a Spannable to be able to add URL spans, return the original text if it's not possible (in tests, i.e.)
val spannable = text.toSpannable() ?: return text
// Get all URL spans, as they will be removed by LinkifyCompat.addLinks
val oldURLSpans = spannable.getSpans<URLSpan>(0, text.length).associateWith {
val start = spannable.getSpanStart(it)
val end = spannable.getSpanEnd(it)
Pair(start, end)
}
// Find and set as URLSpans any links present in the text
val addedNewLinks = LinkifyCompat.addLinks(spannable, linkifyMask)
// Process newly added URL spans
if (addedNewLinks) {
val newUrlSpans = spannable.getSpans<URLSpan>(0, spannable.length)
for (urlSpan in newUrlSpans) {
val start = spannable.getSpanStart(urlSpan)
val end = spannable.getSpanEnd(urlSpan)
// Try to avoid including trailing punctuation in the link.
// Since this might fail in some edge cases, we catch the exception and just use the original end index.
val newEnd = runCatchingExceptions {
adjustLinkifiedUrlSpanEndIndex(spannable, start, end)
}.onFailure {
Timber.e(it, "Failed to adjust end index for link span")
}.getOrNull() ?: end
// Adapt the url in the URL span to the new end index too if needed
if (end != newEnd) {
val url = spannable.subSequence(start, newEnd).toString()
spannable.removeSpan(urlSpan)
spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} else {
spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
// Restore old spans, remove new ones if there is a conflict
for ((urlSpan, location) in oldURLSpans) {
val (start, end) = location
val addedConflictingSpans = spannable.getSpans<URLSpan>(start, end)
if (addedConflictingSpans.isNotEmpty()) {
for (span in addedConflictingSpans) {
spannable.removeSpan(span)
}
}
spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return spannable
}
private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int {
var end = end
// Trailing punctuation found, adjust the end index
while (spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '…') && end > start) {
end--
}
// If the last character is a closing parenthesis, check if it's part of a pair
if (spannable[end - 1] == ')' && end > start) {
val linkifiedTextLastPath = spannable.substring(start, end).substringAfterLast('/')
val closingParenthesisCount = linkifiedTextLastPath.count { it == ')' }
val openingParenthesisCount = linkifiedTextLastPath.count { it == '(' }
// If it's not part of a pair, remove it from the link span by adjusting the end index
end -= closingParenthesisCount - openingParenthesisCount
}
return end
}
}
/**
* Linkify the text with the default mask (WEB_URLS, PHONE_NUMBERS, EMAIL_ADDRESSES).
*/
fun CharSequence.safeLinkify(): CharSequence {
return LinkifyHelper.linkify(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
}

View File

@@ -0,0 +1,35 @@
/*
* 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.androidutils.throttler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/**
* Simple ThrottleFirst
* See https://raw.githubusercontent.com/wiki/ReactiveX/RxJava/images/rx-operators/throttleFirst.png
*/
class FirstThrottler(
private val minimumInterval: Long = 800,
private val coroutineScope: CoroutineScope,
) {
private val canHandle = AtomicBoolean(true)
fun canHandle(): Boolean {
return canHandle.getAndSet(false).also { result ->
if (result) {
coroutineScope.launch {
delay(minimumInterval)
canHandle.set(true)
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.androidutils.ui
import android.os.Build
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowInsets
import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
fun View.hideKeyboard() {
val imm = context?.getSystemService<InputMethodManager>()
imm?.hideSoftInputFromWindow(windowToken, 0)
}
fun View.showKeyboard(andRequestFocus: Boolean = false) {
if (andRequestFocus) {
requestFocus()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
windowInsetsController?.show(WindowInsets.Type.ime())
} else {
val imm = context?.getSystemService<InputMethodManager>()
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
}
fun View.isKeyboardVisible(): Boolean {
val imm = context?.getSystemService<InputMethodManager>()
return imm?.isAcceptingText == true
}
suspend fun View.awaitWindowFocus() = suspendCancellableCoroutine { continuation ->
if (hasWindowFocus()) {
continuation.resume(Unit)
} else {
val listener = object : ViewTreeObserver.OnWindowFocusChangeListener {
override fun onWindowFocusChanged(hasFocus: Boolean) {
if (hasFocus) {
viewTreeObserver.removeOnWindowFocusChangeListener(this)
continuation.resume(Unit)
}
}
}
viewTreeObserver.addOnWindowFocusChangeListener(listener)
continuation.invokeOnCancellation {
viewTreeObserver.removeOnWindowFocusChangeListener(listener)
}
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2021-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.androidutils.uri
import android.net.Uri
import androidx.core.net.toUri
const val IGNORED_SCHEMA = "ignored"
fun createIgnoredUri(path: String): Uri = "$IGNORED_SCHEMA://$path".toUri()

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Не знойдзена сумяшчальная праграма для выканання гэтага дзеяння."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nebyla nalezena žádná kompatibilní aplikace, která by tuto akci zpracovala."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Heb ganfod ap cydnaws i drin y weithred hon."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Der blev ikke fundet nogen kompatibel app til at håndtere denne handling."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Für diese Aktion wurde keine kompatible App gefunden."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Δεν βρέθηκε συμβατή εφαρμογή για να χειριστεί αυτήν την ενέργεια."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"No se encontró ninguna aplicación compatible con esta acción."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Ei õnnestunud leida selle tegevuse jaoks vajalikku välist rakendust."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Ez da ekintza hau kudeatzeko aplikazio bateragarririk aurkitu."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"هیچ برنامه سازگاری برای انجام این عمل یافت نشد."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Yhteensopivaa sovellusta ei löytynyt käsittelemään tätä toimintoa."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Aucune application compatible na été trouvée pour gérer cette action."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nem található kompatibilis alkalmazás a művelet kezeléséhez."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Tidak ada aplikasi yang kompatibel yang ditemukan untuk menangani tindakan ini."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Non è stata trovata alcuna app compatibile per gestire questa azione."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"თავსებადი აპლიკაცია ვერ მოიძებნა ამ მოქმედების შესასრულებლად."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"이 동작을 수행할 수 있는 앱을 찾지 못했습니다."</string>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2022 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.
-->
<resources>
<integer name="rtl_x_multiplier">-1</integer>
<integer name="rtl_mirror_flip">180</integer>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nerasta suderinamos programos, kuri galėtų atlikti šį veiksmą."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Ingen kompatibel app ble funnet for å håndtere denne handlingen."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Er is geen compatibele app gevonden om deze actie uit te voeren."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nie znaleziono kompatybilnej aplikacji do obsługi tej akcji."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nenhum aplicativo compatível foi encontrado para lidar com essa ação."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nenhuma aplicação encontrada capaz de continuar esta ação."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Не найдено совместимое приложение для обработки этого действия."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nebola nájdená žiadna kompatibilná aplikácia, ktorá by túto akciu dokázala spracovať."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Ingen kompatibel app hittades för att hantera den här åtgärden."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Bu eylemi gerçekleştirecek uyumlu bir uygulama bulunamadı."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Не знайдено сумісного застосунку для виконання цієї дії."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"اس کارروائی کو سنبھالنے کے لیے کوئی مطابقت پذیر اطلاقیہ نہیں ملا۔"</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Bu amalni bajarish uchun mos ilova topilmadi."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"找不到相容的應用程式來執行此動作。"</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"找不到完成此项操作的合适应用。"</string>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2022 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.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<integer name="rtl_x_multiplier" tools:ignore="UnusedResources">1</integer>
<integer name="rtl_mirror_flip" tools:ignore="UnusedResources">0</integer>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"No compatible app was found to handle this action."</string>
</resources>

View File

@@ -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.androidutils.filesize
import android.os.Build
import com.google.common.truth.Truth.assertThat
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class AndroidFileSizeFormatterTest {
@Test
fun `test api 24 long format`() {
val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.N)
assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1.00B")
assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98KB")
assertThat(sut.format(1024, useShortFormat = false)).isEqualTo("1.00KB")
assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("1.00MB")
assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("1.00GB")
}
@Test
fun `test api 26 long format`() {
val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.O)
assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1.00B")
assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98KB")
assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("0.95MB")
assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("0.93GB")
}
@Test
fun `test api 24 short format`() {
val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.N)
assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1.0B")
assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98KB")
assertThat(sut.format(1024, useShortFormat = true)).isEqualTo("1.0KB")
assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("1.0MB")
assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("1.0GB")
}
@Test
fun `test api 26 short format`() {
val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.O)
assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1.0B")
assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98KB")
assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("0.95MB")
assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("0.93GB")
}
private fun createAndroidFileSizeFormatter(sdkLevel: Int) = AndroidFileSizeFormatter(
context = RuntimeEnvironment.getApplication(),
sdkIntProvider = FakeBuildVersionSdkIntProvider(sdkInt = sdkLevel)
)
}

View File

@@ -0,0 +1,80 @@
/*
* 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.androidutils.media
import android.util.Size
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class VideoCompressorHelperTest {
@Test
fun `test getOutputSize`() {
val helper = VideoCompressorHelper(maxSize = 720)
// Landscape input
var inputSize = Size(1920, 1080)
var outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(720, 405))
// Landscape input small height
inputSize = Size(1920, 200)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(720, 75))
// Portrait input
inputSize = Size(1080, 1920)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(405, 720))
// Portrait input small width
inputSize = Size(200, 1920)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(75, 720))
// Square input
inputSize = Size(1000, 1000)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(720, 720))
// Square input same size
inputSize = Size(720, 720)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(720, 720))
// Square input no downscaling
inputSize = Size(240, 240)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(240, 240))
// Small input landscape (no downscaling)
inputSize = Size(640, 480)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(640, 480))
// Small input portrait (no downscaling)
inputSize = Size(480, 640)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(480, 640))
}
@Test
fun `test calculateOptimalBitrate`() {
val helper = VideoCompressorHelper(maxSize = 720)
val inputSize = Size(1920, 1080)
var bitrate = helper.calculateOptimalBitrate(inputSize, frameRate = 30)
// Output size will be 720x405, so bitrate = 720*405*0.1*30 = 874800
assertThat(bitrate).isEqualTo(874_800L)
// Half frame rate, half bitrate
bitrate = helper.calculateOptimalBitrate(inputSize, frameRate = 15)
assertThat(bitrate).isEqualTo(437_400L)
}
}

View File

@@ -0,0 +1,125 @@
/*
* 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.androidutils.text
import android.telephony.TelephonyManager
import android.text.style.URLSpan
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.WarmUpRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.shadow.api.Shadow.newInstanceOf
@RunWith(RobolectricTestRunner::class)
class LinkifierHelperTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `linkification finds URL`() {
val text = "A url https://matrix.org"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("https://matrix.org")
}
@Test
fun `linkification finds partial URL`() {
val text = "A partial url matrix.org/test"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("http://matrix.org/test")
}
@Test
fun `linkification finds domain`() {
val text = "A domain matrix.org"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("http://matrix.org")
}
@Test
fun `linkification finds email`() {
val text = "An email address john@doe.com"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("mailto:john@doe.com")
}
@Test
@Config(sdk = [30])
fun `linkification finds phone`() {
val text = "Test phone number +34950123456"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("tel:+34950123456")
}
@Test
@Config(sdk = [30])
fun `linkification finds phone in Germany`() {
// For some reason the linkification of phone numbers in Germany is very lenient and any number will fit here
val telephonyManager = shadowOf(newInstanceOf(TelephonyManager::class.java))
telephonyManager.setSimCountryIso("DE")
val text = "Test phone number 1234"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("tel:1234")
}
@Test
fun `linkification handles trailing dot`() {
val text = "A url https://matrix.org."
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("https://matrix.org")
}
@Test
fun `linkification handles trailing punctuation`() {
val text = "A url https://matrix.org!?; Check it out!"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("https://matrix.org")
}
@Test
fun `linkification handles parenthesis surrounding URL`() {
val text = "A url (this one (https://github.com/element-hq/element-android/issues/1234))"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/issues/1234")
}
@Test
fun `linkification handles parenthesis in URL`() {
val text = "A url: (https://github.com/element-hq/element-android/READ(ME))"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/READ(ME)")
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.androidutils.throttler
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test
class FirstThrottlerTest {
@Test
fun `throttle canHandle returns the expected result`() = runTest {
val throttler = FirstThrottler(
minimumInterval = 300,
coroutineScope = backgroundScope,
)
assertThat(throttler.canHandle()).isTrue()
assertThat(throttler.canHandle()).isFalse()
advanceTimeBy(200)
assertThat(throttler.canHandle()).isFalse()
advanceTimeBy(110)
assertThat(throttler.canHandle()).isTrue()
}
}