First Commit
This commit is contained in:
1
libraries/androidutils/.gitignore
vendored
Normal file
1
libraries/androidutils/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
42
libraries/androidutils/build.gradle.kts
Normal file
42
libraries/androidutils/build.gradle.kts
Normal 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)
|
||||
}
|
||||
0
libraries/androidutils/consumer-rules.pro
Normal file
0
libraries/androidutils/consumer-rules.pro
Normal file
11
libraries/androidutils/src/main/AndroidManifest.xml
Normal file
11
libraries/androidutils/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 n’a été trouvée pour gérer cette action."</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
13
libraries/androidutils/src/main/res/values/integers.xml
Normal file
13
libraries/androidutils/src/main/res/values/integers.xml
Normal 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>
|
||||
4
libraries/androidutils/src/main/res/values/localazy.xml
Normal file
4
libraries/androidutils/src/main/res/values/localazy.xml
Normal 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>
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user