diff options
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/util')
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 + } + } +} |