aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/main/java/com/wireguard/android/util
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/util')
-rw-r--r--ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt17
-rw-r--r--ui/src/main/java/com/wireguard/android/util/AsyncWorker.kt43
-rw-r--r--ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt69
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt8
-rw-r--r--ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt98
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt102
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.kt27
-rw-r--r--ui/src/main/java/com/wireguard/android/util/Extensions.kt20
-rw-r--r--ui/src/main/java/com/wireguard/android/util/FragmentUtils.kt21
-rw-r--r--ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt84
-rw-r--r--ui/src/main/java/com/wireguard/android/util/QuantityFormatter.kt66
-rw-r--r--ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt152
-rw-r--r--ui/src/main/java/com/wireguard/android/util/UserKnobs.kt121
13 files changed, 616 insertions, 212 deletions
diff --git a/ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt b/ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt
new file mode 100644
index 00000000..2c23910b
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util
+
+import android.content.RestrictionsManager
+import androidx.core.content.getSystemService
+import com.wireguard.android.Application
+
+object AdminKnobs {
+ private val restrictions: RestrictionsManager? = Application.get().getSystemService()
+ val disableConfigExport: Boolean
+ get() = restrictions?.applicationRestrictions?.getBoolean("disable_config_export", false)
+ ?: false
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/AsyncWorker.kt b/ui/src/main/java/com/wireguard/android/util/AsyncWorker.kt
deleted file mode 100644
index a6e5d4be..00000000
--- a/ui/src/main/java/com/wireguard/android/util/AsyncWorker.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright © 2017-2020 WireGuard LLC. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-package com.wireguard.android.util
-
-import android.os.Handler
-import java9.util.concurrent.CompletableFuture
-import java9.util.concurrent.CompletionStage
-import java.util.concurrent.Executor
-
-/**
- * Helper class for running asynchronous tasks and ensuring they are completed on the main thread.
- */
-
-class AsyncWorker(private val executor: Executor, private val handler: Handler) {
-
- fun runAsync(run: () -> Unit): CompletionStage<Void> {
- val future = CompletableFuture<Void>()
- executor.execute {
- try {
- run()
- handler.post { future.complete(null) }
- } catch (t: Throwable) {
- handler.post { future.completeExceptionally(t) }
- }
- }
- return future
- }
-
- fun <T> supplyAsync(get: () -> T?): CompletionStage<T> {
- val future = CompletableFuture<T>()
- executor.execute {
- try {
- val result = get()
- handler.post { future.complete(result) }
- } catch (t: Throwable) {
- handler.post { future.completeExceptionally(t) }
- }
- }
- return future
- }
-}
diff --git a/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt b/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt
index aed8a4f2..064ea04d 100644
--- a/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt
+++ b/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt
@@ -1,28 +1,27 @@
/*
- * Copyright © 2020 WireGuard LLC. All Rights Reserved.
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
-import android.annotation.SuppressLint
-import android.app.KeyguardManager
-import android.content.Context
-import android.os.Build
import android.os.Handler
+import android.os.Looper
import android.util.Log
import androidx.annotation.StringRes
-import androidx.biometric.BiometricConstants
import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricManager.Authenticators
import androidx.biometric.BiometricPrompt
-import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import com.wireguard.android.R
object BiometricAuthenticator {
private const val TAG = "WireGuard/BiometricAuthenticator"
- private val handler = Handler()
+
+ // Not all devices support strong biometric auth so we're allowing both device credentials as
+ // well as weak biometrics.
+ private const val allowedAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
sealed class Result {
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
@@ -31,40 +30,30 @@ object BiometricAuthenticator {
object Cancelled : Result()
}
- @SuppressLint("PrivateApi")
- private fun isPinEnabled(context: Context): Boolean {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
- return context.getSystemService<KeyguardManager>()!!.isDeviceSecure
- return try {
- val lockUtilsClass = Class.forName("com.android.internal.widget.LockPatternUtils")
- val lockUtils = lockUtilsClass.getConstructor(Context::class.java).newInstance(context)
- val method = lockUtilsClass.getMethod("isLockScreenDisabled")
- !(method.invoke(lockUtils) as Boolean)
- } catch (e: Exception) {
- false
- }
- }
-
fun authenticate(
- @StringRes dialogTitleRes: Int,
- fragment: Fragment,
- callback: (Result) -> Unit
+ @StringRes dialogTitleRes: Int,
+ fragment: Fragment,
+ callback: (Result) -> Unit
) {
val authCallback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
Log.d(TAG, "BiometricAuthentication error: errorCode=$errorCode, msg=$errString")
- callback(when (errorCode) {
- BiometricConstants.ERROR_CANCELED, BiometricConstants.ERROR_USER_CANCELED,
- BiometricConstants.ERROR_NEGATIVE_BUTTON -> {
- Result.Cancelled
- }
- BiometricConstants.ERROR_HW_NOT_PRESENT, BiometricConstants.ERROR_HW_UNAVAILABLE,
- BiometricConstants.ERROR_NO_BIOMETRICS, BiometricConstants.ERROR_NO_DEVICE_CREDENTIAL -> {
- Result.HardwareUnavailableOrDisabled
+ callback(
+ when (errorCode) {
+ BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED,
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
+ Result.Cancelled
+ }
+
+ BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE,
+ BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
+ Result.HardwareUnavailableOrDisabled
+ }
+
+ else -> Result.Failure(errorCode, fragment.getString(R.string.biometric_auth_error_reason, errString))
}
- else -> Result.Failure(errorCode, fragment.getString(R.string.biometric_auth_error_reason, errString))
- })
+ )
}
override fun onAuthenticationFailed() {
@@ -77,12 +66,12 @@ object BiometricAuthenticator {
callback(Result.Success(result.cryptoObject))
}
}
- val biometricPrompt = BiometricPrompt(fragment, { handler.post(it) }, authCallback)
+ val biometricPrompt = BiometricPrompt(fragment, { Handler(Looper.getMainLooper()).post(it) }, authCallback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
- .setTitle(fragment.getString(dialogTitleRes))
- .setDeviceCredentialAllowed(true)
- .build()
- if (BiometricManager.from(fragment.requireContext()).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS || isPinEnabled(fragment.requireContext())) {
+ .setTitle(fragment.getString(dialogTitleRes))
+ .setAllowedAuthenticators(allowedAuthenticators)
+ .build()
+ if (BiometricManager.from(fragment.requireContext()).canAuthenticate(allowedAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS) {
biometricPrompt.authenticate(promptInfo)
} else {
callback(Result.HardwareUnavailableOrDisabled)
diff --git a/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt
index 51f6486f..8968979f 100644
--- a/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt
+++ b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt
@@ -1,16 +1,18 @@
/*
- * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.content.ClipData
import android.content.ClipboardManager
+import android.os.Build
import android.view.View
import android.widget.TextView
import androidx.core.content.getSystemService
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
+import com.wireguard.android.R
/**
* Standalone utilities for interacting with the system clipboard.
@@ -28,6 +30,8 @@ object ClipboardUtils {
}
val service = view.context.getSystemService<ClipboardManager>() ?: return
service.setPrimaryClip(ClipData.newPlainText(data.second, data.first))
- Snackbar.make(view, "${data.second} copied to clipboard", Snackbar.LENGTH_LONG).show()
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ Snackbar.make(view, view.context.getString(R.string.copied_to_clipboard, data.second), Snackbar.LENGTH_LONG).show()
+ }
}
}
diff --git a/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt
index a0f0e1fb..f78094b6 100644
--- a/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt
+++ b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt
@@ -1,68 +1,100 @@
/*
- * Copyright © 2019 WireGuard LLC. All Rights Reserved.
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
+import android.Manifest
import android.content.ContentValues
import android.content.Context
+import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
import com.wireguard.android.R
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
-object DownloadsFileSaver {
- @Throws(Exception::class)
- fun save(context: Context, name: String, mimeType: String?, overwriteExisting: Boolean) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- val contentResolver = context.contentResolver
- if (overwriteExisting)
- contentResolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, String.format("%s = ?", MediaColumns.DISPLAY_NAME), arrayOf(name))
- val contentValues = ContentValues()
- contentValues.put(MediaColumns.DISPLAY_NAME, name)
- contentValues.put(MediaColumns.MIME_TYPE, mimeType)
- val contentUri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
+class DownloadsFileSaver(private val context: ComponentActivity) {
+ private lateinit var activityResult: ActivityResultLauncher<String>
+ private lateinit var futureGrant: CompletableDeferred<Boolean>
+
+ init {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ futureGrant = CompletableDeferred()
+ activityResult = context.registerForActivityResult(ActivityResultContracts.RequestPermission()) { ret -> futureGrant.complete(ret) }
+ }
+ }
+
+ suspend fun save(name: String, mimeType: String?, overwriteExisting: Boolean) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ withContext(Dispatchers.IO) {
+ val contentResolver = context.contentResolver
+ if (overwriteExisting)
+ contentResolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, String.format("%s = ?", MediaColumns.DISPLAY_NAME), arrayOf(name))
+ val contentValues = ContentValues()
+ contentValues.put(MediaColumns.DISPLAY_NAME, name)
+ contentValues.put(MediaColumns.MIME_TYPE, mimeType)
+ val contentUri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
?: throw IOException(context.getString(R.string.create_downloads_file_error))
- val contentStream = contentResolver.openOutputStream(contentUri)
+ val contentStream = contentResolver.openOutputStream(contentUri)
?: throw IOException(context.getString(R.string.create_downloads_file_error))
- @Suppress("DEPRECATION") var cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DATA), null, null, null)
- var path: String? = null
- if (cursor != null) {
- try {
- if (cursor.moveToFirst())
- path = cursor.getString(0)
- } finally {
- cursor.close()
- }
- }
- if (path == null) {
- path = "Download/"
- cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DISPLAY_NAME), null, null, null)
+ @Suppress("DEPRECATION") var cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DATA), null, null, null)
+ var path: String? = null
if (cursor != null) {
try {
if (cursor.moveToFirst())
- path += cursor.getString(0)
+ path = cursor.getString(0)
} finally {
cursor.close()
}
}
+ if (path == null) {
+ path = "Download/"
+ cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DISPLAY_NAME), null, null, null)
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst())
+ path += cursor.getString(0)
+ } finally {
+ cursor.close()
+ }
+ }
+ }
+ DownloadsFile(context, contentStream, path, contentUri)
}
- DownloadsFile(context, contentStream, path, contentUri)
} else {
- @Suppress("DEPRECATION") val path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
- val file = File(path, name)
- if (!path.isDirectory && !path.mkdirs())
- throw IOException(context.getString(R.string.create_output_dir_error))
- DownloadsFile(context, FileOutputStream(file), file.absolutePath, null)
+ withContext(Dispatchers.Main.immediate) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+ activityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ val granted = futureGrant.await()
+ if (!granted) {
+ futureGrant = CompletableDeferred()
+ return@withContext null
+ }
+ }
+ @Suppress("DEPRECATION") val path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ withContext(Dispatchers.IO) {
+ val file = File(path, name)
+ if (!path.isDirectory && !path.mkdirs())
+ throw IOException(context.getString(R.string.create_output_dir_error))
+ DownloadsFile(context, FileOutputStream(file), file.absolutePath, null)
+ }
+ }
}
class DownloadsFile(private val context: Context, val outputStream: OutputStream, val fileName: String, private val uri: Uri?) {
- fun delete() {
+ suspend fun delete() = withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
context.contentResolver.delete(uri!!, null, null)
else
diff --git a/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt
index d8ac94d9..4157ebf2 100644
--- a/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt
+++ b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt
@@ -1,12 +1,14 @@
/*
- * Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.content.res.Resources
import android.os.RemoteException
-import com.wireguard.android.Application.Companion.get
+import com.google.zxing.ChecksumException
+import com.google.zxing.NotFoundException
+import com.wireguard.android.Application
import com.wireguard.android.R
import com.wireguard.android.backend.BackendException
import com.wireguard.android.util.RootShell.RootShellException
@@ -20,50 +22,51 @@ import java.net.InetAddress
object ErrorMessages {
private val BCE_REASON_MAP = mapOf(
- BadConfigException.Reason.INVALID_KEY to R.string.bad_config_reason_invalid_key,
- BadConfigException.Reason.INVALID_NUMBER to R.string.bad_config_reason_invalid_number,
- BadConfigException.Reason.INVALID_VALUE to R.string.bad_config_reason_invalid_value,
- BadConfigException.Reason.MISSING_ATTRIBUTE to R.string.bad_config_reason_missing_attribute,
- BadConfigException.Reason.MISSING_SECTION to R.string.bad_config_reason_missing_section,
- BadConfigException.Reason.SYNTAX_ERROR to R.string.bad_config_reason_syntax_error,
- BadConfigException.Reason.UNKNOWN_ATTRIBUTE to R.string.bad_config_reason_unknown_attribute,
- BadConfigException.Reason.UNKNOWN_SECTION to R.string.bad_config_reason_unknown_section
+ BadConfigException.Reason.INVALID_KEY to R.string.bad_config_reason_invalid_key,
+ BadConfigException.Reason.INVALID_NUMBER to R.string.bad_config_reason_invalid_number,
+ BadConfigException.Reason.INVALID_VALUE to R.string.bad_config_reason_invalid_value,
+ BadConfigException.Reason.MISSING_ATTRIBUTE to R.string.bad_config_reason_missing_attribute,
+ BadConfigException.Reason.MISSING_SECTION to R.string.bad_config_reason_missing_section,
+ BadConfigException.Reason.SYNTAX_ERROR to R.string.bad_config_reason_syntax_error,
+ BadConfigException.Reason.UNKNOWN_ATTRIBUTE to R.string.bad_config_reason_unknown_attribute,
+ BadConfigException.Reason.UNKNOWN_SECTION to R.string.bad_config_reason_unknown_section
)
private val BE_REASON_MAP = mapOf(
- BackendException.Reason.UNKNOWN_KERNEL_MODULE_NAME to R.string.module_version_error,
- BackendException.Reason.WG_QUICK_CONFIG_ERROR_CODE to R.string.tunnel_config_error,
- BackendException.Reason.TUNNEL_MISSING_CONFIG to R.string.no_config_error,
- BackendException.Reason.VPN_NOT_AUTHORIZED to R.string.vpn_not_authorized_error,
- BackendException.Reason.UNABLE_TO_START_VPN to R.string.vpn_start_error,
- BackendException.Reason.TUN_CREATION_ERROR to R.string.tun_create_error,
- BackendException.Reason.GO_ACTIVATION_ERROR_CODE to R.string.tunnel_on_error
+ BackendException.Reason.UNKNOWN_KERNEL_MODULE_NAME to R.string.module_version_error,
+ BackendException.Reason.WG_QUICK_CONFIG_ERROR_CODE to R.string.tunnel_config_error,
+ BackendException.Reason.TUNNEL_MISSING_CONFIG to R.string.no_config_error,
+ BackendException.Reason.VPN_NOT_AUTHORIZED to R.string.vpn_not_authorized_error,
+ BackendException.Reason.UNABLE_TO_START_VPN to R.string.vpn_start_error,
+ BackendException.Reason.TUN_CREATION_ERROR to R.string.tun_create_error,
+ BackendException.Reason.GO_ACTIVATION_ERROR_CODE to R.string.tunnel_on_error,
+ BackendException.Reason.DNS_RESOLUTION_FAILURE to R.string.tunnel_dns_failure
)
private val KFE_FORMAT_MAP = mapOf(
- Key.Format.BASE64 to R.string.key_length_explanation_base64,
- Key.Format.BINARY to R.string.key_length_explanation_binary,
- Key.Format.HEX to R.string.key_length_explanation_hex
+ Key.Format.BASE64 to R.string.key_length_explanation_base64,
+ Key.Format.BINARY to R.string.key_length_explanation_binary,
+ Key.Format.HEX to R.string.key_length_explanation_hex
)
private val KFE_TYPE_MAP = mapOf(
- KeyFormatException.Type.CONTENTS to R.string.key_contents_error,
- KeyFormatException.Type.LENGTH to R.string.key_length_error
+ KeyFormatException.Type.CONTENTS to R.string.key_contents_error,
+ KeyFormatException.Type.LENGTH to R.string.key_length_error
)
private val PE_CLASS_MAP = mapOf(
- InetAddress::class.java to R.string.parse_error_inet_address,
- InetEndpoint::class.java to R.string.parse_error_inet_endpoint,
- InetNetwork::class.java to R.string.parse_error_inet_network,
- Int::class.java to R.string.parse_error_integer
+ InetAddress::class.java to R.string.parse_error_inet_address,
+ InetEndpoint::class.java to R.string.parse_error_inet_endpoint,
+ InetNetwork::class.java to R.string.parse_error_inet_network,
+ Int::class.java to R.string.parse_error_integer
)
private val RSE_REASON_MAP = mapOf(
- RootShellException.Reason.NO_ROOT_ACCESS to R.string.error_root,
- RootShellException.Reason.SHELL_MARKER_COUNT_ERROR to R.string.shell_marker_count_error,
- RootShellException.Reason.SHELL_EXIT_STATUS_READ_ERROR to R.string.shell_exit_status_read_error,
- RootShellException.Reason.SHELL_START_ERROR to R.string.shell_start_error,
- RootShellException.Reason.CREATE_BIN_DIR_ERROR to R.string.create_bin_dir_error,
- RootShellException.Reason.CREATE_TEMP_DIR_ERROR to R.string.create_temp_dir_error
+ RootShellException.Reason.NO_ROOT_ACCESS to R.string.error_root,
+ RootShellException.Reason.SHELL_MARKER_COUNT_ERROR to R.string.shell_marker_count_error,
+ RootShellException.Reason.SHELL_EXIT_STATUS_READ_ERROR to R.string.shell_exit_status_read_error,
+ RootShellException.Reason.SHELL_START_ERROR to R.string.shell_start_error,
+ RootShellException.Reason.CREATE_BIN_DIR_ERROR to R.string.create_bin_dir_error,
+ RootShellException.Reason.CREATE_TEMP_DIR_ERROR to R.string.create_temp_dir_error
)
operator fun get(throwable: Throwable?): String {
- val resources = get().resources
+ val resources = Application.get().resources
if (throwable == null) return resources.getString(R.string.unknown_error)
val rootCause = rootCause(throwable)
return when {
@@ -77,15 +80,27 @@ object ErrorMessages {
val explanation = getBadConfigExceptionExplanation(resources, rootCause)
resources.getString(R.string.bad_config_error, reason, context) + explanation
}
+
rootCause is BackendException -> {
resources.getString(BE_REASON_MAP.getValue(rootCause.reason), *rootCause.format)
}
+
rootCause is RootShellException -> {
resources.getString(RSE_REASON_MAP.getValue(rootCause.reason), *rootCause.format)
}
- rootCause.message != null -> {
- rootCause.message!!
+
+ rootCause is NotFoundException -> {
+ resources.getString(R.string.error_no_qr_found)
+ }
+
+ rootCause is ChecksumException -> {
+ resources.getString(R.string.error_qr_checksum)
+ }
+
+ rootCause.localizedMessage != null -> {
+ rootCause.localizedMessage!!
}
+
else -> {
val errorType = rootCause.javaClass.simpleName
resources.getString(R.string.generic_error, errorType)
@@ -93,14 +108,16 @@ object ErrorMessages {
}
}
- private fun getBadConfigExceptionExplanation(resources: Resources,
- bce: BadConfigException): String {
+ private fun getBadConfigExceptionExplanation(
+ resources: Resources,
+ bce: BadConfigException
+ ): String {
if (bce.cause is KeyFormatException) {
val kfe = bce.cause as KeyFormatException?
if (kfe!!.type == KeyFormatException.Type.LENGTH) return resources.getString(KFE_FORMAT_MAP.getValue(kfe.format))
} else if (bce.cause is ParseException) {
val pe = bce.cause as ParseException?
- if (pe!!.message != null) return ": ${pe.message}"
+ if (pe!!.localizedMessage != null) return ": ${pe.localizedMessage}"
} else if (bce.location == BadConfigException.Location.LISTEN_PORT) {
return resources.getString(R.string.bad_config_explanation_udp_port)
} else if (bce.location == BadConfigException.Location.MTU) {
@@ -111,8 +128,10 @@ object ErrorMessages {
return ""
}
- private fun getBadConfigExceptionReason(resources: Resources,
- bce: BadConfigException): String {
+ private fun getBadConfigExceptionReason(
+ resources: Resources,
+ bce: BadConfigException
+ ): String {
if (bce.cause is KeyFormatException) {
val kfe = bce.cause as KeyFormatException?
return resources.getString(KFE_TYPE_MAP.getValue(kfe!!.type))
@@ -128,7 +147,8 @@ object ErrorMessages {
var cause = throwable
while (cause.cause != null) {
if (cause is BadConfigException || cause is BackendException ||
- cause is RootShellException) break
+ cause is RootShellException
+ ) break
val nextCause = cause.cause!!
if (nextCause is RemoteException) break
cause = nextCause
diff --git a/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.kt b/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.kt
deleted file mode 100644
index 4470134c..00000000
--- a/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-package com.wireguard.android.util
-
-import android.util.Log
-import java9.util.function.BiConsumer
-
-/**
- * Helpers for logging exceptions from asynchronous tasks. These can be passed to
- * `CompletionStage.whenComplete()` at the end of an asynchronous future chain.
- */
-enum class ExceptionLoggers(private val priority: Int) : BiConsumer<Any?, Throwable?> {
- D(Log.DEBUG), E(Log.ERROR);
-
- override fun accept(result: Any?, throwable: Throwable?) {
- if (throwable != null)
- Log.println(Log.ERROR, TAG, Log.getStackTraceString(throwable))
- else if (priority <= Log.DEBUG)
- Log.println(priority, TAG, "Future completed successfully")
- }
-
- companion object {
- private const val TAG = "WireGuard/ExceptionLoggers"
- }
-}
diff --git a/ui/src/main/java/com/wireguard/android/util/Extensions.kt b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
index a705401f..c4b43951 100644
--- a/ui/src/main/java/com/wireguard/android/util/Extensions.kt
+++ b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
@@ -1,5 +1,5 @@
/*
- * Copyright © 2020 WireGuard LLC. All Rights Reserved.
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
@@ -8,7 +8,11 @@ package com.wireguard.android.util
import android.content.Context
import android.util.TypedValue
import androidx.annotation.AttrRes
-import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.Preference
+import com.wireguard.android.Application
+import com.wireguard.android.activity.SettingsActivity
+import kotlinx.coroutines.CoroutineScope
fun Context.resolveAttribute(@AttrRes attrRes: Int): Int {
val typedValue = TypedValue()
@@ -16,6 +20,12 @@ fun Context.resolveAttribute(@AttrRes attrRes: Int): Int {
return typedValue.data
}
-fun Fragment.requireTargetFragment(): Fragment {
- return requireNotNull(targetFragment) { "A target fragment should always be set for $this" }
-}
+val Any.applicationScope: CoroutineScope
+ get() = Application.getCoroutineScope()
+
+val Preference.activity: SettingsActivity
+ get() = context as? SettingsActivity
+ ?: throw IllegalStateException("Failed to resolve SettingsActivity")
+
+val Preference.lifecycleScope: CoroutineScope
+ get() = activity.lifecycleScope
diff --git a/ui/src/main/java/com/wireguard/android/util/FragmentUtils.kt b/ui/src/main/java/com/wireguard/android/util/FragmentUtils.kt
deleted file mode 100644
index 90e7ab0c..00000000
--- a/ui/src/main/java/com/wireguard/android/util/FragmentUtils.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-package com.wireguard.android.util
-
-import android.view.ContextThemeWrapper
-import androidx.preference.Preference
-import com.wireguard.android.activity.SettingsActivity
-
-object FragmentUtils {
- fun getPrefActivity(preference: Preference): SettingsActivity {
- val context = preference.context
- if (context is ContextThemeWrapper) {
- if (context is SettingsActivity) {
- return context
- }
- }
- throw IllegalStateException("Failed to resolve SettingsActivity")
- }
-}
diff --git a/ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt b/ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt
new file mode 100644
index 00000000..4ea2dc7a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util
+
+import android.content.ContentResolver
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.util.Log
+import com.google.zxing.BinaryBitmap
+import com.google.zxing.DecodeHintType
+import com.google.zxing.NotFoundException
+import com.google.zxing.RGBLuminanceSource
+import com.google.zxing.Reader
+import com.google.zxing.Result
+import com.google.zxing.common.HybridBinarizer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Encapsulates the logic of scanning a barcode from a file,
+ * @property contentResolver - Resolver to read the incoming data
+ * @property reader - An instance of zxing's [Reader] class to parse the image
+ */
+class QrCodeFromFileScanner(
+ private val contentResolver: ContentResolver,
+ private val reader: Reader,
+) {
+ private fun scanBitmapForResult(source: Bitmap): Result {
+ val width = source.width
+ val height = source.height
+ val pixels = IntArray(width * height)
+ source.getPixels(pixels, 0, width, 0, 0, width, height)
+
+ val bBitmap = BinaryBitmap(HybridBinarizer(RGBLuminanceSource(width, height, pixels)))
+ return reader.decode(bBitmap, mapOf(DecodeHintType.TRY_HARDER to true))
+ }
+
+ private fun doScan(data: Uri): Result {
+ Log.d(TAG, "Starting to scan an image: $data")
+ contentResolver.openInputStream(data).use { inputStream ->
+ var bitmap: Bitmap? = null
+ var firstException: Throwable? = null
+ for (i in arrayOf(1, 2, 4, 8, 16, 32, 64, 128)) {
+ try {
+ val options = BitmapFactory.Options()
+ options.inSampleSize = i
+ bitmap = BitmapFactory.decodeStream(inputStream, null, options)
+ ?: throw IllegalArgumentException("Can't decode stream for bitmap")
+ return scanBitmapForResult(bitmap)
+ } catch (e: Throwable) {
+ bitmap?.recycle()
+ System.gc()
+ Log.e(TAG, "Original image scan at scale factor $i finished with error: $e")
+ if (firstException == null)
+ firstException = e
+ }
+ }
+ throw Exception(firstException)
+ }
+ }
+
+ /**
+ * Attempts to parse incoming data
+ * @return result of the decoding operation
+ * @throws NotFoundException when parser didn't find QR code in the image
+ */
+ suspend fun scan(data: Uri) = withContext(Dispatchers.Default) { doScan(data) }
+
+ companion object {
+ private const val TAG = "QrCodeFromFileScanner"
+
+ /**
+ * Given a reference to a file, check if this file could be parsed by this class
+ * @return true if the file can be parsed, false if not
+ */
+ fun validContentType(contentResolver: ContentResolver, data: Uri): Boolean {
+ return contentResolver.getType(data)?.startsWith("image/") == true
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/QuantityFormatter.kt b/ui/src/main/java/com/wireguard/android/util/QuantityFormatter.kt
new file mode 100644
index 00000000..2fbb5c29
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/QuantityFormatter.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util
+
+import android.icu.text.ListFormatter
+import android.icu.text.MeasureFormat
+import android.icu.text.RelativeDateTimeFormatter
+import android.icu.util.Measure
+import android.icu.util.MeasureUnit
+import android.os.Build
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import java.util.Locale
+import kotlin.time.Duration.Companion.seconds
+
+object QuantityFormatter {
+ fun formatBytes(bytes: Long): String {
+ val context = Application.get().applicationContext
+ return when {
+ bytes < 1024 -> context.getString(R.string.transfer_bytes, bytes)
+ bytes < 1024 * 1024 -> context.getString(R.string.transfer_kibibytes, bytes / 1024.0)
+ bytes < 1024 * 1024 * 1024 -> context.getString(R.string.transfer_mibibytes, bytes / (1024.0 * 1024.0))
+ bytes < 1024 * 1024 * 1024 * 1024L -> context.getString(R.string.transfer_gibibytes, bytes / (1024.0 * 1024.0 * 1024.0))
+ else -> context.getString(R.string.transfer_tibibytes, bytes / (1024.0 * 1024.0 * 1024.0) / 1024.0)
+ }
+ }
+
+ fun formatEpochAgo(epochMillis: Long): String {
+ var span = (System.currentTimeMillis() - epochMillis) / 1000
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
+ return Application.get().applicationContext.getString(R.string.latest_handshake_ago, span.seconds.toString())
+
+ if (span <= 0L)
+ return RelativeDateTimeFormatter.getInstance().format(RelativeDateTimeFormatter.Direction.PLAIN, RelativeDateTimeFormatter.AbsoluteUnit.NOW)
+ val measureFormat = MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE)
+ val parts = ArrayList<CharSequence>(4)
+ if (span >= 24 * 60 * 60L) {
+ val v = span / (24 * 60 * 60L)
+ parts.add(measureFormat.format(Measure(v, MeasureUnit.DAY)))
+ span -= v * (24 * 60 * 60L)
+ }
+ if (span >= 60 * 60L) {
+ val v = span / (60 * 60L)
+ parts.add(measureFormat.format(Measure(v, MeasureUnit.HOUR)))
+ span -= v * (60 * 60L)
+ }
+ if (span >= 60L) {
+ val v = span / 60L
+ parts.add(measureFormat.format(Measure(v, MeasureUnit.MINUTE)))
+ span -= v * 60L
+ }
+ if (span > 0L)
+ parts.add(measureFormat.format(Measure(span, MeasureUnit.SECOND)))
+
+ val joined = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
+ parts.joinToString()
+ else
+ ListFormatter.getInstance(Locale.getDefault(), ListFormatter.Type.UNITS, ListFormatter.Width.SHORT).format(parts)
+
+ return Application.get().applicationContext.getString(R.string.latest_handshake_ago, joined)
+ }
+} \ No newline at end of file
diff --git a/ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt b/ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt
new file mode 100644
index 00000000..18a37ef6
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util
+
+import android.content.ContentResolver
+import android.net.Uri
+import android.provider.OpenableColumns
+import android.util.Log
+import androidx.fragment.app.FragmentManager
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.fragment.ConfigNamingDialogFragment
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.config.Config
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.withContext
+import java.io.BufferedReader
+import java.io.ByteArrayInputStream
+import java.io.InputStreamReader
+import java.nio.charset.StandardCharsets
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+
+object TunnelImporter {
+ suspend fun importTunnel(contentResolver: ContentResolver, uri: Uri, messageCallback: (CharSequence) -> Unit) = withContext(Dispatchers.IO) {
+ val context = Application.get().applicationContext
+ val futureTunnels = ArrayList<Deferred<ObservableTunnel>>()
+ val throwables = ArrayList<Throwable>()
+ try {
+ val columns = arrayOf(OpenableColumns.DISPLAY_NAME)
+ var name = ""
+ contentResolver.query(uri, columns, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst() && !cursor.isNull(0)) {
+ name = cursor.getString(0)
+ }
+ }
+ if (name.isEmpty()) {
+ name = Uri.decode(uri.lastPathSegment)
+ }
+ var idx = name.lastIndexOf('/')
+ if (idx >= 0) {
+ require(idx < name.length - 1) { context.getString(R.string.illegal_filename_error, name) }
+ name = name.substring(idx + 1)
+ }
+ val isZip = name.lowercase().endsWith(".zip")
+ if (name.lowercase().endsWith(".conf")) {
+ name = name.substring(0, name.length - ".conf".length)
+ } else {
+ require(isZip) { context.getString(R.string.bad_extension_error) }
+ }
+
+ if (isZip) {
+ ZipInputStream(contentResolver.openInputStream(uri)).use { zip ->
+ val reader = BufferedReader(InputStreamReader(zip, StandardCharsets.UTF_8))
+ var entry: ZipEntry?
+ while (true) {
+ entry = zip.nextEntry ?: break
+ name = entry.name
+ idx = name.lastIndexOf('/')
+ if (idx >= 0) {
+ if (idx >= name.length - 1) {
+ continue
+ }
+ name = name.substring(name.lastIndexOf('/') + 1)
+ }
+ if (name.lowercase().endsWith(".conf")) {
+ name = name.substring(0, name.length - ".conf".length)
+ } else {
+ continue
+ }
+ try {
+ Config.parse(reader)
+ } catch (e: Throwable) {
+ throwables.add(e)
+ null
+ }?.let {
+ val nameCopy = name
+ futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(nameCopy, it) })
+ }
+ }
+ }
+ } else {
+ futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(name, Config.parse(contentResolver.openInputStream(uri)!!)) })
+ }
+
+ if (futureTunnels.isEmpty()) {
+ if (throwables.size == 1) {
+ throw throwables[0]
+ } else {
+ require(throwables.isNotEmpty()) { context.getString(R.string.no_configs_error) }
+ }
+ }
+ val tunnels = futureTunnels.mapNotNull {
+ try {
+ it.await()
+ } catch (e: Throwable) {
+ throwables.add(e)
+ null
+ }
+ }
+ withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(tunnels, throwables, messageCallback) }
+ } catch (e: Throwable) {
+ withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(emptyList(), listOf(e), messageCallback) }
+ }
+ }
+
+ fun importTunnel(parentFragmentManager: FragmentManager, configText: String, messageCallback: (CharSequence) -> Unit) {
+ try {
+ // Ensure the config text is parseable before proceeding…
+ Config.parse(ByteArrayInputStream(configText.toByteArray(StandardCharsets.UTF_8)))
+
+ // Config text is valid, now create the tunnel…
+ ConfigNamingDialogFragment.newInstance(configText).show(parentFragmentManager, null)
+ } catch (e: Throwable) {
+ onTunnelImportFinished(emptyList(), listOf<Throwable>(e), messageCallback)
+ }
+ }
+
+ private fun onTunnelImportFinished(tunnels: List<ObservableTunnel>, throwables: Collection<Throwable>, messageCallback: (CharSequence) -> Unit) {
+ val context = Application.get().applicationContext
+ var message = ""
+ for (throwable in throwables) {
+ val error = ErrorMessages[throwable]
+ message = context.getString(R.string.import_error, error)
+ Log.e(TAG, message, throwable)
+ }
+ if (tunnels.size == 1 && throwables.isEmpty())
+ message = context.getString(R.string.import_success, tunnels[0].name)
+ else if (tunnels.isEmpty() && throwables.size == 1)
+ else if (throwables.isEmpty())
+ message = context.resources.getQuantityString(
+ R.plurals.import_total_success,
+ tunnels.size, tunnels.size
+ )
+ else if (!throwables.isEmpty())
+ message = context.resources.getQuantityString(
+ R.plurals.import_partial_success,
+ tunnels.size + throwables.size,
+ tunnels.size, tunnels.size + throwables.size
+ )
+
+ messageCallback(message)
+ }
+
+ private const val TAG = "WireGuard/TunnelImporter"
+} \ No newline at end of file
diff --git a/ui/src/main/java/com/wireguard/android/util/UserKnobs.kt b/ui/src/main/java/com/wireguard/android/util/UserKnobs.kt
new file mode 100644
index 00000000..ca051739
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/UserKnobs.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util
+
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import com.wireguard.android.Application
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+object UserKnobs {
+ private val ENABLE_KERNEL_MODULE = booleanPreferencesKey("enable_kernel_module")
+ val enableKernelModule: Flow<Boolean>
+ get() = Application.getPreferencesDataStore().data.map {
+ it[ENABLE_KERNEL_MODULE] ?: false
+ }
+
+ suspend fun setEnableKernelModule(enable: Boolean?) {
+ Application.getPreferencesDataStore().edit {
+ if (enable == null)
+ it.remove(ENABLE_KERNEL_MODULE)
+ else
+ it[ENABLE_KERNEL_MODULE] = enable
+ }
+ }
+
+ private val MULTIPLE_TUNNELS = booleanPreferencesKey("multiple_tunnels")
+ val multipleTunnels: Flow<Boolean>
+ get() = Application.getPreferencesDataStore().data.map {
+ it[MULTIPLE_TUNNELS] ?: false
+ }
+
+ private val DARK_THEME = booleanPreferencesKey("dark_theme")
+ val darkTheme: Flow<Boolean>
+ get() = Application.getPreferencesDataStore().data.map {
+ it[DARK_THEME] ?: false
+ }
+
+ suspend fun setDarkTheme(on: Boolean) {
+ Application.getPreferencesDataStore().edit {
+ it[DARK_THEME] = on
+ }
+ }
+
+ private val ALLOW_REMOTE_CONTROL_INTENTS = booleanPreferencesKey("allow_remote_control_intents")
+ val allowRemoteControlIntents: Flow<Boolean>
+ get() = Application.getPreferencesDataStore().data.map {
+ it[ALLOW_REMOTE_CONTROL_INTENTS] ?: false
+ }
+
+ private val RESTORE_ON_BOOT = booleanPreferencesKey("restore_on_boot")
+ val restoreOnBoot: Flow<Boolean>
+ get() = Application.getPreferencesDataStore().data.map {
+ it[RESTORE_ON_BOOT] ?: false
+ }
+
+ private val LAST_USED_TUNNEL = stringPreferencesKey("last_used_tunnel")
+ val lastUsedTunnel: Flow<String?>
+ get() = Application.getPreferencesDataStore().data.map {
+ it[LAST_USED_TUNNEL]
+ }
+
+ suspend fun setLastUsedTunnel(lastUsedTunnel: String?) {
+ Application.getPreferencesDataStore().edit {
+ if (lastUsedTunnel == null)
+ it.remove(LAST_USED_TUNNEL)
+ else
+ it[LAST_USED_TUNNEL] = lastUsedTunnel
+ }
+ }
+
+ private val RUNNING_TUNNELS = stringSetPreferencesKey("enabled_configs")
+ val runningTunnels: Flow<Set<String>>
+ get() = Application.getPreferencesDataStore().data.map {
+ it[RUNNING_TUNNELS] ?: emptySet()
+ }
+
+ suspend fun setRunningTunnels(runningTunnels: Set<String>) {
+ Application.getPreferencesDataStore().edit {
+ if (runningTunnels.isEmpty())
+ it.remove(RUNNING_TUNNELS)
+ else
+ it[RUNNING_TUNNELS] = runningTunnels
+ }
+ }
+
+ private val UPDATER_NEWER_VERSION_SEEN = stringPreferencesKey("updater_newer_version_seen")
+ val updaterNewerVersionSeen: Flow<String?>
+ get() = Application.getPreferencesDataStore().data.map {
+ it[UPDATER_NEWER_VERSION_SEEN]
+ }
+
+ suspend fun setUpdaterNewerVersionSeen(newerVersionSeen: String?) {
+ Application.getPreferencesDataStore().edit {
+ if (newerVersionSeen == null)
+ it.remove(UPDATER_NEWER_VERSION_SEEN)
+ else
+ it[UPDATER_NEWER_VERSION_SEEN] = newerVersionSeen
+ }
+ }
+
+ private val UPDATER_NEWER_VERSION_CONSENTED = stringPreferencesKey("updater_newer_version_consented")
+ val updaterNewerVersionConsented: Flow<String?>
+ get() = Application.getPreferencesDataStore().data.map {
+ it[UPDATER_NEWER_VERSION_CONSENTED]
+ }
+
+ suspend fun setUpdaterNewerVersionConsented(newerVersionConsented: String?) {
+ Application.getPreferencesDataStore().edit {
+ if (newerVersionConsented == null)
+ it.remove(UPDATER_NEWER_VERSION_CONSENTED)
+ else
+ it[UPDATER_NEWER_VERSION_CONSENTED] = newerVersionConsented
+ }
+ }
+}