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")