diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 68b8dbc8af..3d81c3798a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -16,7 +16,7 @@ datastore = "1.0.0"
constraintlayout = "2.1.4"
recyclerview = "1.3.0"
lifecycle = "2.5.1"
-activity_compose = "1.6.1"
+activity = "1.6.1"
startup = "1.1.1"
# Compose
@@ -70,7 +70,8 @@ androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process",
androidx_splash = "androidx.core:core-splashscreen:1.0.0"
androidx_security_crypto = "androidx.security:security-crypto:1.0.0"
-androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" }
+androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
+androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" }
androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts
index 13da553bbf..8bc0d8e421 100644
--- a/libraries/androidutils/build.gradle.kts
+++ b/libraries/androidutils/build.gradle.kts
@@ -26,5 +26,6 @@ android {
dependencies {
implementation(libs.timber)
implementation(libs.androidx.corektx)
+ implementation(libs.androidx.activity.activity)
implementation(projects.libraries.core)
}
diff --git a/libraries/androidutils/src/main/AndroidManifest.xml b/libraries/androidutils/src/main/AndroidManifest.xml
index 5a19e495ae..8b1ccda517 100644
--- a/libraries/androidutils/src/main/AndroidManifest.xml
+++ b/libraries/androidutils/src/main/AndroidManifest.xml
@@ -17,4 +17,5 @@
+
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt
new file mode 100644
index 0000000000..3b95a10eba
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.androidutils.compat
+
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.os.Build
+
+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 -> @Suppress("DEPRECATION") getApplicationInfo(packageName, flags)
+ }
+}
+
+fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int): PackageInfo {
+ return when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getPackageInfo(
+ packageName,
+ PackageManager.PackageInfoFlags.of(flags.toLong())
+ )
+ else -> @Suppress("DEPRECATION") getPackageInfo(packageName, flags)
+ }
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt
new file mode 100644
index 0000000000..dcdb800a19
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.androidutils.intent
+
+import android.app.PendingIntent
+import android.os.Build
+
+object PendingIntentCompat {
+ const val FLAG_IMMUTABLE = PendingIntent.FLAG_IMMUTABLE
+
+ val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_MUTABLE
+ } else {
+ 0
+ }
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/WifiDetector.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/WifiDetector.kt
new file mode 100644
index 0000000000..85b17c6ff8
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/WifiDetector.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.androidutils.network
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import androidx.core.content.getSystemService
+import io.element.android.libraries.core.bool.orFalse
+import timber.log.Timber
+
+class WifiDetector(
+ context: Context
+) {
+ private val connectivityManager = context.getSystemService()!!
+
+ fun isConnectedToWifi(): Boolean {
+ return connectivityManager.activeNetwork
+ ?.let { connectivityManager.getNetworkCapabilities(it) }
+ ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
+ .orFalse()
+ .also { Timber.d("isConnected to WiFi: $it") }
+ }
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt
new file mode 100644
index 0000000000..3eaa907303
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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()
+ ?.setPrimaryClip(ClipData.newPlainText("", text))
+ }
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt
new file mode 100644
index 0000000000..7c2da46e0d
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2018 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.androidutils.system
+
+import android.annotation.TargetApi
+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.os.PowerManager
+import android.provider.Settings
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.annotation.RequiresApi
+import androidx.core.content.getSystemService
+import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat
+
+/**
+ * Tells if the application ignores battery optimizations.
+ *
+ * Ignoring them allows the app to run in background to make background sync with the homeserver.
+ * This user option appears on Android M but Android O enforces its usage and kills apps not
+ * authorised by the user to run in background.
+ *
+ * @return true if battery optimisations are ignored
+ */
+fun Context.isIgnoringBatteryOptimizations(): Boolean {
+ // no issue before Android M, battery optimisations did not exist
+ return getSystemService()?.isIgnoringBatteryOptimizations(packageName) == true
+}
+
+fun Context.isAirplaneModeOn(): Boolean {
+ return Settings.Global.getInt(contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0) != 0
+}
+
+fun Context.isAnimationEnabled(): Boolean {
+ return Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) != 0f
+}
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
+fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+
+/**
+ * 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
+ }
+}
+
+/**
+ * display the system dialog for granting this permission. If previously granted, the
+ * system will not show it (so you should call this method).
+ *
+ * Note: If the user finally does not grant the permission, PushManager.isBackgroundSyncAllowed()
+ * will return false and the notification privacy will fallback to "LOW_DETAIL".
+ */
+fun requestDisablingBatteryOptimization(activity: Activity, activityResultLauncher: ActivityResultLauncher) {
+ val intent = Intent()
+ intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
+ intent.data = Uri.parse("package:" + activity.packageName)
+ activityResultLauncher.launch(intent)
+}
+
+// ==============================================================================================================
+// Clipboard helper
+// ==============================================================================================================
+
+/**
+ * Copy a text to the clipboard, and display a Toast when done.
+ *
+ * @param context the context
+ * @param text the text to copy
+ * @param toastMessage content of the toast message as a String resource. Null for no toast
+ */
+fun copyToClipboard(
+ context: Context,
+ text: CharSequence,
+ toastMessage: String? = null
+) {
+ CopyToClipboardUseCase(context).execute(text)
+ toastMessage?.let { context.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 startNotificationSettingsIntent(context: Context, activityResultLauncher: ActivityResultLauncher) {
+ val intent = Intent()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
+ intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ } else {
+ intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
+ intent.putExtra("app_package", context.packageName)
+ intent.putExtra("app_uid", context.applicationInfo?.uid)
+ }
+ activityResultLauncher.launch(intent)
+}
+
+/**
+ * Shows notification system settings for the given channel id.
+ */
+@TargetApi(Build.VERSION_CODES.O)
+fun startNotificationChannelSettingsIntent(activity: Activity, channelID: String) {
+ if (!supportNotificationChannels()) return
+ val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)
+ putExtra(Settings.EXTRA_CHANNEL_ID, channelID)
+ }
+ activity.startActivity(intent)
+}
+
+fun startAddGoogleAccountIntent(
+ context: Context,
+ activityResultLauncher: ActivityResultLauncher,
+ noActivityFoundMessage: String,
+) {
+ val intent = Intent(Settings.ACTION_ADD_ACCOUNT)
+ intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google"))
+ try {
+ activityResultLauncher.launch(intent)
+ } catch (activityNotFoundException: ActivityNotFoundException) {
+ context.toast(noActivityFoundMessage)
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+fun startInstallFromSourceIntent(
+ context: Context,
+ activityResultLauncher: ActivityResultLauncher,
+ noActivityFoundMessage: String,
+) {
+ val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
+ .setData(Uri.parse(String.format("package:%s", context.packageName)))
+ try {
+ activityResultLauncher.launch(intent)
+ } catch (activityNotFoundException: ActivityNotFoundException) {
+ context.toast(noActivityFoundMessage)
+ }
+}
+
+fun startSharePlainTextIntent(
+ context: Context,
+ activityResultLauncher: ActivityResultLauncher?,
+ chooserTitle: String?,
+ text: String,
+ subject: String? = null,
+ extraTitle: String? = null,
+ noActivityFoundMessage: String,
+) {
+ val share = Intent(Intent.ACTION_SEND)
+ share.type = "text/plain"
+ 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 {
+ context.startActivity(intent)
+ }
+ } catch (activityNotFoundException: ActivityNotFoundException) {
+ context.toast(noActivityFoundMessage)
+ }
+}
+
+fun startImportTextFromFileIntent(
+ context: Context,
+ activityResultLauncher: ActivityResultLauncher,
+ noActivityFoundMessage: String,
+) {
+ val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
+ type = "text/plain"
+ }
+ try {
+ activityResultLauncher.launch(intent)
+ } catch (activityNotFoundException: ActivityNotFoundException) {
+ context.toast(noActivityFoundMessage)
+ }
+}
+
+// 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()
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt
new file mode 100644
index 0000000000..7f3de04c6a
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.element.android.libraries.androidutils.throttler
+
+import android.os.SystemClock
+
+/**
+ * Simple ThrottleFirst
+ * See https://raw.githubusercontent.com/wiki/ReactiveX/RxJava/images/rx-operators/throttleFirst.png
+ */
+class FirstThrottler(private val minimumInterval: Long = 800) {
+ private var lastDate = 0L
+
+ sealed class CanHandlerResult {
+ object Yes : CanHandlerResult()
+ data class No(val shouldWaitMillis: Long) : CanHandlerResult()
+
+ fun waitMillis(): Long {
+ return when (this) {
+ Yes -> 0
+ is No -> shouldWaitMillis
+ }
+ }
+ }
+
+ fun canHandle(): CanHandlerResult {
+ val now = SystemClock.elapsedRealtime()
+ val delaySinceLast = now - lastDate
+ if (delaySinceLast > minimumInterval) {
+ lastDate = now
+ return CanHandlerResult.Yes
+ }
+
+ // Too soon
+ return CanHandlerResult.No(minimumInterval - delaySinceLast)
+ }
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt
new file mode 100644
index 0000000000..485a103b5b
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.androidutils.uri
+
+import android.net.Uri
+
+const val IGNORED_SCHEMA = "ignored"
+
+fun Uri.isIgnored() = scheme == IGNORED_SCHEMA
+
+fun createIgnoredUri(path: String): Uri = Uri.parse("$IGNORED_SCHEMA://$path")