aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/main/java')
-rw-r--r--ui/src/main/java/com/wireguard/android/Application.kt157
-rw-r--r--ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt34
-rw-r--r--ui/src/main/java/com/wireguard/android/QuickTileService.kt187
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt96
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt382
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/MainActivity.kt129
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt107
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt29
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt66
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt445
-rw-r--r--ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt68
-rw-r--r--ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt82
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt194
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt122
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/Keyed.kt12
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt32
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt106
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt82
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt104
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt170
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt114
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt82
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt150
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt333
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt342
-rw-r--r--ui/src/main/java/com/wireguard/android/model/ApplicationData.kt22
-rw-r--r--ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt146
-rw-r--r--ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt61
-rw-r--r--ui/src/main/java/com/wireguard/android/model/TunnelManager.kt255
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt42
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt88
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt135
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt50
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt79
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt63
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt113
-rw-r--r--ui/src/main/java/com/wireguard/android/updater/Ed25519.java2507
-rw-r--r--ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt173
-rw-r--r--ui/src/main/java/com/wireguard/android/updater/Updater.kt451
-rw-r--r--ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt17
-rw-r--r--ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt80
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt37
-rw-r--r--ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt104
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt158
-rw-r--r--ui/src/main/java/com/wireguard/android/util/Extensions.kt31
-rw-r--r--ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt112
-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
-rw-r--r--ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.kt86
-rw-r--r--ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.kt142
-rw-r--r--ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt294
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.kt49
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.kt49
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt48
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt175
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.kt44
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/TvCardView.kt44
58 files changed, 9619 insertions, 0 deletions
diff --git a/ui/src/main/java/com/wireguard/android/Application.kt b/ui/src/main/java/com/wireguard/android/Application.kt
new file mode 100644
index 00000000..4dcec508
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/Application.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.StrictMode
+import android.os.StrictMode.ThreadPolicy
+import android.os.StrictMode.VmPolicy
+import android.util.Log
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
+import com.google.android.material.color.DynamicColors
+import com.wireguard.android.backend.Backend
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.configStore.FileConfigStore
+import com.wireguard.android.model.TunnelManager
+import com.wireguard.android.updater.Updater
+import com.wireguard.android.util.RootShell
+import com.wireguard.android.util.ToolsInstaller
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.applicationScope
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import java.lang.ref.WeakReference
+import java.util.Locale
+
+class Application : android.app.Application() {
+ private val futureBackend = CompletableDeferred<Backend>()
+ private val coroutineScope = CoroutineScope(Job() + Dispatchers.Main.immediate)
+ private var backend: Backend? = null
+ private lateinit var rootShell: RootShell
+ private lateinit var preferencesDataStore: DataStore<Preferences>
+ private lateinit var toolsInstaller: ToolsInstaller
+ private lateinit var tunnelManager: TunnelManager
+
+ override fun attachBaseContext(context: Context) {
+ super.attachBaseContext(context)
+ if (BuildConfig.MIN_SDK_VERSION > Build.VERSION.SDK_INT) {
+ val intent = Intent(Intent.ACTION_MAIN)
+ intent.addCategory(Intent.CATEGORY_HOME)
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(intent)
+ System.exit(0)
+ }
+ }
+
+ private suspend fun determineBackend(): Backend {
+ var backend: Backend? = null
+ if (UserKnobs.enableKernelModule.first() && WgQuickBackend.hasKernelSupport()) {
+ try {
+ rootShell.start()
+ val wgQuickBackend = WgQuickBackend(applicationContext, rootShell, toolsInstaller)
+ wgQuickBackend.setMultipleTunnels(UserKnobs.multipleTunnels.first())
+ backend = wgQuickBackend
+ UserKnobs.multipleTunnels.onEach {
+ wgQuickBackend.setMultipleTunnels(it)
+ }.launchIn(coroutineScope)
+ } catch (ignored: Exception) {
+ }
+ }
+ if (backend == null) {
+ backend = GoBackend(applicationContext)
+ GoBackend.setAlwaysOnCallback { get().applicationScope.launch { get().tunnelManager.restoreState(true) } }
+ }
+ return backend
+ }
+
+ override fun onCreate() {
+ Log.i(TAG, USER_AGENT)
+ super.onCreate()
+ DynamicColors.applyToActivitiesIfAvailable(this)
+ rootShell = RootShell(applicationContext)
+ toolsInstaller = ToolsInstaller(applicationContext, rootShell)
+ preferencesDataStore = PreferenceDataStoreFactory.create { applicationContext.preferencesDataStoreFile("settings") }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ runBlocking {
+ AppCompatDelegate.setDefaultNightMode(if (UserKnobs.darkTheme.first()) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO)
+ }
+ UserKnobs.darkTheme.onEach {
+ val newMode = if (it) {
+ AppCompatDelegate.MODE_NIGHT_YES
+ } else {
+ AppCompatDelegate.MODE_NIGHT_NO
+ }
+ if (AppCompatDelegate.getDefaultNightMode() != newMode) {
+ AppCompatDelegate.setDefaultNightMode(newMode)
+ }
+ }.launchIn(coroutineScope)
+ } else {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ }
+ tunnelManager = TunnelManager(FileConfigStore(applicationContext))
+ tunnelManager.onCreate()
+ coroutineScope.launch(Dispatchers.IO) {
+ try {
+ backend = determineBackend()
+ futureBackend.complete(backend!!)
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ Updater.monitorForUpdates()
+
+ if (BuildConfig.DEBUG) {
+ StrictMode.setVmPolicy(VmPolicy.Builder().detectAll().penaltyLog().build())
+ StrictMode.setThreadPolicy(ThreadPolicy.Builder().detectAll().penaltyLog().build())
+ }
+ }
+
+ override fun onTerminate() {
+ coroutineScope.cancel()
+ super.onTerminate()
+ }
+
+ companion object {
+ val USER_AGENT = String.format(Locale.ENGLISH, "WireGuard/%s (Android %d; %s; %s; %s %s; %s)", BuildConfig.VERSION_NAME, Build.VERSION.SDK_INT, if (Build.SUPPORTED_ABIS.isNotEmpty()) Build.SUPPORTED_ABIS[0] else "unknown ABI", Build.BOARD, Build.MANUFACTURER, Build.MODEL, Build.FINGERPRINT)
+ private const val TAG = "WireGuard/Application"
+ private lateinit var weakSelf: WeakReference<Application>
+
+ fun get(): Application {
+ return weakSelf.get()!!
+ }
+
+ suspend fun getBackend() = get().futureBackend.await()
+
+ fun getRootShell() = get().rootShell
+
+ fun getPreferencesDataStore() = get().preferencesDataStore
+
+ fun getToolsInstaller() = get().toolsInstaller
+
+ fun getTunnelManager() = get().tunnelManager
+
+ fun getCoroutineScope() = get().coroutineScope
+ }
+
+ init {
+ weakSelf = WeakReference(this)
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt
new file mode 100644
index 00000000..a859bf71
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.util.applicationScope
+import kotlinx.coroutines.launch
+
+class BootShutdownReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action ?: return
+ applicationScope.launch {
+ if (Application.getBackend() !is WgQuickBackend) return@launch
+ val tunnelManager = Application.getTunnelManager()
+ if (Intent.ACTION_BOOT_COMPLETED == action) {
+ Log.i(TAG, "Broadcast receiver restoring state (boot)")
+ tunnelManager.restoreState(false)
+ } else if (Intent.ACTION_SHUTDOWN == action) {
+ Log.i(TAG, "Broadcast receiver saving state (shutdown)")
+ tunnelManager.saveState()
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/BootShutdownReceiver"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/QuickTileService.kt b/ui/src/main/java/com/wireguard/android/QuickTileService.kt
new file mode 100644
index 00000000..9be2cc2b
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/QuickTileService.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.Icon
+import android.net.Uri
+import android.os.Build
+import android.os.IBinder
+import android.provider.Settings
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.databinding.Observable
+import androidx.databinding.Observable.OnPropertyChangedCallback
+import com.wireguard.android.activity.MainActivity
+import com.wireguard.android.activity.TunnelToggleActivity
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.applicationScope
+import com.wireguard.android.widget.SlashDrawable
+import kotlinx.coroutines.launch
+
+/**
+ * Service that maintains the application's custom Quick Settings tile. This service is bound by the
+ * system framework as necessary to update the appearance of the tile in the system UI, and to
+ * forward click events to the application.
+ */
+@RequiresApi(Build.VERSION_CODES.N)
+class QuickTileService : TileService() {
+ private val onStateChangedCallback = OnStateChangedCallback()
+ private val onTunnelChangedCallback = OnTunnelChangedCallback()
+ private var iconOff: Icon? = null
+ private var iconOn: Icon? = null
+ private var tunnel: ObservableTunnel? = null
+
+ /* This works around an annoying unsolved frameworks bug some people are hitting. */
+ override fun onBind(intent: Intent): IBinder? {
+ var ret: IBinder? = null
+ try {
+ ret = super.onBind(intent)
+ } catch (e: Throwable) {
+ Log.d(TAG, "Failed to bind to TileService", e)
+ }
+ return ret
+ }
+
+ override fun onClick() {
+ when (val tunnel = tunnel) {
+ null -> {
+ val intent = Intent(this, MainActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE))
+ } else {
+ @Suppress("DEPRECATION")
+ startActivityAndCollapse(intent)
+ }
+ }
+ else -> {
+ unlockAndRun {
+ applicationScope.launch {
+ try {
+ tunnel.setStateAsync(Tunnel.State.TOGGLE)
+ updateTile()
+ } catch (_: Throwable) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !Settings.canDrawOverlays(this@QuickTileService)) {
+ val permissionIntent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName"))
+ permissionIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivityAndCollapse(PendingIntent.getActivity(this@QuickTileService, 0, permissionIntent, PendingIntent.FLAG_IMMUTABLE))
+ return@launch
+ }
+ val toggleIntent = Intent(this@QuickTileService, TunnelToggleActivity::class.java)
+ toggleIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(toggleIntent)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onCreate() {
+ isAdded = true
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ iconOn = Icon.createWithResource(this, R.drawable.ic_tile)
+ iconOff = iconOn
+ return
+ }
+ val icon = SlashDrawable(resources.getDrawable(R.drawable.ic_tile, Application.get().theme))
+ icon.setAnimationEnabled(false) /* Unfortunately we can't have animations, since Icons are marshaled. */
+ icon.setSlashed(false)
+ var b = Bitmap.createBitmap(icon.intrinsicWidth, icon.intrinsicHeight, Bitmap.Config.ARGB_8888)
+ var c = Canvas(b)
+ icon.setBounds(0, 0, c.width, c.height)
+ icon.draw(c)
+ iconOn = Icon.createWithBitmap(b)
+ icon.setSlashed(true)
+ b = Bitmap.createBitmap(icon.intrinsicWidth, icon.intrinsicHeight, Bitmap.Config.ARGB_8888)
+ c = Canvas(b)
+ icon.setBounds(0, 0, c.width, c.height)
+ icon.draw(c)
+ iconOff = Icon.createWithBitmap(b)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ isAdded = false
+ }
+
+ override fun onStartListening() {
+ Application.getTunnelManager().addOnPropertyChangedCallback(onTunnelChangedCallback)
+ tunnel?.addOnPropertyChangedCallback(onStateChangedCallback)
+ updateTile()
+ }
+
+ override fun onStopListening() {
+ tunnel?.removeOnPropertyChangedCallback(onStateChangedCallback)
+ Application.getTunnelManager().removeOnPropertyChangedCallback(onTunnelChangedCallback)
+ }
+
+ override fun onTileAdded() {
+ isAdded = true
+ }
+
+ override fun onTileRemoved() {
+ isAdded = false
+ }
+
+ private fun updateTile() {
+ // Update the tunnel.
+ val newTunnel = Application.getTunnelManager().lastUsedTunnel
+ if (newTunnel != tunnel) {
+ tunnel?.removeOnPropertyChangedCallback(onStateChangedCallback)
+ tunnel = newTunnel
+ tunnel?.addOnPropertyChangedCallback(onStateChangedCallback)
+ }
+ // Update the tile contents.
+ val tile = qsTile ?: return
+
+ when (val tunnel = tunnel) {
+ null -> {
+ tile.label = getString(R.string.app_name)
+ tile.state = Tile.STATE_INACTIVE
+ tile.icon = iconOff
+ }
+ else -> {
+ tile.label = tunnel.name
+ tile.state = if (tunnel.state == Tunnel.State.UP) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
+ tile.icon = if (tunnel.state == Tunnel.State.UP) iconOn else iconOff
+ }
+ }
+ tile.updateTile()
+ }
+
+ private inner class OnStateChangedCallback : OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ if (sender != tunnel) {
+ sender.removeOnPropertyChangedCallback(this)
+ return
+ }
+ if (propertyId != 0 && propertyId != BR.state)
+ return
+ updateTile()
+ }
+ }
+
+ private inner class OnTunnelChangedCallback : OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ if (propertyId != 0 && propertyId != BR.lastUsedTunnel)
+ return
+ updateTile()
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/QuickTileService"
+ var isAdded: Boolean = false
+ private set
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt
new file mode 100644
index 00000000..56810377
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.CallbackRegistry
+import androidx.databinding.CallbackRegistry.NotifierCallback
+import androidx.lifecycle.lifecycleScope
+import com.wireguard.android.Application
+import com.wireguard.android.model.ObservableTunnel
+import kotlinx.coroutines.launch
+
+/**
+ * Base class for activities that need to remember the currently-selected tunnel.
+ */
+abstract class BaseActivity : AppCompatActivity() {
+ private val selectionChangeRegistry = SelectionChangeRegistry()
+ private var created = false
+ var selectedTunnel: ObservableTunnel? = null
+ set(value) {
+ val oldTunnel = field
+ if (oldTunnel == value) return
+ field = value
+ if (created) {
+ if (!onSelectedTunnelChanged(oldTunnel, value)) {
+ field = oldTunnel
+ } else {
+ selectionChangeRegistry.notifyCallbacks(oldTunnel, 0, value)
+ }
+ }
+ }
+
+ fun addOnSelectedTunnelChangedListener(listener: OnSelectedTunnelChangedListener) {
+ selectionChangeRegistry.add(listener)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Restore the saved tunnel if there is one; otherwise grab it from the arguments.
+ val savedTunnelName = when {
+ savedInstanceState != null -> savedInstanceState.getString(KEY_SELECTED_TUNNEL)
+ intent != null -> intent.getStringExtra(KEY_SELECTED_TUNNEL)
+ else -> null
+ }
+ if (savedTunnelName != null) {
+ lifecycleScope.launch {
+ val tunnel = Application.getTunnelManager().getTunnels()[savedTunnelName]
+ if (tunnel == null)
+ created = true
+ selectedTunnel = tunnel
+ created = true
+ }
+ } else {
+ created = true
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ if (selectedTunnel != null) outState.putString(KEY_SELECTED_TUNNEL, selectedTunnel!!.name)
+ super.onSaveInstanceState(outState)
+ }
+
+ protected abstract fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?): Boolean
+
+ fun removeOnSelectedTunnelChangedListener(
+ listener: OnSelectedTunnelChangedListener
+ ) {
+ selectionChangeRegistry.remove(listener)
+ }
+
+ interface OnSelectedTunnelChangedListener {
+ fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?)
+ }
+
+ private class SelectionChangeNotifier : NotifierCallback<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel>() {
+ override fun onNotifyCallback(
+ listener: OnSelectedTunnelChangedListener,
+ oldTunnel: ObservableTunnel?,
+ ignored: Int,
+ newTunnel: ObservableTunnel?
+ ) {
+ listener.onSelectedTunnelChanged(oldTunnel, newTunnel)
+ }
+ }
+
+ private class SelectionChangeRegistry :
+ CallbackRegistry<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel>(SelectionChangeNotifier())
+
+ companion object {
+ private const val KEY_SELECTED_TUNNEL = "selected_tunnel"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt
new file mode 100644
index 00000000..69e33bc9
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt
@@ -0,0 +1,382 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity
+
+import android.content.ClipDescription.compareMimeTypes
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Intent
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.graphics.Typeface.BOLD
+import android.net.Uri
+import android.os.Bundle
+import android.os.ParcelFileDescriptor
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.style.ForegroundColorSpan
+import android.text.style.StyleSpan
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.collection.CircularArray
+import androidx.core.app.ShareCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.textview.MaterialTextView
+import com.wireguard.android.BuildConfig
+import com.wireguard.android.R
+import com.wireguard.android.databinding.LogViewerActivityBinding
+import com.wireguard.android.util.DownloadsFileSaver
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.resolveAttribute
+import com.wireguard.crypto.KeyPair
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.BufferedReader
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStreamReader
+import java.nio.charset.StandardCharsets
+import java.text.DateFormat
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.ConcurrentHashMap
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+class LogViewerActivity : AppCompatActivity() {
+ private lateinit var binding: LogViewerActivityBinding
+ private lateinit var logAdapter: LogEntryAdapter
+ private var logLines = CircularArray<LogLine>()
+ private var rawLogLines = CircularArray<String>()
+ private var recyclerView: RecyclerView? = null
+ private var saveButton: MenuItem? = null
+ private val year by lazy {
+ val yearFormatter: DateFormat = SimpleDateFormat("yyyy", Locale.US)
+ yearFormatter.format(Date())
+ }
+
+ private val defaultColor by lazy { resolveAttribute(com.google.android.material.R.attr.colorOnSurface) }
+
+ private val debugColor by lazy { ResourcesCompat.getColor(resources, R.color.debug_tag_color, theme) }
+
+ private val errorColor by lazy { ResourcesCompat.getColor(resources, R.color.error_tag_color, theme) }
+
+ private val infoColor by lazy { ResourcesCompat.getColor(resources, R.color.info_tag_color, theme) }
+
+ private val warningColor by lazy { ResourcesCompat.getColor(resources, R.color.warning_tag_color, theme) }
+
+ private var lastUri: Uri? = null
+
+ private fun revokeLastUri() {
+ lastUri?.let {
+ LOGS.remove(it.pathSegments.lastOrNull())
+ revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ lastUri = null
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = LogViewerActivityBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ logAdapter = LogEntryAdapter()
+ binding.recyclerView.apply {
+ recyclerView = this
+ layoutManager = LinearLayoutManager(context)
+ adapter = logAdapter
+ addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
+ }
+
+ lifecycleScope.launch(Dispatchers.IO) { streamingLog() }
+
+ val revokeLastActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ revokeLastUri()
+ }
+
+ binding.shareFab.setOnClickListener {
+ lifecycleScope.launch {
+ revokeLastUri()
+ val key = KeyPair().privateKey.toHex()
+ LOGS[key] = rawLogBytes()
+ lastUri = Uri.parse("content://${BuildConfig.APPLICATION_ID}.exported-log/$key")
+ val shareIntent = ShareCompat.IntentBuilder(this@LogViewerActivity)
+ .setType("text/plain")
+ .setSubject(getString(R.string.log_export_subject))
+ .setStream(lastUri)
+ .setChooserTitle(R.string.log_export_title)
+ .createChooserIntent()
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ grantUriPermission("android", lastUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ revokeLastActivityResultLauncher.launch(shareIntent)
+ }
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.log_viewer, menu)
+ saveButton = menu.findItem(R.id.save_log)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+
+ R.id.save_log -> {
+ saveButton?.isEnabled = false
+ lifecycleScope.launch { saveLog() }
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private val downloadsFileSaver = DownloadsFileSaver(this)
+
+ private suspend fun rawLogBytes(): ByteArray {
+ val builder = StringBuilder()
+ withContext(Dispatchers.IO) {
+ for (i in 0 until rawLogLines.size()) {
+ builder.append(rawLogLines[i])
+ builder.append('\n')
+ }
+ }
+ return builder.toString().toByteArray(Charsets.UTF_8)
+ }
+
+ private suspend fun saveLog() {
+ var exception: Throwable? = null
+ var outputFile: DownloadsFileSaver.DownloadsFile? = null
+ withContext(Dispatchers.IO) {
+ try {
+ outputFile = downloadsFileSaver.save("wireguard-log.txt", "text/plain", true)
+ outputFile?.outputStream?.write(rawLogBytes())
+ } catch (e: Throwable) {
+ outputFile?.delete()
+ exception = e
+ }
+ }
+ saveButton?.isEnabled = true
+ if (outputFile == null)
+ return
+ Snackbar.make(
+ findViewById(android.R.id.content),
+ if (exception == null) getString(R.string.log_export_success, outputFile?.fileName)
+ else getString(R.string.log_export_error, ErrorMessages[exception]),
+ if (exception == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
+ )
+ .setAnchorView(binding.shareFab)
+ .show()
+ }
+
+ private suspend fun streamingLog() = withContext(Dispatchers.IO) {
+ val builder = ProcessBuilder().command("logcat", "-b", "all", "-v", "threadtime", "*:V")
+ builder.environment()["LC_ALL"] = "C"
+ var process: Process? = null
+ try {
+ process = try {
+ builder.start()
+ } catch (e: IOException) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ return@withContext
+ }
+ val stdout = BufferedReader(InputStreamReader(process!!.inputStream, StandardCharsets.UTF_8))
+
+ var posStart = 0
+ var timeLastNotify = System.nanoTime()
+ var priorModified = false
+ val bufferedLogLines = arrayListOf<LogLine>()
+ var timeout = 1000000000L / 2 // The timeout is initially small so that the view gets populated immediately.
+ val MAX_LINES = (1 shl 16) - 1
+ val MAX_BUFFERED_LINES = (1 shl 14) - 1
+
+ while (true) {
+ val line = stdout.readLine() ?: break
+ if (rawLogLines.size() >= MAX_LINES)
+ rawLogLines.popFirst()
+ rawLogLines.addLast(line)
+ val logLine = parseLine(line)
+ if (logLine != null) {
+ bufferedLogLines.add(logLine)
+ } else {
+ if (bufferedLogLines.isNotEmpty()) {
+ bufferedLogLines.last().msg += "\n$line"
+ } else if (!logLines.isEmpty()) {
+ logLines[logLines.size() - 1].msg += "\n$line"
+ priorModified = true
+ }
+ }
+ val timeNow = System.nanoTime()
+ if (bufferedLogLines.size < MAX_BUFFERED_LINES && (timeNow - timeLastNotify) < timeout && stdout.ready())
+ continue
+ timeout = 1000000000L * 5 / 2 // Increase the timeout after the initial view has something in it.
+ timeLastNotify = timeNow
+
+ withContext(Dispatchers.Main.immediate) {
+ val isScrolledToBottomAlready = recyclerView?.canScrollVertically(1) == false
+ if (priorModified) {
+ logAdapter.notifyItemChanged(posStart - 1)
+ priorModified = false
+ }
+ val fullLen = logLines.size() + bufferedLogLines.size
+ if (fullLen >= MAX_LINES) {
+ val numToRemove = fullLen - MAX_LINES + 1
+ logLines.removeFromStart(numToRemove)
+ logAdapter.notifyItemRangeRemoved(0, numToRemove)
+ posStart -= numToRemove
+
+ }
+ for (bufferedLine in bufferedLogLines) {
+ logLines.addLast(bufferedLine)
+ }
+ bufferedLogLines.clear()
+ logAdapter.notifyItemRangeInserted(posStart, logLines.size() - posStart)
+ posStart = logLines.size()
+
+ if (isScrolledToBottomAlready) {
+ recyclerView?.scrollToPosition(logLines.size() - 1)
+ }
+ }
+ }
+ } finally {
+ process?.destroy()
+ }
+ }
+
+ private fun parseTime(timeStr: String): Date? {
+ val formatter: DateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
+ return try {
+ formatter.parse("$year-$timeStr")
+ } catch (e: ParseException) {
+ null
+ }
+ }
+
+ private fun parseLine(line: String): LogLine? {
+ val m: Matcher = THREADTIME_LINE.matcher(line)
+ return if (m.matches()) {
+ LogLine(m.group(2)!!.toInt(), m.group(3)!!.toInt(), parseTime(m.group(1)!!), m.group(4)!!, m.group(5)!!, m.group(6)!!)
+ } else {
+ null
+ }
+ }
+
+ private data class LogLine(val pid: Int, val tid: Int, val time: Date?, val level: String, val tag: String, var msg: String)
+
+ companion object {
+ /**
+ * Match a single line of `logcat -v threadtime`, such as:
+ *
+ * <pre>05-26 11:02:36.886 5689 5689 D AndroidRuntime: CheckJNI is OFF.</pre>
+ */
+ private val THREADTIME_LINE: Pattern =
+ Pattern.compile("^(\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3})(?:\\s+[0-9A-Za-z]+)?\\s+(\\d+)\\s+(\\d+)\\s+([A-Z])\\s+(.+?)\\s*: (.*)$")
+ private val LOGS: MutableMap<String, ByteArray> = ConcurrentHashMap()
+ private const val TAG = "WireGuard/LogViewerActivity"
+ }
+
+ private inner class LogEntryAdapter : RecyclerView.Adapter<LogEntryAdapter.ViewHolder>() {
+
+ private inner class ViewHolder(val layout: View, var isSingleLine: Boolean = true) : RecyclerView.ViewHolder(layout)
+
+ private fun levelToColor(level: String): Int {
+ return when (level) {
+ "V", "D" -> debugColor
+ "E" -> errorColor
+ "I" -> infoColor
+ "W" -> warningColor
+ else -> defaultColor
+ }
+ }
+
+ override fun getItemCount() = logLines.size()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.log_viewer_entry, parent, false)
+ return ViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val line = logLines[position]
+ val spannable = if (position > 0 && logLines[position - 1].tag == line.tag)
+ SpannableString(line.msg)
+ else
+ SpannableString("${line.tag}: ${line.msg}").apply {
+ setSpan(StyleSpan(BOLD), 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ setSpan(
+ ForegroundColorSpan(levelToColor(line.level)),
+ 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ }
+ holder.layout.apply {
+ findViewById<MaterialTextView>(R.id.log_date).text = line.time.toString()
+ findViewById<MaterialTextView>(R.id.log_msg).apply {
+ setSingleLine()
+ text = spannable
+ setOnClickListener {
+ isSingleLine = !holder.isSingleLine
+ holder.isSingleLine = !holder.isSingleLine
+ }
+ }
+ }
+ }
+ }
+
+ class ExportedLogContentProvider : ContentProvider() {
+ private fun logForUri(uri: Uri): ByteArray? = LOGS[uri.pathSegments.lastOrNull()]
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? =
+ logForUri(uri)?.let {
+ val m = MatrixCursor(arrayOf(android.provider.OpenableColumns.DISPLAY_NAME, android.provider.OpenableColumns.SIZE), 1)
+ m.addRow(arrayOf("wireguard-log.txt", it.size.toLong()))
+ m
+ }
+
+ override fun onCreate(): Boolean = true
+
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
+
+ override fun getType(uri: Uri): String? = logForUri(uri)?.let { "text/plain" }
+
+ override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>? =
+ getType(uri)?.let { if (compareMimeTypes(it, mimeTypeFilter)) arrayOf(it) else null }
+
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
+ if (mode != "r") return null
+ val log = logForUri(uri) ?: return null
+ return openPipeHelper(uri, "text/plain", null, log) { output, _, _, _, l ->
+ try {
+ FileOutputStream(output.fileDescriptor).write(l!!)
+ } catch (_: Throwable) {
+ }
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt
new file mode 100644
index 00000000..80c4868c
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.addCallback
+import androidx.appcompat.app.ActionBar
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentTransaction
+import androidx.fragment.app.commit
+import com.wireguard.android.R
+import com.wireguard.android.fragment.TunnelDetailFragment
+import com.wireguard.android.fragment.TunnelEditorFragment
+import com.wireguard.android.model.ObservableTunnel
+
+/**
+ * CRUD interface for WireGuard tunnels. This activity serves as the main entry point to the
+ * WireGuard application, and contains several fragments for listing, viewing details of, and
+ * editing the configuration and interface state of WireGuard tunnels.
+ */
+class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener {
+ private var actionBar: ActionBar? = null
+ private var isTwoPaneLayout = false
+ private var backPressedCallback: OnBackPressedCallback? = null
+
+ private fun handleBackPressed() {
+ val backStackEntries = supportFragmentManager.backStackEntryCount
+ // If the two-pane layout does not have an editor open, going back should exit the app.
+ if (isTwoPaneLayout && backStackEntries <= 1) {
+ finish()
+ return
+ }
+
+ if (backStackEntries >= 1)
+ supportFragmentManager.popBackStack()
+
+ // Deselect the current tunnel on navigating back from the detail pane to the one-pane list.
+ if (backStackEntries == 1)
+ selectedTunnel = null
+ }
+
+ override fun onBackStackChanged() {
+ val backStackEntries = supportFragmentManager.backStackEntryCount
+ backPressedCallback?.isEnabled = backStackEntries >= 1
+ if (actionBar == null) return
+ // Do not show the home menu when the two-pane layout is at the detail view (see above).
+ val minBackStackEntries = if (isTwoPaneLayout) 2 else 1
+ actionBar!!.setDisplayHomeAsUpEnabled(backStackEntries >= minBackStackEntries)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.main_activity)
+ actionBar = supportActionBar
+ isTwoPaneLayout = findViewById<View?>(R.id.master_detail_wrapper) != null
+ supportFragmentManager.addOnBackStackChangedListener(this)
+ backPressedCallback = onBackPressedDispatcher.addCallback(this) { handleBackPressed() }
+ onBackStackChanged()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.main_activity, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ // The back arrow in the action bar should act the same as the back button.
+ onBackPressedDispatcher.onBackPressed()
+ true
+ }
+
+ R.id.menu_action_edit -> {
+ supportFragmentManager.commit {
+ replace(R.id.detail_container, TunnelEditorFragment())
+ setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ addToBackStack(null)
+ }
+ true
+ }
+ // This menu item is handled by the editor fragment.
+ R.id.menu_action_save -> false
+ R.id.menu_settings -> {
+ startActivity(Intent(this, SettingsActivity::class.java))
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onSelectedTunnelChanged(
+ oldTunnel: ObservableTunnel?,
+ newTunnel: ObservableTunnel?
+ ): Boolean {
+ val fragmentManager = supportFragmentManager
+ if (fragmentManager.isStateSaved) {
+ return false
+ }
+
+ val backStackEntries = fragmentManager.backStackEntryCount
+ if (newTunnel == null) {
+ // Clear everything off the back stack (all editors and detail fragments).
+ fragmentManager.popBackStackImmediate(0, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ return true
+ }
+ if (backStackEntries == 2) {
+ // Pop the editor off the back stack to reveal the detail fragment. Use the immediate
+ // method to avoid the editor picking up the new tunnel while it is still visible.
+ fragmentManager.popBackStackImmediate()
+ } else if (backStackEntries == 0) {
+ // Create and show a new detail fragment.
+ fragmentManager.commit {
+ add(R.id.detail_container, TunnelDetailFragment())
+ setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ addToBackStack(null)
+ }
+ }
+ return true
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt
new file mode 100644
index 00000000..bd6e1f78
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.content.ComponentName
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.service.quicksettings.TileService
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.commit
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
+import com.wireguard.android.Application
+import com.wireguard.android.QuickTileService
+import com.wireguard.android.R
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.preference.PreferencesPreferenceDataStore
+import com.wireguard.android.util.AdminKnobs
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Interface for changing application-global persistent settings.
+ */
+class SettingsActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (supportFragmentManager.findFragmentById(android.R.id.content) == null) {
+ supportFragmentManager.commit {
+ add(android.R.id.content, SettingsFragment())
+ }
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ if (item.itemId == android.R.id.home) {
+ finish()
+ return true
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ class SettingsFragment : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, key: String?) {
+ preferenceManager.preferenceDataStore = PreferencesPreferenceDataStore(lifecycleScope, Application.getPreferencesDataStore())
+ addPreferencesFromResource(R.xml.preferences)
+ preferenceScreen.initialExpandedChildrenCount = 5
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || QuickTileService.isAdded) {
+ val quickTile = preferenceManager.findPreference<Preference>("quick_tile")
+ quickTile?.parent?.removePreference(quickTile)
+ --preferenceScreen.initialExpandedChildrenCount
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val darkTheme = preferenceManager.findPreference<Preference>("dark_theme")
+ darkTheme?.parent?.removePreference(darkTheme)
+ --preferenceScreen.initialExpandedChildrenCount
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ val remoteApps = preferenceManager.findPreference<Preference>("allow_remote_control_intents")
+ remoteApps?.parent?.removePreference(remoteApps)
+ }
+ if (AdminKnobs.disableConfigExport) {
+ val zipExporter = preferenceManager.findPreference<Preference>("zip_exporter")
+ zipExporter?.parent?.removePreference(zipExporter)
+ }
+ val wgQuickOnlyPrefs = arrayOf(
+ preferenceManager.findPreference("tools_installer"),
+ preferenceManager.findPreference("restore_on_boot"),
+ preferenceManager.findPreference<Preference>("multiple_tunnels")
+ ).filterNotNull()
+ wgQuickOnlyPrefs.forEach { it.isVisible = false }
+ lifecycleScope.launch {
+ if (Application.getBackend() is WgQuickBackend) {
+ ++preferenceScreen.initialExpandedChildrenCount
+ wgQuickOnlyPrefs.forEach { it.isVisible = true }
+ } else {
+ wgQuickOnlyPrefs.forEach { it.parent?.removePreference(it) }
+ }
+ }
+ preferenceManager.findPreference<Preference>("log_viewer")?.setOnPreferenceClickListener {
+ startActivity(Intent(requireContext(), LogViewerActivity::class.java))
+ true
+ }
+ val kernelModuleEnabler = preferenceManager.findPreference<Preference>("kernel_module_enabler")
+ if (WgQuickBackend.hasKernelSupport()) {
+ lifecycleScope.launch {
+ if (Application.getBackend() !is WgQuickBackend) {
+ try {
+ withContext(Dispatchers.IO) { Application.getRootShell().start() }
+ } catch (_: Throwable) {
+ kernelModuleEnabler?.parent?.removePreference(kernelModuleEnabler)
+ }
+ }
+ }
+ } else {
+ kernelModuleEnabler?.parent?.removePreference(kernelModuleEnabler)
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt
new file mode 100644
index 00000000..bdf798ca
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.os.Bundle
+import androidx.fragment.app.commit
+import com.wireguard.android.fragment.TunnelEditorFragment
+import com.wireguard.android.model.ObservableTunnel
+
+/**
+ * Standalone activity for creating tunnels.
+ */
+class TunnelCreatorActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (supportFragmentManager.findFragmentById(android.R.id.content) == null) {
+ supportFragmentManager.commit {
+ add(android.R.id.content, TunnelEditorFragment())
+ }
+ }
+ }
+
+ override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?): Boolean {
+ finish()
+ return true
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt
new file mode 100644
index 00000000..59b9349f
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.content.ComponentName
+import android.os.Build
+import android.os.Bundle
+import android.service.quicksettings.TileService
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import com.wireguard.android.Application
+import com.wireguard.android.QuickTileService
+import com.wireguard.android.R
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.util.ErrorMessages
+import kotlinx.coroutines.launch
+
+@RequiresApi(Build.VERSION_CODES.N)
+class TunnelToggleActivity : AppCompatActivity() {
+ private val permissionActivityResultLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { toggleTunnelWithPermissionsResult() }
+
+ private fun toggleTunnelWithPermissionsResult() {
+ val tunnel = Application.getTunnelManager().lastUsedTunnel ?: return
+ lifecycleScope.launch {
+ try {
+ tunnel.setStateAsync(Tunnel.State.TOGGLE)
+ } catch (e: Throwable) {
+ TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java))
+ val error = ErrorMessages[e]
+ val message = getString(R.string.toggle_error, error)
+ Log.e(TAG, message, e)
+ Toast.makeText(this@TunnelToggleActivity, message, Toast.LENGTH_LONG).show()
+ finishAffinity()
+ return@launch
+ }
+ TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java))
+ finishAffinity()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ lifecycleScope.launch {
+ if (Application.getBackend() is GoBackend) {
+ val intent = GoBackend.VpnService.prepare(this@TunnelToggleActivity)
+ if (intent != null) {
+ permissionActivityResultLauncher.launch(intent)
+ return@launch
+ }
+ }
+ toggleTunnelWithPermissionsResult()
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/TunnelToggleActivity"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt
new file mode 100644
index 00000000..4c86b4c8
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt
@@ -0,0 +1,445 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity
+
+import android.Manifest
+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.Bundle
+import android.os.Environment
+import android.os.storage.StorageManager
+import android.os.storage.StorageVolume
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.activity.addCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import androidx.core.view.forEach
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.Observable
+import androidx.databinding.ObservableBoolean
+import androidx.databinding.ObservableField
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.Keyed
+import com.wireguard.android.databinding.ObservableKeyedArrayList
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter
+import com.wireguard.android.databinding.TvActivityBinding
+import com.wireguard.android.databinding.TvFileListItemBinding
+import com.wireguard.android.databinding.TvTunnelListItemBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.QuantityFormatter
+import com.wireguard.android.util.TunnelImporter
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.applicationScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+
+class TvMainActivity : AppCompatActivity() {
+ private val tunnelFileImportResultLauncher = registerForActivityResult(object : ActivityResultContracts.GetContent() {
+ override fun createIntent(context: Context, input: String): Intent {
+ val intent = super.createIntent(context, input)
+
+ /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
+ * what we can do, so detect this and throw an exception that we can catch later. */
+ val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
+ } else {
+ @Suppress("DEPRECATION")
+ context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
+ }
+ if (activitiesToResolveIntent.all {
+ val name = it.activityInfo.packageName
+ name.startsWith("com.google.android.tv.frameworkpackagestubs") || name.startsWith("com.android.tv.frameworkpackagestubs")
+ }) {
+ throw ActivityNotFoundException()
+ }
+ return intent
+ }
+ }) { data ->
+ if (data == null) return@registerForActivityResult
+ lifecycleScope.launch {
+ TunnelImporter.importTunnel(contentResolver, data) {
+ Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ private var pendingTunnel: ObservableTunnel? = null
+ private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ val tunnel = pendingTunnel
+ if (tunnel != null)
+ setTunnelStateWithPermissionsResult(tunnel)
+ pendingTunnel = null
+ }
+
+ private fun setTunnelStateWithPermissionsResult(tunnel: ObservableTunnel) {
+ lifecycleScope.launch {
+ try {
+ tunnel.setStateAsync(Tunnel.State.TOGGLE)
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val message = getString(R.string.error_up, error)
+ Toast.makeText(this@TvMainActivity, message, Toast.LENGTH_LONG).show()
+ Log.e(TAG, message, e)
+ }
+ updateStats()
+ }
+ }
+
+ private lateinit var binding: TvActivityBinding
+ private val isDeleting = ObservableBoolean()
+ private val files = ObservableKeyedArrayList<String, KeyedFile>()
+ private val filesRoot = ObservableField("")
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_YES) {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ applicationScope.launch {
+ UserKnobs.setDarkTheme(true)
+ }
+ }
+ }
+ super.onCreate(savedInstanceState)
+ binding = TvActivityBinding.inflate(layoutInflater)
+ lifecycleScope.launch {
+ binding.tunnels = Application.getTunnelManager().getTunnels()
+ if (binding.tunnels?.isEmpty() == true)
+ binding.importButton.requestFocus()
+ else
+ binding.tunnelList.requestFocus()
+ }
+ binding.isDeleting = isDeleting
+ binding.files = files
+ binding.filesRoot = filesRoot
+ val gridManager = binding.tunnelList.layoutManager as GridLayoutManager
+ gridManager.spanSizeLookup = SlatedSpanSizeLookup(gridManager)
+ binding.tunnelRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvTunnelListItemBinding, ObservableTunnel> {
+ override fun onConfigureRow(binding: TvTunnelListItemBinding, item: ObservableTunnel, position: Int) {
+ binding.isDeleting = isDeleting
+ binding.isFocused = ObservableBoolean()
+ binding.root.setOnFocusChangeListener { _, focused ->
+ binding.isFocused?.set(focused)
+ }
+ binding.root.setOnClickListener {
+ lifecycleScope.launch {
+ if (isDeleting.get()) {
+ try {
+ item.deleteAsync()
+ if (this@TvMainActivity.binding.tunnels?.isEmpty() != false)
+ isDeleting.set(false)
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val message = getString(R.string.config_delete_error, error)
+ Toast.makeText(this@TvMainActivity, message, Toast.LENGTH_LONG).show()
+ Log.e(TAG, message, e)
+ }
+ } else {
+ if (Application.getBackend() is GoBackend) {
+ val intent = GoBackend.VpnService.prepare(binding.root.context)
+ if (intent != null) {
+ pendingTunnel = item
+ permissionActivityResultLauncher.launch(intent)
+ return@launch
+ }
+ }
+ setTunnelStateWithPermissionsResult(item)
+ }
+ }
+ }
+ }
+ }
+
+ binding.filesRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvFileListItemBinding, KeyedFile> {
+ override fun onConfigureRow(binding: TvFileListItemBinding, item: KeyedFile, position: Int) {
+ binding.root.setOnClickListener {
+ if (item.file.isDirectory)
+ navigateTo(item.file)
+ else {
+ val uri = Uri.fromFile(item.file)
+ files.clear()
+ filesRoot.set("")
+ lifecycleScope.launch {
+ TunnelImporter.importTunnel(contentResolver, uri) {
+ Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show()
+ }
+ }
+ runOnUiThread {
+ this@TvMainActivity.binding.tunnelList.requestFocus()
+ }
+ }
+ }
+ }
+ }
+
+ binding.importButton.setOnClickListener {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ if (filesRoot.get()?.isEmpty() != false) {
+ navigateTo(File("/"))
+ runOnUiThread {
+ binding.filesList.requestFocus()
+ }
+ } else {
+ files.clear()
+ filesRoot.set("")
+ runOnUiThread {
+ binding.tunnelList.requestFocus()
+ }
+ }
+ } else {
+ try {
+ tunnelFileImportResultLauncher.launch("*/*")
+ } catch (_: Throwable) {
+ MaterialAlertDialogBuilder(binding.root.context).setMessage(R.string.tv_no_file_picker).setCancelable(false)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ try {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://webstoreredirect")))
+ } catch (_: Throwable) {
+ }
+ }.show()
+ }
+ }
+ }
+
+ binding.deleteButton.setOnClickListener {
+ isDeleting.set(!isDeleting.get())
+ runOnUiThread {
+ binding.tunnelList.requestFocus()
+ }
+ }
+
+ val backPressedCallback = onBackPressedDispatcher.addCallback(this) { handleBackPressed() }
+ val updateBackPressedCallback = object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
+ backPressedCallback.isEnabled = isDeleting.get() || filesRoot.get()?.isNotEmpty() == true
+ }
+ }
+ isDeleting.addOnPropertyChangedCallback(updateBackPressedCallback)
+ filesRoot.addOnPropertyChangedCallback(updateBackPressedCallback)
+ backPressedCallback.isEnabled = false
+
+ binding.executePendingBindings()
+ setContentView(binding.root)
+
+ lifecycleScope.launch {
+ while (true) {
+ updateStats()
+ delay(1000)
+ }
+ }
+ }
+
+ private var pendingNavigation: File? = null
+ private val permissionRequestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
+ val to = pendingNavigation
+ if (it && to != null)
+ navigateTo(to)
+ pendingNavigation = null
+ }
+
+ private var cachedRoots: Collection<KeyedFile>? = null
+
+ private suspend fun makeStorageRoots(): Collection<KeyedFile> = withContext(Dispatchers.IO) {
+ cachedRoots?.let { return@withContext it }
+ val list = HashSet<KeyedFile>()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ val storageManager: StorageManager = getSystemService() ?: return@withContext list
+ list.addAll(storageManager.storageVolumes.mapNotNull { volume ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ volume.directory?.let { KeyedFile(it, volume.getDescription(this@TvMainActivity)) }
+ } else {
+ KeyedFile((StorageVolume::class.java.getMethod("getPathFile").invoke(volume) as File), volume.getDescription(this@TvMainActivity))
+ }
+ })
+ } else {
+ @Suppress("DEPRECATION")
+ list.add(KeyedFile(Environment.getExternalStorageDirectory()))
+ try {
+ File("/storage").listFiles()?.forEach {
+ if (!it.isDirectory) return@forEach
+ try {
+ if (Environment.isExternalStorageRemovable(it)) {
+ list.add(KeyedFile(it))
+ }
+ } catch (_: Throwable) {
+ }
+ }
+ } catch (_: Throwable) {
+ }
+ }
+ cachedRoots = list
+ list
+ }
+
+ private fun isBelowCachedRoots(maybeChild: File): Boolean {
+ val cachedRoots = cachedRoots ?: return true
+ for (root in cachedRoots) {
+ if (maybeChild.canonicalPath.startsWith(root.file.canonicalPath))
+ return false
+ }
+ return true
+ }
+
+ private fun navigateTo(directory: File) {
+ require(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
+
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+ pendingNavigation = directory
+ permissionRequestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
+ return
+ }
+
+ lifecycleScope.launch {
+ if (isBelowCachedRoots(directory)) {
+ val roots = makeStorageRoots()
+ if (roots.count() == 1) {
+ navigateTo(roots.first().file)
+ return@launch
+ }
+ files.clear()
+ files.addAll(roots)
+ filesRoot.set(getString(R.string.tv_select_a_storage_drive))
+ return@launch
+ }
+
+ val newFiles = withContext(Dispatchers.IO) {
+ val newFiles = ArrayList<KeyedFile>()
+ try {
+ directory.parentFile?.let {
+ newFiles.add(KeyedFile(it, "../"))
+ }
+ val listing = directory.listFiles() ?: return@withContext null
+ listing.forEach {
+ if (it.extension == "conf" || it.extension == "zip" || it.isDirectory)
+ newFiles.add(KeyedFile(it))
+ }
+ newFiles.sortWith { a, b ->
+ if (a.file.isDirectory && !b.file.isDirectory) -1
+ else if (!a.file.isDirectory && b.file.isDirectory) 1
+ else a.file.compareTo(b.file)
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ newFiles
+ }
+ if (newFiles?.isEmpty() != false)
+ return@launch
+ files.clear()
+ files.addAll(newFiles)
+ filesRoot.set(directory.canonicalPath)
+ }
+ }
+
+ private fun handleBackPressed() {
+ when {
+ isDeleting.get() -> {
+ isDeleting.set(false)
+ runOnUiThread {
+ binding.tunnelList.requestFocus()
+ }
+ }
+
+ filesRoot.get()?.isNotEmpty() == true -> {
+ files.clear()
+ filesRoot.set("")
+ runOnUiThread {
+ binding.tunnelList.requestFocus()
+ }
+ }
+ }
+ }
+
+ private suspend fun updateStats() {
+ binding.tunnelList.forEach { viewItem ->
+ val listItem = DataBindingUtil.findBinding<TvTunnelListItemBinding>(viewItem)
+ ?: return@forEach
+ try {
+ val tunnel = listItem.item!!
+ if (tunnel.state != Tunnel.State.UP || isDeleting.get()) {
+ throw Exception()
+ }
+ val statistics = tunnel.getStatisticsAsync()
+ val rx = statistics.totalRx()
+ val tx = statistics.totalTx()
+ listItem.tunnelTransfer.text = getString(R.string.transfer_rx_tx, QuantityFormatter.formatBytes(rx), QuantityFormatter.formatBytes(tx))
+ listItem.tunnelTransfer.visibility = View.VISIBLE
+ } catch (_: Throwable) {
+ listItem.tunnelTransfer.visibility = View.GONE
+ listItem.tunnelTransfer.text = ""
+ }
+ }
+ }
+
+ class KeyedFile(val file: File, private val forcedKey: String? = null) : Keyed<String> {
+ override val key: String
+ get() = forcedKey ?: if (file.isDirectory) "${file.name}/" else file.name
+ }
+
+ private class SlatedSpanSizeLookup(private val gridManager: GridLayoutManager) : SpanSizeLookup() {
+ private val originalHeight = gridManager.spanCount
+ private var newWidth = 0
+ private lateinit var sizeMap: Array<IntArray?>
+
+ private fun emptyUnderIndex(index: Int, size: Int): Int {
+ sizeMap[size - 1]?.let { return it[index] }
+ val sizes = IntArray(size)
+ val oh = originalHeight
+ val nw = newWidth
+ var empties = 0
+ for (i in 0 until size) {
+ val ox = (i + empties) / oh
+ val oy = (i + empties) % oh
+ var empty = 0
+ for (j in oy + 1 until oh) {
+ val ni = nw * j + ox
+ if (ni < size)
+ break
+ empty++
+ }
+ empties += empty
+ sizes[i] = empty
+ }
+ sizeMap[size - 1] = sizes
+ return sizes[index]
+ }
+
+ override fun getSpanSize(position: Int): Int {
+ if (newWidth == 0) {
+ val child = gridManager.getChildAt(0) ?: return 1
+ if (child.width == 0) return 1
+ newWidth = gridManager.width / child.width
+ sizeMap = Array(originalHeight * newWidth - 1) { null }
+ }
+ val total = gridManager.itemCount
+ if (total >= originalHeight * newWidth || total == 0)
+ return 1
+ return emptyUnderIndex(position, total) + 1
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/TvMainActivity"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt
new file mode 100644
index 00000000..7336e78f
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.configStore
+
+import com.wireguard.config.Config
+
+/**
+ * Interface for persistent storage providers for WireGuard configurations.
+ */
+interface ConfigStore {
+ /**
+ * Create a persistent tunnel, which must have a unique name within the persistent storage
+ * medium.
+ *
+ * @param name The name of the tunnel to create.
+ * @param config Configuration for the new tunnel.
+ * @return The configuration that was actually saved to persistent storage.
+ */
+ @Throws(Exception::class)
+ fun create(name: String, config: Config): Config
+
+ /**
+ * Delete a persistent tunnel.
+ *
+ * @param name The name of the tunnel to delete.
+ */
+ @Throws(Exception::class)
+ fun delete(name: String)
+
+ /**
+ * Enumerate the names of tunnels present in persistent storage.
+ *
+ * @return The set of present tunnel names.
+ */
+ fun enumerate(): Set<String>
+
+ /**
+ * Load the configuration for the tunnel given by `name`.
+ *
+ * @param name The identifier for the configuration in persistent storage (i.e. the name of the
+ * tunnel).
+ * @return An in-memory representation of the configuration loaded from persistent storage.
+ */
+ @Throws(Exception::class)
+ fun load(name: String): Config
+
+ /**
+ * Rename the configuration for the tunnel given by `name`.
+ *
+ * @param name The identifier for the existing configuration in persistent storage.
+ * @param replacement The new identifier for the configuration in persistent storage.
+ */
+ @Throws(Exception::class)
+ fun rename(name: String, replacement: String)
+
+ /**
+ * Save the configuration for an existing tunnel given by `name`.
+ *
+ * @param name The identifier for the configuration in persistent storage (i.e. the name of
+ * the tunnel).
+ * @param config An updated configuration object for the tunnel.
+ * @return The configuration that was actually saved to persistent storage.
+ */
+ @Throws(Exception::class)
+ fun save(name: String, config: Config): Config
+}
diff --git a/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt
new file mode 100644
index 00000000..17e3221b
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.configStore
+
+import android.content.Context
+import android.util.Log
+import com.wireguard.android.R
+import com.wireguard.config.BadConfigException
+import com.wireguard.config.Config
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+
+/**
+ * Configuration store that uses a `wg-quick`-style file for each configured tunnel.
+ */
+class FileConfigStore(private val context: Context) : ConfigStore {
+ @Throws(IOException::class)
+ override fun create(name: String, config: Config): Config {
+ Log.d(TAG, "Creating configuration for tunnel $name")
+ val file = fileFor(name)
+ if (!file.createNewFile())
+ throw IOException(context.getString(R.string.config_file_exists_error, file.name))
+ FileOutputStream(file, false).use { it.write(config.toWgQuickString().toByteArray(StandardCharsets.UTF_8)) }
+ return config
+ }
+
+ @Throws(IOException::class)
+ override fun delete(name: String) {
+ Log.d(TAG, "Deleting configuration for tunnel $name")
+ val file = fileFor(name)
+ if (!file.delete())
+ throw IOException(context.getString(R.string.config_delete_error, file.name))
+ }
+
+ override fun enumerate(): Set<String> {
+ return context.fileList()
+ .filter { it.endsWith(".conf") }
+ .map { it.substring(0, it.length - ".conf".length) }
+ .toSet()
+ }
+
+ private fun fileFor(name: String): File {
+ return File(context.filesDir, "$name.conf")
+ }
+
+ @Throws(BadConfigException::class, IOException::class)
+ override fun load(name: String): Config {
+ FileInputStream(fileFor(name)).use { stream -> return Config.parse(stream) }
+ }
+
+ @Throws(IOException::class)
+ override fun rename(name: String, replacement: String) {
+ Log.d(TAG, "Renaming configuration for tunnel $name to $replacement")
+ val file = fileFor(name)
+ val replacementFile = fileFor(replacement)
+ if (!replacementFile.createNewFile()) throw IOException(context.getString(R.string.config_exists_error, replacement))
+ if (!file.renameTo(replacementFile)) {
+ if (!replacementFile.delete()) Log.w(TAG, "Couldn't delete marker file for new name $replacement")
+ throw IOException(context.getString(R.string.config_rename_error, file.name))
+ }
+ }
+
+ @Throws(IOException::class)
+ override fun save(name: String, config: Config): Config {
+ Log.d(TAG, "Saving configuration for tunnel $name")
+ val file = fileFor(name)
+ if (!file.isFile)
+ throw FileNotFoundException(context.getString(R.string.config_not_found_error, file.name))
+ FileOutputStream(file, false).use { stream -> stream.write(config.toWgQuickString().toByteArray(StandardCharsets.UTF_8)) }
+ return config
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/FileConfigStore"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt
new file mode 100644
index 00000000..afba41cb
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import android.text.InputFilter
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.databinding.BindingAdapter
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ObservableList
+import androidx.databinding.ViewDataBinding
+import androidx.databinding.adapters.ListenerUtil
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.wireguard.android.BR
+import com.wireguard.android.R
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler
+import com.wireguard.android.widget.ToggleSwitch
+import com.wireguard.android.widget.ToggleSwitch.OnBeforeCheckedChangeListener
+import com.wireguard.android.widget.TvCardView
+import com.wireguard.config.Attribute
+import com.wireguard.config.InetNetwork
+import java.net.InetAddress
+import java.util.Optional
+
+/**
+ * Static methods for use by generated code in the Android data binding library.
+ */
+object BindingAdapters {
+ @JvmStatic
+ @BindingAdapter("checked")
+ fun setChecked(view: ToggleSwitch, checked: Boolean) {
+ view.setCheckedInternal(checked)
+ }
+
+ @JvmStatic
+ @BindingAdapter("filter")
+ fun setFilter(view: TextView, filter: InputFilter) {
+ view.filters = arrayOf(filter)
+ }
+
+ @JvmStatic
+ @BindingAdapter("items", "layout", "fragment")
+ fun <E> setItems(
+ view: LinearLayout,
+ oldList: ObservableList<E>?, oldLayoutId: Int, @Suppress("UNUSED_PARAMETER") oldFragment: Fragment?,
+ newList: ObservableList<E>?, newLayoutId: Int, newFragment: Fragment?
+ ) {
+ if (oldList === newList && oldLayoutId == newLayoutId)
+ return
+ var listener: ItemChangeListener<E>? = ListenerUtil.getListener(view, R.id.item_change_listener)
+ // If the layout changes, any existing listener must be replaced.
+ if (listener != null && oldList != null && oldLayoutId != newLayoutId) {
+ listener.setList(null)
+ listener = null
+ // Stop tracking the old listener.
+ ListenerUtil.trackListener<Any?>(view, null, R.id.item_change_listener)
+ }
+ // Avoid adding a listener when there is no new list or layout.
+ if (newList == null || newLayoutId == 0)
+ return
+ if (listener == null) {
+ listener = ItemChangeListener(view, newLayoutId, newFragment)
+ ListenerUtil.trackListener(view, listener, R.id.item_change_listener)
+ }
+ // Either the list changed, or this is an entirely new listener because the layout changed.
+ listener.setList(newList)
+ }
+
+ @JvmStatic
+ @BindingAdapter("items", "layout")
+ fun <E> setItems(
+ view: LinearLayout,
+ oldList: Iterable<E>?, oldLayoutId: Int,
+ newList: Iterable<E>?, newLayoutId: Int
+ ) {
+ if (oldList === newList && oldLayoutId == newLayoutId)
+ return
+ view.removeAllViews()
+ if (newList == null)
+ return
+ val layoutInflater = LayoutInflater.from(view.context)
+ for (item in newList) {
+ val binding = DataBindingUtil.inflate<ViewDataBinding>(layoutInflater, newLayoutId, view, false)
+ binding.setVariable(BR.collection, newList)
+ binding.setVariable(BR.item, item)
+ binding.executePendingBindings()
+ view.addView(binding.root)
+ }
+ }
+
+ @JvmStatic
+ @BindingAdapter(requireAll = false, value = ["items", "layout", "configurationHandler"])
+ fun <K, E : Keyed<out K>> setItems(
+ view: RecyclerView,
+ oldList: ObservableKeyedArrayList<K, E>?, oldLayoutId: Int,
+ @Suppress("UNUSED_PARAMETER") oldRowConfigurationHandler: RowConfigurationHandler<*, *>?,
+ newList: ObservableKeyedArrayList<K, E>?, newLayoutId: Int,
+ newRowConfigurationHandler: RowConfigurationHandler<*, *>?
+ ) {
+ if (view.layoutManager == null)
+ view.layoutManager = LinearLayoutManager(view.context, RecyclerView.VERTICAL, false)
+ if (oldList === newList && oldLayoutId == newLayoutId)
+ return
+ // The ListAdapter interface is not generic, so this cannot be checked.
+ @Suppress("UNCHECKED_CAST") var adapter = view.adapter as? ObservableKeyedRecyclerViewAdapter<K, E>?
+ // If the layout changes, any existing adapter must be replaced.
+ if (adapter != null && oldList != null && oldLayoutId != newLayoutId) {
+ adapter.setList(null)
+ adapter = null
+ }
+ // Avoid setting an adapter when there is no new list or layout.
+ if (newList == null || newLayoutId == 0)
+ return
+ if (adapter == null) {
+ adapter = ObservableKeyedRecyclerViewAdapter(view.context, newLayoutId, newList)
+ view.adapter = adapter
+ }
+ adapter.setRowConfigurationHandler(newRowConfigurationHandler)
+ // Either the list changed, or this is an entirely new listener because the layout changed.
+ adapter.setList(newList)
+ }
+
+ @JvmStatic
+ @BindingAdapter("onBeforeCheckedChanged")
+ fun setOnBeforeCheckedChanged(
+ view: ToggleSwitch,
+ listener: OnBeforeCheckedChangeListener?
+ ) {
+ view.setOnBeforeCheckedChangeListener(listener)
+ }
+
+ @JvmStatic
+ @BindingAdapter("onFocusChange")
+ fun setOnFocusChange(
+ view: EditText,
+ listener: View.OnFocusChangeListener?
+ ) {
+ view.onFocusChangeListener = listener
+ }
+
+ @JvmStatic
+ @BindingAdapter("android:text")
+ fun setOptionalText(view: TextView, text: Optional<*>?) {
+ view.text = text?.map { it.toString() }?.orElse("") ?: ""
+ }
+
+ @JvmStatic
+ @BindingAdapter("android:text")
+ fun setInetNetworkSetText(view: TextView, networks: Iterable<InetNetwork?>?) {
+ view.text = if (networks != null) Attribute.join(networks) else ""
+ }
+
+ @JvmStatic
+ @BindingAdapter("android:text")
+ fun setInetAddressSetText(view: TextView, addresses: Iterable<InetAddress?>?) {
+ view.text = if (addresses != null) Attribute.join(addresses.map { it?.hostAddress }) else ""
+ }
+
+ @JvmStatic
+ @BindingAdapter("android:text")
+ fun setStringSetText(view: TextView, strings: Iterable<String?>?) {
+ view.text = if (strings != null) Attribute.join(strings) else ""
+ }
+
+ @JvmStatic
+ fun tryParseInt(s: String?): Int {
+ if (s == null)
+ return 0
+ return try {
+ Integer.parseInt(s)
+ } catch (_: Throwable) {
+ 0
+ }
+ }
+
+ @JvmStatic
+ @BindingAdapter("isUp")
+ fun setIsUp(card: TvCardView, up: Boolean) {
+ card.isUp = up
+ }
+
+ @JvmStatic
+ @BindingAdapter("isDeleting")
+ fun setIsDeleting(card: TvCardView, deleting: Boolean) {
+ card.isDeleting = deleting
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt
new file mode 100644
index 00000000..da153bbe
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ObservableList
+import androidx.databinding.ViewDataBinding
+import androidx.fragment.app.Fragment
+import com.wireguard.android.BR
+import java.lang.ref.WeakReference
+
+/**
+ * Helper class for binding an ObservableList to the children of a ViewGroup.
+ */
+internal class ItemChangeListener<T>(private val container: ViewGroup, private val layoutId: Int, private val fragment: Fragment?) {
+ private val callback = OnListChangedCallback(this)
+ private val layoutInflater: LayoutInflater = LayoutInflater.from(container.context)
+ private var list: ObservableList<T>? = null
+
+ private fun getView(position: Int, convertView: View?): View {
+ var binding = if (convertView != null) DataBindingUtil.getBinding<ViewDataBinding>(convertView) else null
+ if (binding == null) {
+ binding = DataBindingUtil.inflate(layoutInflater, layoutId, container, false)
+ }
+ require(list != null) { "Trying to get a view while list is still null" }
+ binding!!.setVariable(BR.collection, list)
+ binding.setVariable(BR.item, list!![position])
+ binding.setVariable(BR.fragment, fragment)
+ binding.executePendingBindings()
+ return binding.root
+ }
+
+ fun setList(newList: ObservableList<T>?) {
+ list?.removeOnListChangedCallback(callback)
+ list = newList
+ if (list != null) {
+ list!!.addOnListChangedCallback(callback)
+ callback.onChanged(list!!)
+ } else {
+ container.removeAllViews()
+ }
+ }
+
+ private class OnListChangedCallback<T> constructor(listener: ItemChangeListener<T>) : ObservableList.OnListChangedCallback<ObservableList<T>>() {
+ private val weakListener: WeakReference<ItemChangeListener<T>> = WeakReference(listener)
+
+ override fun onChanged(sender: ObservableList<T>) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ // TODO: recycle views
+ listener.container.removeAllViews()
+ for (i in sender.indices)
+ listener.container.addView(listener.getView(i, null))
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ override fun onItemRangeChanged(
+ sender: ObservableList<T>, positionStart: Int,
+ itemCount: Int
+ ) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ for (i in positionStart until positionStart + itemCount) {
+ val child = listener.container.getChildAt(i)
+ listener.container.removeViewAt(i)
+ listener.container.addView(listener.getView(i, child))
+ }
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ override fun onItemRangeInserted(
+ sender: ObservableList<T>, positionStart: Int,
+ itemCount: Int
+ ) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ for (i in positionStart until positionStart + itemCount)
+ listener.container.addView(listener.getView(i, null))
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ override fun onItemRangeMoved(
+ sender: ObservableList<T>, fromPosition: Int,
+ toPosition: Int, itemCount: Int
+ ) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ val views = arrayOfNulls<View>(itemCount)
+ for (i in 0 until itemCount) views[i] = listener.container.getChildAt(fromPosition + i)
+ listener.container.removeViews(fromPosition, itemCount)
+ for (i in 0 until itemCount) listener.container.addView(views[i], toPosition + i)
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ override fun onItemRangeRemoved(
+ sender: ObservableList<T>, positionStart: Int,
+ itemCount: Int
+ ) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ listener.container.removeViews(positionStart, itemCount)
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/Keyed.kt b/ui/src/main/java/com/wireguard/android/databinding/Keyed.kt
new file mode 100644
index 00000000..f91581d0
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/Keyed.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+/**
+ * Interface for objects that have a identifying key of the given type.
+ */
+interface Keyed<K> {
+ val key: K
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt
new file mode 100644
index 00000000..947644b3
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import androidx.databinding.ObservableArrayList
+
+/**
+ * ArrayList that allows looking up elements by some key property. As the key property must always
+ * be retrievable, this list cannot hold `null` elements. Because this class places no
+ * restrictions on the order or duplication of keys, lookup by key, as well as all list modification
+ * operations, require O(n) time.
+ */
+open class ObservableKeyedArrayList<K, E : Keyed<out K>> : ObservableArrayList<E>() {
+ fun containsKey(key: K) = indexOfKey(key) >= 0
+
+ operator fun get(key: K): E? {
+ val index = indexOfKey(key)
+ return if (index >= 0) get(index) else null
+ }
+
+ open fun indexOfKey(key: K): Int {
+ val iterator = listIterator()
+ while (iterator.hasNext()) {
+ val index = iterator.nextIndex()
+ if (iterator.next()!!.key == key)
+ return index
+ }
+ return -1
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt
new file mode 100644
index 00000000..91223ad1
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ObservableList
+import androidx.databinding.ViewDataBinding
+import androidx.recyclerview.widget.RecyclerView
+import com.wireguard.android.BR
+import java.lang.ref.WeakReference
+
+/**
+ * A generic `RecyclerView.Adapter` backed by a `ObservableKeyedArrayList`.
+ */
+class ObservableKeyedRecyclerViewAdapter<K, E : Keyed<out K>> internal constructor(
+ context: Context, private val layoutId: Int,
+ list: ObservableKeyedArrayList<K, E>?
+) : RecyclerView.Adapter<ObservableKeyedRecyclerViewAdapter.ViewHolder>() {
+ private val callback = OnListChangedCallback(this)
+ private val layoutInflater: LayoutInflater = LayoutInflater.from(context)
+ private var list: ObservableKeyedArrayList<K, E>? = null
+ private var rowConfigurationHandler: RowConfigurationHandler<ViewDataBinding, Any>? = null
+
+ private fun getItem(position: Int): E? = if (list == null || position < 0 || position >= list!!.size) null else list?.get(position)
+
+ override fun getItemCount() = list?.size ?: 0
+
+ override fun getItemId(position: Int) = (getKey(position)?.hashCode() ?: -1).toLong()
+
+ private fun getKey(position: Int): K? = getItem(position)?.key
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.binding.setVariable(BR.collection, list)
+ holder.binding.setVariable(BR.key, getKey(position))
+ holder.binding.setVariable(BR.item, getItem(position))
+ holder.binding.executePendingBindings()
+ if (rowConfigurationHandler != null) {
+ val item = getItem(position)
+ if (item != null) {
+ rowConfigurationHandler?.onConfigureRow(holder.binding, item, position)
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(DataBindingUtil.inflate(layoutInflater, layoutId, parent, false))
+
+ fun setList(newList: ObservableKeyedArrayList<K, E>?) {
+ list?.removeOnListChangedCallback(callback)
+ list = newList
+ list?.addOnListChangedCallback(callback)
+ notifyDataSetChanged()
+ }
+
+ fun setRowConfigurationHandler(rowConfigurationHandler: RowConfigurationHandler<*, *>?) {
+ @Suppress("UNCHECKED_CAST")
+ this.rowConfigurationHandler = rowConfigurationHandler as? RowConfigurationHandler<ViewDataBinding, Any>
+ }
+
+ interface RowConfigurationHandler<B : ViewDataBinding, T> {
+ fun onConfigureRow(binding: B, item: T, position: Int)
+ }
+
+ private class OnListChangedCallback<E : Keyed<*>> constructor(adapter: ObservableKeyedRecyclerViewAdapter<*, E>) : ObservableList.OnListChangedCallback<ObservableList<E>>() {
+ private val weakAdapter: WeakReference<ObservableKeyedRecyclerViewAdapter<*, E>> = WeakReference(adapter)
+
+ override fun onChanged(sender: ObservableList<E>) {
+ val adapter = weakAdapter.get()
+ if (adapter != null)
+ adapter.notifyDataSetChanged()
+ else
+ sender.removeOnListChangedCallback(this)
+ }
+
+ override fun onItemRangeChanged(sender: ObservableList<E>, positionStart: Int,
+ itemCount: Int) {
+ onChanged(sender)
+ }
+
+ override fun onItemRangeInserted(sender: ObservableList<E>, positionStart: Int,
+ itemCount: Int) {
+ onChanged(sender)
+ }
+
+ override fun onItemRangeMoved(sender: ObservableList<E>, fromPosition: Int,
+ toPosition: Int, itemCount: Int) {
+ onChanged(sender)
+ }
+
+ override fun onItemRangeRemoved(sender: ObservableList<E>, positionStart: Int,
+ itemCount: Int) {
+ onChanged(sender)
+ }
+
+ }
+
+ class ViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
+
+ init {
+ setList(list)
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt b/ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt
new file mode 100644
index 00000000..a09d726f
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import java.util.AbstractList
+import java.util.Collections
+import java.util.Comparator
+import java.util.Spliterator
+
+/**
+ * KeyedArrayList that enforces uniqueness and sorted order across the set of keys. This class uses
+ * binary search to improve lookup and replacement times to O(log(n)). However, due to the
+ * array-based nature of this class, insertion and removal of elements with anything but the largest
+ * key still require O(n) time.
+ */
+class ObservableSortedKeyedArrayList<K, E : Keyed<out K>>(private val comparator: Comparator<in K>) : ObservableKeyedArrayList<K, E>() {
+ @Transient
+ private val keyList = KeyList(this)
+
+ override fun add(element: E): Boolean {
+ val insertionPoint = getInsertionPoint(element)
+ if (insertionPoint < 0) {
+ // Skipping insertion is non-destructive if the new and existing objects are the same.
+ if (element === get(-insertionPoint - 1)) return false
+ throw IllegalArgumentException("Element with same key already exists in list")
+ }
+ super.add(insertionPoint, element)
+ return true
+ }
+
+ override fun add(index: Int, element: E) {
+ val insertionPoint = getInsertionPoint(element)
+ require(insertionPoint >= 0) { "Element with same key already exists in list" }
+ if (insertionPoint != index) throw IndexOutOfBoundsException("Wrong index given for element")
+ super.add(index, element)
+ }
+
+ override fun addAll(elements: Collection<E>): Boolean {
+ var didChange = false
+ for (e in elements) {
+ if (add(e))
+ didChange = true
+ }
+ return didChange
+ }
+
+ override fun addAll(index: Int, elements: Collection<E>): Boolean {
+ var i = index
+ for (e in elements)
+ add(i++, e)
+ return true
+ }
+
+ private fun getInsertionPoint(e: E) = -Collections.binarySearch(keyList, e.key, comparator) - 1
+
+ override fun indexOfKey(key: K): Int {
+ val index = Collections.binarySearch(keyList, key, comparator)
+ return if (index >= 0) index else -1
+ }
+
+ override fun set(index: Int, element: E): E {
+ val order = comparator.compare(element.key, get(index).key)
+ if (order != 0) {
+ // Allow replacement if the new key would be inserted adjacent to the replaced element.
+ val insertionPoint = getInsertionPoint(element)
+ if (insertionPoint < index || insertionPoint > index + 1)
+ throw IndexOutOfBoundsException("Wrong index given for element")
+ }
+ return super.set(index, element)
+ }
+
+ private class KeyList<K, E : Keyed<out K>>(private val list: ObservableSortedKeyedArrayList<K, E>) : AbstractList<K>(), Set<K> {
+ override fun get(index: Int): K = list[index].key
+
+ override val size
+ get() = list.size
+
+ override fun spliterator(): Spliterator<K> = super<AbstractList>.spliterator()
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
new file mode 100644
index 00000000..78b9f3d7
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.pm.PackageManager
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
+import androidx.core.os.bundleOf
+import androidx.fragment.app.setFragmentResult
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.wireguard.android.R
+import com.wireguard.android.util.resolveAttribute
+
+class AddTunnelsSheet : BottomSheetDialogFragment() {
+
+ private var behavior: BottomSheetBehavior<FrameLayout>? = null
+ private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {
+ }
+
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
+ dismiss()
+ }
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ if (savedInstanceState != null) dismiss()
+ val view = inflater.inflate(R.layout.add_tunnels_bottom_sheet, container, false)
+ if (activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) != true) {
+ val qrcode = view.findViewById<View>(R.id.create_from_qrcode)
+ qrcode.isEnabled = false
+ qrcode.visibility = View.GONE
+ }
+ return view
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ view.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ val dialog = dialog as BottomSheetDialog? ?: return
+ behavior = dialog.behavior
+ behavior?.apply {
+ state = BottomSheetBehavior.STATE_EXPANDED
+ peekHeight = 0
+ addBottomSheetCallback(bottomSheetCallback)
+ }
+ dialog.findViewById<View>(R.id.create_empty)?.setOnClickListener {
+ dismiss()
+ onRequestCreateConfig()
+ }
+ dialog.findViewById<View>(R.id.create_from_file)?.setOnClickListener {
+ dismiss()
+ onRequestImportConfig()
+ }
+ dialog.findViewById<View>(R.id.create_from_qrcode)?.setOnClickListener {
+ dismiss()
+ onRequestScanQRCode()
+ }
+ }
+ })
+ val gradientDrawable = GradientDrawable().apply {
+ setColor(requireContext().resolveAttribute(com.google.android.material.R.attr.colorSurface))
+ }
+ view.background = gradientDrawable
+ }
+
+ override fun dismiss() {
+ super.dismiss()
+ behavior?.removeBottomSheetCallback(bottomSheetCallback)
+ }
+
+ private fun onRequestCreateConfig() {
+ setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_CREATE))
+ }
+
+ private fun onRequestImportConfig() {
+ setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_IMPORT))
+ }
+
+ private fun onRequestScanQRCode() {
+ setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_SCAN))
+ }
+
+ companion object {
+ const val REQUEST_KEY_NEW_TUNNEL = "request_new_tunnel"
+ const val REQUEST_METHOD = "request_method"
+ const val REQUEST_CREATE = "request_create"
+ const val REQUEST_IMPORT = "request_import"
+ const val REQUEST_SCAN = "request_scan"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt
new file mode 100644
index 00000000..1cd19934
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.Manifest
+import android.app.Dialog
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.PackageInfoFlags
+import android.os.Build
+import android.os.Bundle
+import android.widget.Button
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.core.os.bundleOf
+import androidx.databinding.Observable
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.setFragmentResult
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.tabs.TabLayout
+import com.wireguard.android.BR
+import com.wireguard.android.R
+import com.wireguard.android.databinding.AppListDialogFragmentBinding
+import com.wireguard.android.databinding.ObservableKeyedArrayList
+import com.wireguard.android.model.ApplicationData
+import com.wireguard.android.util.ErrorMessages
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class AppListDialogFragment : DialogFragment() {
+ private val appData = ObservableKeyedArrayList<String, ApplicationData>()
+ private var currentlySelectedApps = emptyList<String>()
+ private var initiallyExcluded = false
+ private var button: Button? = null
+ private var tabs: TabLayout? = null
+
+ private fun loadData() {
+ val activity = activity ?: return
+ val pm = activity.packageManager
+ lifecycleScope.launch(Dispatchers.Default) {
+ try {
+ val applicationData: MutableList<ApplicationData> = ArrayList()
+ withContext(Dispatchers.IO) {
+ val packageInfos = getPackagesHoldingPermissions(pm, arrayOf(Manifest.permission.INTERNET))
+ packageInfos.forEach {
+ val packageName = it.packageName
+ val appInfo = it.applicationInfo
+ val appData =
+ ApplicationData(appInfo.loadIcon(pm), appInfo.loadLabel(pm).toString(), packageName, currentlySelectedApps.contains(packageName))
+ applicationData.add(appData)
+ appData.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
+ if (propertyId == BR.selected)
+ setButtonText()
+ }
+ })
+ }
+ }
+ applicationData.sortWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
+ withContext(Dispatchers.Main.immediate) {
+ appData.clear()
+ appData.addAll(applicationData)
+ setButtonText()
+ }
+ } catch (e: Throwable) {
+ withContext(Dispatchers.Main.immediate) {
+ val error = ErrorMessages[e]
+ val message = activity.getString(R.string.error_fetching_apps, error)
+ Toast.makeText(activity, message, Toast.LENGTH_LONG).show()
+ dismissAllowingStateLoss()
+ }
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ currentlySelectedApps = (arguments?.getStringArrayList(KEY_SELECTED_APPS) ?: emptyList())
+ initiallyExcluded = arguments?.getBoolean(KEY_IS_EXCLUDED) ?: true
+ }
+
+ private fun getPackagesHoldingPermissions(pm: PackageManager, permissions: Array<String>): List<PackageInfo> {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ pm.getPackagesHoldingPermissions(permissions, PackageInfoFlags.of(0L))
+ } else {
+ @Suppress("DEPRECATION")
+ pm.getPackagesHoldingPermissions(permissions, 0)
+ }
+ }
+
+ private fun setButtonText() {
+ val numSelected = appData.count { it.isSelected }
+ button?.text = if (numSelected == 0)
+ getString(R.string.use_all_applications)
+ else when (tabs?.selectedTabPosition) {
+ 0 -> resources.getQuantityString(R.plurals.exclude_n_applications, numSelected, numSelected)
+ 1 -> resources.getQuantityString(R.plurals.include_n_applications, numSelected, numSelected)
+ else -> null
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val alertDialogBuilder = MaterialAlertDialogBuilder(requireActivity())
+ val binding = AppListDialogFragmentBinding.inflate(requireActivity().layoutInflater, null, false)
+ binding.executePendingBindings()
+ alertDialogBuilder.setView(binding.root)
+ tabs = binding.tabs
+ tabs?.apply {
+ selectTab(binding.tabs.getTabAt(if (initiallyExcluded) 0 else 1))
+ addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
+ override fun onTabReselected(tab: TabLayout.Tab?) = Unit
+ override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
+ override fun onTabSelected(tab: TabLayout.Tab?) = setButtonText()
+ })
+ }
+ alertDialogBuilder.setPositiveButton(" ") { _, _ -> setSelectionAndDismiss() }
+ alertDialogBuilder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
+ alertDialogBuilder.setNeutralButton(R.string.toggle_all) { _, _ -> }
+ binding.fragment = this
+ binding.appData = appData
+ loadData()
+ val dialog = alertDialogBuilder.create()
+ dialog.setOnShowListener {
+ button = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
+ setButtonText()
+ dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { _ ->
+ val selectAll = appData.none { it.isSelected }
+ appData.forEach {
+ it.isSelected = selectAll
+ }
+ }
+ }
+ return dialog
+ }
+
+ private fun setSelectionAndDismiss() {
+ val selectedApps: MutableList<String> = ArrayList()
+ for (data in appData) {
+ if (data.isSelected) {
+ selectedApps.add(data.packageName)
+ }
+ }
+ setFragmentResult(
+ REQUEST_SELECTION, bundleOf(
+ KEY_SELECTED_APPS to selectedApps.toTypedArray(),
+ KEY_IS_EXCLUDED to (tabs?.selectedTabPosition == 0)
+ )
+ )
+ dismiss()
+ }
+
+ companion object {
+ const val KEY_SELECTED_APPS = "selected_apps"
+ const val KEY_IS_EXCLUDED = "is_excluded"
+ const val REQUEST_SELECTION = "request_selection"
+
+ fun newInstance(selectedApps: ArrayList<String?>?, isExcluded: Boolean): AppListDialogFragment {
+ val extras = Bundle()
+ extras.putStringArrayList(KEY_SELECTED_APPS, selectedApps)
+ extras.putBoolean(KEY_IS_EXCLUDED, isExcluded)
+ val fragment = AppListDialogFragment()
+ fragment.arguments = extras
+ return fragment
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt
new file mode 100644
index 00000000..d5c1723f
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.Context
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ViewDataBinding
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.snackbar.Snackbar
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.activity.BaseActivity
+import com.wireguard.android.activity.BaseActivity.OnSelectedTunnelChangedListener
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.TunnelDetailFragmentBinding
+import com.wireguard.android.databinding.TunnelListItemBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.ErrorMessages
+import kotlinx.coroutines.launch
+
+/**
+ * Base class for fragments that need to know the currently-selected tunnel. Only does anything when
+ * attached to a `BaseActivity`.
+ */
+abstract class BaseFragment : Fragment(), OnSelectedTunnelChangedListener {
+ private var pendingTunnel: ObservableTunnel? = null
+ private var pendingTunnelUp: Boolean? = null
+ private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ val tunnel = pendingTunnel
+ val checked = pendingTunnelUp
+ if (tunnel != null && checked != null)
+ setTunnelStateWithPermissionsResult(tunnel, checked)
+ pendingTunnel = null
+ pendingTunnelUp = null
+ }
+
+ protected var selectedTunnel: ObservableTunnel?
+ get() = (activity as? BaseActivity)?.selectedTunnel
+ protected set(tunnel) {
+ (activity as? BaseActivity)?.selectedTunnel = tunnel
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (activity as? BaseActivity)?.addOnSelectedTunnelChangedListener(this)
+ }
+
+ override fun onDetach() {
+ (activity as? BaseActivity)?.removeOnSelectedTunnelChangedListener(this)
+ super.onDetach()
+ }
+
+ fun setTunnelState(view: View, checked: Boolean) {
+ val tunnel = when (val binding = DataBindingUtil.findBinding<ViewDataBinding>(view)) {
+ is TunnelDetailFragmentBinding -> binding.tunnel
+ is TunnelListItemBinding -> binding.item
+ else -> return
+ } ?: return
+ val activity = activity ?: return
+ activity.lifecycleScope.launch {
+ if (Application.getBackend() is GoBackend) {
+ try {
+ val intent = GoBackend.VpnService.prepare(activity)
+ if (intent != null) {
+ pendingTunnel = tunnel
+ pendingTunnelUp = checked
+ permissionActivityResultLauncher.launch(intent)
+ return@launch
+ }
+ } catch (e: Throwable) {
+ val message = activity.getString(R.string.error_prepare, ErrorMessages[e])
+ Snackbar.make(view, message, Snackbar.LENGTH_LONG)
+ .setAnchorView(view.findViewById(R.id.create_fab))
+ .show()
+ Log.e(TAG, message, e)
+ }
+ }
+ setTunnelStateWithPermissionsResult(tunnel, checked)
+ }
+ }
+
+ private fun setTunnelStateWithPermissionsResult(tunnel: ObservableTunnel, checked: Boolean) {
+ val activity = activity ?: return
+ activity.lifecycleScope.launch {
+ try {
+ tunnel.setStateAsync(Tunnel.State.of(checked))
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val messageResId = if (checked) R.string.error_up else R.string.error_down
+ val message = activity.getString(messageResId, error)
+ val view = view
+ if (view != null)
+ Snackbar.make(view, message, Snackbar.LENGTH_LONG)
+ .setAnchorView(view.findViewById(R.id.create_fab))
+ .show()
+ else
+ Toast.makeText(activity, message, Toast.LENGTH_LONG).show()
+ Log.e(TAG, message, e)
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/BaseFragment"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt
new file mode 100644
index 00000000..34c96505
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.databinding.ConfigNamingDialogFragmentBinding
+import com.wireguard.config.BadConfigException
+import com.wireguard.config.Config
+import kotlinx.coroutines.launch
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+
+class ConfigNamingDialogFragment : DialogFragment() {
+ private var binding: ConfigNamingDialogFragmentBinding? = null
+ private var config: Config? = null
+
+ private fun createTunnelAndDismiss() {
+ val binding = binding ?: return
+ val activity = activity ?: return
+ val name = binding.tunnelNameText.text.toString()
+ activity.lifecycleScope.launch {
+ try {
+ Application.getTunnelManager().create(name, config)
+ dismiss()
+ } catch (e: Throwable) {
+ binding.tunnelNameTextLayout.error = e.message
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val configText = requireArguments().getString(KEY_CONFIG_TEXT)
+ val configBytes = configText!!.toByteArray(StandardCharsets.UTF_8)
+ config = try {
+ Config.parse(ByteArrayInputStream(configBytes))
+ } catch (e: Throwable) {
+ when (e) {
+ is BadConfigException, is IOException -> throw IllegalArgumentException("Invalid config passed to ${javaClass.simpleName}", e)
+ else -> throw e
+ }
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val activity = requireActivity()
+ val alertDialogBuilder = MaterialAlertDialogBuilder(activity)
+ alertDialogBuilder.setTitle(R.string.import_from_qr_code)
+ binding = ConfigNamingDialogFragmentBinding.inflate(activity.layoutInflater, null, false)
+ binding?.apply {
+ executePendingBindings()
+ alertDialogBuilder.setView(root)
+ }
+ alertDialogBuilder.setPositiveButton(R.string.create_tunnel) { _, _ -> createTunnelAndDismiss() }
+ alertDialogBuilder.setNegativeButton(R.string.cancel) { _, _ -> dismiss() }
+ val dialog = alertDialogBuilder.create()
+ dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
+ return dialog
+ }
+
+ companion object {
+ private const val KEY_CONFIG_TEXT = "config_text"
+
+ fun newInstance(configText: String?): ConfigNamingDialogFragment {
+ val extras = Bundle()
+ extras.putString(KEY_CONFIG_TEXT, configText)
+ val fragment = ConfigNamingDialogFragment()
+ fragment.arguments = extras
+ return fragment
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt
new file mode 100644
index 00000000..81d8e8c6
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.MenuProvider
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import com.wireguard.android.R
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.TunnelDetailFragmentBinding
+import com.wireguard.android.databinding.TunnelDetailPeerBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.QuantityFormatter
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * Fragment that shows details about a specific tunnel.
+ */
+class TunnelDetailFragment : BaseFragment(), MenuProvider {
+ private var binding: TunnelDetailFragmentBinding? = null
+ private var lastState = Tunnel.State.TOGGLE
+ private var timerActive = true
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return false
+ }
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.tunnel_detail, menu)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ super.onCreateView(inflater, container, savedInstanceState)
+ binding = TunnelDetailFragmentBinding.inflate(inflater, container, false)
+ binding?.executePendingBindings()
+ return binding?.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
+ }
+
+ override fun onDestroyView() {
+ binding = null
+ super.onDestroyView()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ timerActive = true
+ lifecycleScope.launch {
+ while (timerActive) {
+ updateStats()
+ delay(1000)
+ }
+ }
+ }
+
+ override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) {
+ val binding = binding ?: return
+ binding.tunnel = newTunnel
+ if (newTunnel == null) {
+ binding.config = null
+ } else {
+ lifecycleScope.launch {
+ try {
+ binding.config = newTunnel.getConfigAsync()
+ } catch (_: Throwable) {
+ binding.config = null
+ }
+ }
+ }
+ lastState = Tunnel.State.TOGGLE
+ lifecycleScope.launch { updateStats() }
+ }
+
+ override fun onStop() {
+ timerActive = false
+ super.onStop()
+ }
+
+ override fun onViewStateRestored(savedInstanceState: Bundle?) {
+ binding ?: return
+ binding!!.fragment = this
+ onSelectedTunnelChanged(null, selectedTunnel)
+ super.onViewStateRestored(savedInstanceState)
+ }
+
+ private suspend fun updateStats() {
+ val binding = binding ?: return
+ val tunnel = binding.tunnel ?: return
+ if (!isResumed) return
+ val state = tunnel.state
+ if (state != Tunnel.State.UP && lastState == state) return
+ lastState = state
+ try {
+ val statistics = tunnel.getStatisticsAsync()
+ for (i in 0 until binding.peersLayout.childCount) {
+ val peer: TunnelDetailPeerBinding = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i))
+ ?: continue
+ val publicKey = peer.item!!.publicKey
+ val peerStats = statistics.peer(publicKey)
+ if (peerStats == null || (peerStats.rxBytes == 0L && peerStats.txBytes == 0L)) {
+ peer.transferLabel.visibility = View.GONE
+ peer.transferText.visibility = View.GONE
+ } else {
+ peer.transferText.text = getString(
+ R.string.transfer_rx_tx,
+ QuantityFormatter.formatBytes(peerStats.rxBytes),
+ QuantityFormatter.formatBytes(peerStats.txBytes)
+ )
+ peer.transferLabel.visibility = View.VISIBLE
+ peer.transferText.visibility = View.VISIBLE
+ }
+ if (peerStats == null || peerStats.latestHandshakeEpochMillis == 0L) {
+ peer.latestHandshakeLabel.visibility = View.GONE
+ peer.latestHandshakeText.visibility = View.GONE
+ } else {
+ peer.latestHandshakeText.text = QuantityFormatter.formatEpochAgo(peerStats.latestHandshakeEpochMillis)
+ peer.latestHandshakeLabel.visibility = View.VISIBLE
+ peer.latestHandshakeText.visibility = View.VISIBLE
+ }
+ }
+ } catch (e: Throwable) {
+ for (i in 0 until binding.peersLayout.childCount) {
+ val peer: TunnelDetailPeerBinding = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i))
+ ?: continue
+ peer.transferLabel.visibility = View.GONE
+ peer.transferText.visibility = View.GONE
+ peer.latestHandshakeLabel.visibility = View.GONE
+ peer.latestHandshakeText.visibility = View.GONE
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt
new file mode 100644
index 00000000..edf4b226
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt
@@ -0,0 +1,333 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.Context
+import android.os.Bundle
+import android.text.InputType
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.view.inputmethod.InputMethodManager
+import android.widget.EditText
+import android.widget.Toast
+import androidx.core.os.BundleCompat
+import androidx.core.view.MenuProvider
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.snackbar.Snackbar
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.TunnelEditorFragmentBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.AdminKnobs
+import com.wireguard.android.util.BiometricAuthenticator
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.viewmodel.ConfigProxy
+import com.wireguard.config.Config
+import kotlinx.coroutines.launch
+
+/**
+ * Fragment for editing a WireGuard configuration.
+ */
+class TunnelEditorFragment : BaseFragment(), MenuProvider {
+ private var haveShownKeys = false
+ private var binding: TunnelEditorFragmentBinding? = null
+ private var tunnel: ObservableTunnel? = null
+
+ private fun onConfigLoaded(config: Config) {
+ binding?.config = ConfigProxy(config)
+ }
+
+ private fun onConfigSaved(savedTunnel: Tunnel, throwable: Throwable?) {
+ val ctx = activity ?: Application.get()
+ if (throwable == null) {
+ val message = ctx.getString(R.string.config_save_success, savedTunnel.name)
+ Log.d(TAG, message)
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ onFinished()
+ } else {
+ val error = ErrorMessages[throwable]
+ val message = ctx.getString(R.string.config_save_error, savedTunnel.name, error)
+ Log.e(TAG, message, throwable)
+ val binding = binding
+ if (binding != null)
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show()
+ else
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.config_editor, menu)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ super.onCreateView(inflater, container, savedInstanceState)
+ binding = TunnelEditorFragmentBinding.inflate(inflater, container, false)
+ binding?.apply {
+ executePendingBindings()
+ privateKeyTextLayout.setEndIconOnClickListener { config?.`interface`?.generateKeyPair() }
+ }
+ return binding?.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
+ }
+
+ override fun onDestroyView() {
+ activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ binding = null
+ super.onDestroyView()
+ }
+
+ private fun onFinished() {
+ // Hide the keyboard; it rarely goes away on its own.
+ val activity = activity ?: return
+ val focusedView = activity.currentFocus
+ if (focusedView != null) {
+ val inputManager = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ inputManager?.hideSoftInputFromWindow(
+ focusedView.windowToken,
+ InputMethodManager.HIDE_NOT_ALWAYS
+ )
+ }
+ parentFragmentManager.popBackStackImmediate()
+
+ // If we just made a new one, save it to select the details page.
+ if (selectedTunnel != tunnel)
+ selectedTunnel = tunnel
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ if (menuItem.itemId == R.id.menu_action_save) {
+ binding ?: return false
+ val newConfig = try {
+ binding!!.config!!.resolve()
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val tunnelName = if (tunnel == null) binding!!.name else tunnel!!.name
+ val message = getString(R.string.config_save_error, tunnelName, error)
+ Log.e(TAG, message, e)
+ Snackbar.make(binding!!.mainContainer, error, Snackbar.LENGTH_LONG).show()
+ return false
+ }
+ val activity = requireActivity()
+ activity.lifecycleScope.launch {
+ when {
+ tunnel == null -> {
+ Log.d(TAG, "Attempting to create new tunnel " + binding!!.name)
+ val manager = Application.getTunnelManager()
+ try {
+ onTunnelCreated(manager.create(binding!!.name!!, newConfig), null)
+ } catch (e: Throwable) {
+ onTunnelCreated(null, e)
+ }
+ }
+
+ tunnel!!.name != binding!!.name -> {
+ Log.d(TAG, "Attempting to rename tunnel to " + binding!!.name)
+ try {
+ tunnel!!.setNameAsync(binding!!.name!!)
+ onTunnelRenamed(tunnel!!, newConfig, null)
+ } catch (e: Throwable) {
+ onTunnelRenamed(tunnel!!, newConfig, e)
+ }
+ }
+
+ else -> {
+ Log.d(TAG, "Attempting to save config of " + tunnel!!.name)
+ try {
+ tunnel!!.setConfigAsync(newConfig)
+ onConfigSaved(tunnel!!, null)
+ } catch (e: Throwable) {
+ onConfigSaved(tunnel!!, e)
+ }
+ }
+ }
+ }
+ return true
+ }
+ return false
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ fun onRequestSetExcludedIncludedApplications(view: View?) {
+ if (binding != null) {
+ var isExcluded = true
+ var selectedApps = ArrayList(binding!!.config!!.`interface`.excludedApplications)
+ if (selectedApps.isEmpty()) {
+ selectedApps = ArrayList(binding!!.config!!.`interface`.includedApplications)
+ if (selectedApps.isNotEmpty())
+ isExcluded = false
+ }
+ val fragment = AppListDialogFragment.newInstance(selectedApps, isExcluded)
+ childFragmentManager.setFragmentResultListener(AppListDialogFragment.REQUEST_SELECTION, viewLifecycleOwner) { _, bundle ->
+ requireNotNull(binding) { "Tried to set excluded/included apps while no view was loaded" }
+ val newSelections = requireNotNull(bundle.getStringArray(AppListDialogFragment.KEY_SELECTED_APPS))
+ val excluded = requireNotNull(bundle.getBoolean(AppListDialogFragment.KEY_IS_EXCLUDED))
+ if (excluded) {
+ binding!!.config!!.`interface`.includedApplications.clear()
+ binding!!.config!!.`interface`.excludedApplications.apply {
+ clear()
+ addAll(newSelections)
+ }
+ } else {
+ binding!!.config!!.`interface`.excludedApplications.clear()
+ binding!!.config!!.`interface`.includedApplications.apply {
+ clear()
+ addAll(newSelections)
+ }
+ }
+ }
+ fragment.show(childFragmentManager, null)
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ if (binding != null) outState.putParcelable(KEY_LOCAL_CONFIG, binding!!.config)
+ outState.putString(KEY_ORIGINAL_NAME, if (tunnel == null) null else tunnel!!.name)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onSelectedTunnelChanged(
+ oldTunnel: ObservableTunnel?,
+ newTunnel: ObservableTunnel?
+ ) {
+ tunnel = newTunnel
+ if (binding == null) return
+ binding!!.config = ConfigProxy()
+ if (tunnel != null) {
+ binding!!.name = tunnel!!.name
+ lifecycleScope.launch {
+ try {
+ onConfigLoaded(tunnel!!.getConfigAsync())
+ } catch (_: Throwable) {
+ }
+ }
+ } else {
+ binding!!.name = ""
+ }
+ }
+
+ private fun onTunnelCreated(newTunnel: ObservableTunnel?, throwable: Throwable?) {
+ val ctx = activity ?: Application.get()
+ if (throwable == null) {
+ tunnel = newTunnel
+ val message = ctx.getString(R.string.tunnel_create_success, tunnel!!.name)
+ Log.d(TAG, message)
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ onFinished()
+ } else {
+ val error = ErrorMessages[throwable]
+ val message = ctx.getString(R.string.tunnel_create_error, error)
+ Log.e(TAG, message, throwable)
+ val binding = binding
+ if (binding != null)
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show()
+ else
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private suspend fun onTunnelRenamed(
+ renamedTunnel: ObservableTunnel, newConfig: Config,
+ throwable: Throwable?
+ ) {
+ val ctx = activity ?: Application.get()
+ if (throwable == null) {
+ val message = ctx.getString(R.string.tunnel_rename_success, renamedTunnel.name)
+ Log.d(TAG, message)
+ // Now save the rest of configuration changes.
+ Log.d(TAG, "Attempting to save config of renamed tunnel " + tunnel!!.name)
+ try {
+ renamedTunnel.setConfigAsync(newConfig)
+ onConfigSaved(renamedTunnel, null)
+ } catch (e: Throwable) {
+ onConfigSaved(renamedTunnel, e)
+ }
+ } else {
+ val error = ErrorMessages[throwable]
+ val message = ctx.getString(R.string.tunnel_rename_error, error)
+ Log.e(TAG, message, throwable)
+ val binding = binding
+ if (binding != null)
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show()
+ else
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onViewStateRestored(savedInstanceState: Bundle?) {
+ binding ?: return
+ binding!!.fragment = this
+ if (savedInstanceState == null) {
+ onSelectedTunnelChanged(null, selectedTunnel)
+ } else {
+ tunnel = selectedTunnel
+ val config = BundleCompat.getParcelable(savedInstanceState, KEY_LOCAL_CONFIG, ConfigProxy::class.java)!!
+ val originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME)
+ if (tunnel != null && tunnel!!.name != originalName) onSelectedTunnelChanged(null, tunnel) else binding!!.config = config
+ }
+ super.onViewStateRestored(savedInstanceState)
+ }
+
+ private var showingAuthenticator = false
+
+ fun onKeyClick(view: View) = onKeyFocusChange(view, true)
+
+ fun onKeyFocusChange(view: View, isFocused: Boolean) {
+ if (!isFocused || showingAuthenticator) return
+ val edit = view as? EditText ?: return
+ if (edit.inputType == InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) return
+ if (!haveShownKeys && edit.text.isNotEmpty()) {
+ if (AdminKnobs.disableConfigExport) return
+ showingAuthenticator = true
+ BiometricAuthenticator.authenticate(R.string.biometric_prompt_private_key_title, this) {
+ showingAuthenticator = false
+ when (it) {
+ is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
+ haveShownKeys = true
+ showPrivateKey(edit)
+ }
+
+ is BiometricAuthenticator.Result.Failure -> {
+ Snackbar.make(
+ binding!!.mainContainer,
+ it.message,
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+
+ is BiometricAuthenticator.Result.Cancelled -> {}
+ }
+ }
+ } else {
+ showPrivateKey(edit)
+ }
+ }
+
+ private fun showPrivateKey(edit: EditText) {
+ activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ edit.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
+ }
+
+ companion object {
+ private const val KEY_LOCAL_CONFIG = "local_config"
+ private const val KEY_ORIGINAL_NAME = "original_name"
+ private const val TAG = "WireGuard/TunnelEditorFragment"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt
new file mode 100644
index 00000000..cba7c476
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt
@@ -0,0 +1,342 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.Intent
+import android.content.res.Resources
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.addCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ActionMode
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.snackbar.Snackbar
+import com.google.zxing.qrcode.QRCodeReader
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.activity.TunnelCreatorActivity
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler
+import com.wireguard.android.databinding.TunnelListFragmentBinding
+import com.wireguard.android.databinding.TunnelListItemBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.updater.SnackbarUpdateShower
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.QrCodeFromFileScanner
+import com.wireguard.android.util.TunnelImporter
+import com.wireguard.android.widget.MultiselectableRelativeLayout
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
+
+/**
+ * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels.
+ */
+class TunnelListFragment : BaseFragment() {
+ private val actionModeListener = ActionModeListener()
+ private var actionMode: ActionMode? = null
+ private var backPressedCallback: OnBackPressedCallback? = null
+ private var binding: TunnelListFragmentBinding? = null
+ private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data ->
+ if (data == null) return@registerForActivityResult
+ val activity = activity ?: return@registerForActivityResult
+ val contentResolver = activity.contentResolver ?: return@registerForActivityResult
+ activity.lifecycleScope.launch {
+ if (QrCodeFromFileScanner.validContentType(contentResolver, data)) {
+ try {
+ val qrCodeFromFileScanner = QrCodeFromFileScanner(contentResolver, QRCodeReader())
+ val result = qrCodeFromFileScanner.scan(data)
+ TunnelImporter.importTunnel(parentFragmentManager, result.text) { showSnackbar(it) }
+ } catch (e: Exception) {
+ val error = ErrorMessages[e]
+ val message = Application.get().resources.getString(R.string.import_error, error)
+ Log.e(TAG, message, e)
+ showSnackbar(message)
+ }
+ } else {
+ TunnelImporter.importTunnel(contentResolver, data) { showSnackbar(it) }
+ }
+ }
+ }
+
+ private val qrImportResultLauncher = registerForActivityResult(ScanContract()) { result ->
+ val qrCode = result.contents
+ val activity = activity
+ if (qrCode != null && activity != null) {
+ activity.lifecycleScope.launch { TunnelImporter.importTunnel(parentFragmentManager, qrCode) { showSnackbar(it) } }
+ }
+ }
+
+ private val snackbarUpdateShower = SnackbarUpdateShower(this)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ if (savedInstanceState != null) {
+ val checkedItems = savedInstanceState.getIntegerArrayList(CHECKED_ITEMS)
+ if (checkedItems != null) {
+ for (i in checkedItems) actionModeListener.setItemChecked(i, true)
+ }
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ super.onCreateView(inflater, container, savedInstanceState)
+ binding = TunnelListFragmentBinding.inflate(inflater, container, false)
+ val bottomSheet = AddTunnelsSheet()
+ binding?.apply {
+ createFab.setOnClickListener {
+ if (childFragmentManager.findFragmentByTag("BOTTOM_SHEET") != null)
+ return@setOnClickListener
+ childFragmentManager.setFragmentResultListener(AddTunnelsSheet.REQUEST_KEY_NEW_TUNNEL, viewLifecycleOwner) { _, bundle ->
+ when (bundle.getString(AddTunnelsSheet.REQUEST_METHOD)) {
+ AddTunnelsSheet.REQUEST_CREATE -> {
+ startActivity(Intent(requireActivity(), TunnelCreatorActivity::class.java))
+ }
+
+ AddTunnelsSheet.REQUEST_IMPORT -> {
+ tunnelFileImportResultLauncher.launch("*/*")
+ }
+
+ AddTunnelsSheet.REQUEST_SCAN -> {
+ qrImportResultLauncher.launch(
+ ScanOptions()
+ .setOrientationLocked(false)
+ .setBeepEnabled(false)
+ .setPrompt(getString(R.string.qr_code_hint))
+ )
+ }
+ }
+ }
+ bottomSheet.showNow(childFragmentManager, "BOTTOM_SHEET")
+ }
+ executePendingBindings()
+ snackbarUpdateShower.attach(mainContainer, createFab)
+ }
+ backPressedCallback = requireActivity().onBackPressedDispatcher.addCallback(this) { actionMode?.finish() }
+ backPressedCallback?.isEnabled = false
+
+ return binding?.root
+ }
+
+ override fun onDestroyView() {
+ binding = null
+ super.onDestroyView()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putIntegerArrayList(CHECKED_ITEMS, actionModeListener.getCheckedItems())
+ }
+
+ override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) {
+ binding ?: return
+ lifecycleScope.launch {
+ val tunnels = Application.getTunnelManager().getTunnels()
+ if (newTunnel != null) viewForTunnel(newTunnel, tunnels)?.setSingleSelected(true)
+ if (oldTunnel != null) viewForTunnel(oldTunnel, tunnels)?.setSingleSelected(false)
+ }
+ }
+
+ private fun onTunnelDeletionFinished(count: Int, throwable: Throwable?) {
+ val message: String
+ val ctx = activity ?: Application.get()
+ if (throwable == null) {
+ message = ctx.resources.getQuantityString(R.plurals.delete_success, count, count)
+ } else {
+ val error = ErrorMessages[throwable]
+ message = ctx.resources.getQuantityString(R.plurals.delete_error, count, count, error)
+ Log.e(TAG, message, throwable)
+ }
+ showSnackbar(message)
+ }
+
+ override fun onViewStateRestored(savedInstanceState: Bundle?) {
+ super.onViewStateRestored(savedInstanceState)
+ binding ?: return
+ binding!!.fragment = this
+ lifecycleScope.launch { binding!!.tunnels = Application.getTunnelManager().getTunnels() }
+ binding!!.rowConfigurationHandler = object : RowConfigurationHandler<TunnelListItemBinding, ObservableTunnel> {
+ override fun onConfigureRow(binding: TunnelListItemBinding, item: ObservableTunnel, position: Int) {
+ binding.fragment = this@TunnelListFragment
+ binding.root.setOnClickListener {
+ if (actionMode == null) {
+ selectedTunnel = item
+ } else {
+ actionModeListener.toggleItemChecked(position)
+ }
+ }
+ binding.root.setOnLongClickListener {
+ actionModeListener.toggleItemChecked(position)
+ true
+ }
+ if (actionMode != null)
+ (binding.root as MultiselectableRelativeLayout).setMultiSelected(actionModeListener.checkedItems.contains(position))
+ else
+ (binding.root as MultiselectableRelativeLayout).setSingleSelected(selectedTunnel == item)
+ }
+ }
+ }
+
+ private fun showSnackbar(message: CharSequence) {
+ val binding = binding
+ if (binding != null)
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG)
+ .setAnchorView(binding.createFab)
+ .show()
+ else
+ Toast.makeText(activity ?: Application.get(), message, Toast.LENGTH_SHORT).show()
+ }
+
+ private fun viewForTunnel(tunnel: ObservableTunnel, tunnels: List<*>): MultiselectableRelativeLayout? {
+ return binding?.tunnelList?.findViewHolderForAdapterPosition(tunnels.indexOf(tunnel))?.itemView as? MultiselectableRelativeLayout
+ }
+
+ private inner class ActionModeListener : ActionMode.Callback {
+ val checkedItems: MutableCollection<Int> = HashSet()
+ private var resources: Resources? = null
+
+ fun getCheckedItems(): ArrayList<Int> {
+ return ArrayList(checkedItems)
+ }
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.menu_action_delete -> {
+ val activity = activity ?: return true
+ val copyCheckedItems = HashSet(checkedItems)
+ binding?.createFab?.apply {
+ visibility = View.VISIBLE
+ scaleX = 1f
+ scaleY = 1f
+ }
+ activity.lifecycleScope.launch {
+ try {
+ val tunnels = Application.getTunnelManager().getTunnels()
+ val tunnelsToDelete = ArrayList<ObservableTunnel>()
+ for (position in copyCheckedItems) tunnelsToDelete.add(tunnels[position])
+ val futures = tunnelsToDelete.map { async(SupervisorJob()) { it.deleteAsync() } }
+ onTunnelDeletionFinished(futures.awaitAll().size, null)
+ } catch (e: Throwable) {
+ onTunnelDeletionFinished(0, e)
+ }
+ }
+ checkedItems.clear()
+ mode.finish()
+ true
+ }
+
+ R.id.menu_action_select_all -> {
+ lifecycleScope.launch {
+ val tunnels = Application.getTunnelManager().getTunnels()
+ for (i in 0 until tunnels.size) {
+ setItemChecked(i, true)
+ }
+ }
+ true
+ }
+
+ else -> false
+ }
+ }
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ actionMode = mode
+ backPressedCallback?.isEnabled = true
+ if (activity != null) {
+ resources = activity!!.resources
+ }
+ animateFab(binding?.createFab, false)
+ mode.menuInflater.inflate(R.menu.tunnel_list_action_mode, menu)
+ binding?.tunnelList?.adapter?.notifyDataSetChanged()
+ return true
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {
+ actionMode = null
+ backPressedCallback?.isEnabled = false
+ resources = null
+ animateFab(binding?.createFab, true)
+ checkedItems.clear()
+ binding?.tunnelList?.adapter?.notifyDataSetChanged()
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ updateTitle(mode)
+ return false
+ }
+
+ fun setItemChecked(position: Int, checked: Boolean) {
+ if (checked) {
+ checkedItems.add(position)
+ } else {
+ checkedItems.remove(position)
+ }
+ val adapter = if (binding == null) null else binding!!.tunnelList.adapter
+ if (actionMode == null && !checkedItems.isEmpty() && activity != null) {
+ (activity as AppCompatActivity).startSupportActionMode(this)
+ } else if (actionMode != null && checkedItems.isEmpty()) {
+ actionMode!!.finish()
+ }
+ adapter?.notifyItemChanged(position)
+ updateTitle(actionMode)
+ }
+
+ fun toggleItemChecked(position: Int) {
+ setItemChecked(position, !checkedItems.contains(position))
+ }
+
+ private fun updateTitle(mode: ActionMode?) {
+ if (mode == null) {
+ return
+ }
+ val count = checkedItems.size
+ if (count == 0) {
+ mode.title = ""
+ } else {
+ mode.title = resources!!.getQuantityString(R.plurals.delete_title, count, count)
+ }
+ }
+
+ private fun animateFab(view: View?, show: Boolean) {
+ view ?: return
+ val animation = AnimationUtils.loadAnimation(
+ context, if (show) R.anim.scale_up else R.anim.scale_down
+ )
+ animation.setAnimationListener(object : Animation.AnimationListener {
+ override fun onAnimationRepeat(animation: Animation?) {
+ }
+
+ override fun onAnimationEnd(animation: Animation?) {
+ if (!show) view.visibility = View.GONE
+ }
+
+ override fun onAnimationStart(animation: Animation?) {
+ if (show) view.visibility = View.VISIBLE
+ }
+ })
+ view.startAnimation(animation)
+ }
+ }
+
+ companion object {
+ private const val CHECKED_ITEMS = "CHECKED_ITEMS"
+ private const val TAG = "WireGuard/TunnelListFragment"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/ApplicationData.kt b/ui/src/main/java/com/wireguard/android/model/ApplicationData.kt
new file mode 100644
index 00000000..f3892424
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/ApplicationData.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.model
+
+import android.graphics.drawable.Drawable
+import androidx.databinding.BaseObservable
+import androidx.databinding.Bindable
+import com.wireguard.android.BR
+import com.wireguard.android.databinding.Keyed
+
+class ApplicationData(val icon: Drawable, val name: String, val packageName: String, isSelected: Boolean) : BaseObservable(), Keyed<String> {
+ override val key = name
+
+ @get:Bindable
+ var isSelected = isSelected
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.selected)
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt
new file mode 100644
index 00000000..aa237aee
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.model
+
+import android.util.Log
+import androidx.databinding.BaseObservable
+import androidx.databinding.Bindable
+import com.wireguard.android.BR
+import com.wireguard.android.backend.Statistics
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.Keyed
+import com.wireguard.android.util.applicationScope
+import com.wireguard.config.Config
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Encapsulates the volatile and nonvolatile state of a WireGuard tunnel.
+ */
+class ObservableTunnel internal constructor(
+ private val manager: TunnelManager,
+ private var name: String,
+ config: Config?,
+ state: Tunnel.State
+) : BaseObservable(), Keyed<String>, Tunnel {
+ override val key
+ get() = name
+
+ @Bindable
+ override fun getName() = name
+
+ suspend fun setNameAsync(name: String): String = withContext(Dispatchers.Main.immediate) {
+ if (name != this@ObservableTunnel.name)
+ manager.setTunnelName(this@ObservableTunnel, name)
+ else
+ this@ObservableTunnel.name
+ }
+
+ fun onNameChanged(name: String): String {
+ this.name = name
+ notifyPropertyChanged(BR.name)
+ return name
+ }
+
+
+ @get:Bindable
+ var state = state
+ private set
+
+ override fun onStateChange(newState: Tunnel.State) {
+ onStateChanged(newState)
+ }
+
+ fun onStateChanged(state: Tunnel.State): Tunnel.State {
+ if (state != Tunnel.State.UP) onStatisticsChanged(null)
+ this.state = state
+ notifyPropertyChanged(BR.state)
+ return state
+ }
+
+ suspend fun setStateAsync(state: Tunnel.State): Tunnel.State = withContext(Dispatchers.Main.immediate) {
+ if (state != this@ObservableTunnel.state)
+ manager.setTunnelState(this@ObservableTunnel, state)
+ else
+ this@ObservableTunnel.state
+ }
+
+
+ @get:Bindable
+ var config = config
+ get() {
+ if (field == null)
+ // Opportunistically fetch this if we don't have a cached one, and rely on data bindings to update it eventually
+ applicationScope.launch {
+ try {
+ manager.getTunnelConfig(this@ObservableTunnel)
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ return field
+ }
+ private set
+
+ suspend fun getConfigAsync(): Config = withContext(Dispatchers.Main.immediate) {
+ config ?: manager.getTunnelConfig(this@ObservableTunnel)
+ }
+
+ suspend fun setConfigAsync(config: Config): Config = withContext(Dispatchers.Main.immediate) {
+ this@ObservableTunnel.config.let {
+ if (config != it)
+ manager.setTunnelConfig(this@ObservableTunnel, config)
+ else
+ it
+ }
+ }
+
+ fun onConfigChanged(config: Config?): Config? {
+ this.config = config
+ notifyPropertyChanged(BR.config)
+ return config
+ }
+
+
+ @get:Bindable
+ var statistics: Statistics? = null
+ get() {
+ if (field == null || field?.isStale != false)
+ // Opportunistically fetch this if we don't have a cached one, and rely on data bindings to update it eventually
+ applicationScope.launch {
+ try {
+ manager.getTunnelStatistics(this@ObservableTunnel)
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ return field
+ }
+ private set
+
+ suspend fun getStatisticsAsync(): Statistics = withContext(Dispatchers.Main.immediate) {
+ statistics.let {
+ if (it == null || it.isStale)
+ manager.getTunnelStatistics(this@ObservableTunnel)
+ else
+ it
+ }
+ }
+
+ fun onStatisticsChanged(statistics: Statistics?): Statistics? {
+ this.statistics = statistics
+ notifyPropertyChanged(BR.statistics)
+ return statistics
+ }
+
+
+ suspend fun deleteAsync() = manager.delete(this)
+
+
+ companion object {
+ private const val TAG = "WireGuard/ObservableTunnel"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt b/ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt
new file mode 100644
index 00000000..e6b46a54
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.model
+
+object TunnelComparator : Comparator<String> {
+ private class NaturalSortString(originalString: String) {
+ class NaturalSortToken(val maybeString: String?, val maybeNumber: Int?) : Comparable<NaturalSortToken> {
+ override fun compareTo(other: NaturalSortToken): Int {
+ if (maybeString == null) {
+ if (other.maybeString != null || maybeNumber!! < other.maybeNumber!!) {
+ return -1
+ } else if (maybeNumber > other.maybeNumber) {
+ return 1
+ }
+ } else if (other.maybeString == null || maybeString > other.maybeString) {
+ return 1
+ } else if (maybeString < other.maybeString) {
+ return -1
+ }
+ return 0
+ }
+ }
+
+ val tokens: MutableList<NaturalSortToken> = ArrayList()
+
+ init {
+ for (s in NATURAL_SORT_DIGIT_FINDER.findAll(originalString.split(WHITESPACE_FINDER).joinToString(" ").lowercase())) {
+ try {
+ val n = s.value.toInt()
+ tokens.add(NaturalSortToken(null, n))
+ } catch (_: NumberFormatException) {
+ tokens.add(NaturalSortToken(s.value, null))
+ }
+ }
+ }
+
+ private companion object {
+ private val NATURAL_SORT_DIGIT_FINDER = Regex("""\d+|\D+""")
+ private val WHITESPACE_FINDER = Regex("""\s""")
+ }
+ }
+
+ override fun compare(a: String, b: String): Int {
+ if (a == b)
+ return 0
+ val na = NaturalSortString(a)
+ val nb = NaturalSortString(b)
+ for (i in 0 until nb.tokens.size) {
+ if (i == na.tokens.size) {
+ return -1
+ }
+ val c = na.tokens[i].compareTo(nb.tokens[i])
+ if (c != 0)
+ return c
+ }
+ return 1
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt
new file mode 100644
index 00000000..ba873fa6
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.model
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import android.widget.Toast
+import androidx.databinding.BaseObservable
+import androidx.databinding.Bindable
+import com.wireguard.android.Application.Companion.get
+import com.wireguard.android.Application.Companion.getBackend
+import com.wireguard.android.Application.Companion.getTunnelManager
+import com.wireguard.android.BR
+import com.wireguard.android.R
+import com.wireguard.android.backend.Statistics
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.configStore.ConfigStore
+import com.wireguard.android.databinding.ObservableSortedKeyedArrayList
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.applicationScope
+import com.wireguard.config.Config
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Maintains and mediates changes to the set of available WireGuard tunnels,
+ */
+class TunnelManager(private val configStore: ConfigStore) : BaseObservable() {
+ private val tunnels = CompletableDeferred<ObservableSortedKeyedArrayList<String, ObservableTunnel>>()
+ private val context: Context = get()
+ private val tunnelMap: ObservableSortedKeyedArrayList<String, ObservableTunnel> = ObservableSortedKeyedArrayList(TunnelComparator)
+ private var haveLoaded = false
+
+ private fun addToList(name: String, config: Config?, state: Tunnel.State): ObservableTunnel {
+ val tunnel = ObservableTunnel(this, name, config, state)
+ tunnelMap.add(tunnel)
+ return tunnel
+ }
+
+ suspend fun getTunnels(): ObservableSortedKeyedArrayList<String, ObservableTunnel> = tunnels.await()
+
+ suspend fun create(name: String, config: Config?): ObservableTunnel = withContext(Dispatchers.Main.immediate) {
+ if (Tunnel.isNameInvalid(name))
+ throw IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name))
+ if (tunnelMap.containsKey(name))
+ throw IllegalArgumentException(context.getString(R.string.tunnel_error_already_exists, name))
+ addToList(name, withContext(Dispatchers.IO) { configStore.create(name, config!!) }, Tunnel.State.DOWN)
+ }
+
+ suspend fun delete(tunnel: ObservableTunnel) = withContext(Dispatchers.Main.immediate) {
+ val originalState = tunnel.state
+ val wasLastUsed = tunnel == lastUsedTunnel
+ // Make sure nothing touches the tunnel.
+ if (wasLastUsed)
+ lastUsedTunnel = null
+ tunnelMap.remove(tunnel)
+ try {
+ if (originalState == Tunnel.State.UP)
+ withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.DOWN, null) }
+ try {
+ withContext(Dispatchers.IO) { configStore.delete(tunnel.name) }
+ } catch (e: Throwable) {
+ if (originalState == Tunnel.State.UP)
+ withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.UP, tunnel.config) }
+ throw e
+ }
+ } catch (e: Throwable) {
+ // Failure, put the tunnel back.
+ tunnelMap.add(tunnel)
+ if (wasLastUsed)
+ lastUsedTunnel = tunnel
+ throw e
+ }
+ }
+
+ @get:Bindable
+ var lastUsedTunnel: ObservableTunnel? = null
+ private set(value) {
+ if (value == field) return
+ field = value
+ notifyPropertyChanged(BR.lastUsedTunnel)
+ applicationScope.launch { UserKnobs.setLastUsedTunnel(value?.name) }
+ }
+
+ suspend fun getTunnelConfig(tunnel: ObservableTunnel): Config = withContext(Dispatchers.Main.immediate) {
+ tunnel.onConfigChanged(withContext(Dispatchers.IO) { configStore.load(tunnel.name) })!!
+ }
+
+ fun onCreate() {
+ applicationScope.launch {
+ try {
+ onTunnelsLoaded(withContext(Dispatchers.IO) { configStore.enumerate() }, withContext(Dispatchers.IO) { getBackend().runningTunnelNames })
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ }
+
+ private fun onTunnelsLoaded(present: Iterable<String>, running: Collection<String>) {
+ for (name in present)
+ addToList(name, null, if (running.contains(name)) Tunnel.State.UP else Tunnel.State.DOWN)
+ applicationScope.launch {
+ val lastUsedName = UserKnobs.lastUsedTunnel.first()
+ if (lastUsedName != null)
+ lastUsedTunnel = tunnelMap[lastUsedName]
+ haveLoaded = true
+ restoreState(true)
+ tunnels.complete(tunnelMap)
+ }
+ }
+
+ private fun refreshTunnelStates() {
+ applicationScope.launch {
+ try {
+ val running = withContext(Dispatchers.IO) { getBackend().runningTunnelNames }
+ for (tunnel in tunnelMap)
+ tunnel.onStateChanged(if (running.contains(tunnel.name)) Tunnel.State.UP else Tunnel.State.DOWN)
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ }
+
+ suspend fun restoreState(force: Boolean) {
+ if (!haveLoaded || (!force && !UserKnobs.restoreOnBoot.first()))
+ return
+ val previouslyRunning = UserKnobs.runningTunnels.first()
+ if (previouslyRunning.isEmpty()) return
+ withContext(Dispatchers.IO) {
+ try {
+ tunnelMap.filter { previouslyRunning.contains(it.name) }.map { async(Dispatchers.IO + SupervisorJob()) { setTunnelState(it, Tunnel.State.UP) } }
+ .awaitAll()
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ }
+
+ suspend fun saveState() {
+ UserKnobs.setRunningTunnels(tunnelMap.filter { it.state == Tunnel.State.UP }.map { it.name }.toSet())
+ }
+
+ suspend fun setTunnelConfig(tunnel: ObservableTunnel, config: Config): Config = withContext(Dispatchers.Main.immediate) {
+ tunnel.onConfigChanged(withContext(Dispatchers.IO) {
+ getBackend().setState(tunnel, tunnel.state, config)
+ configStore.save(tunnel.name, config)
+ })!!
+ }
+
+ suspend fun setTunnelName(tunnel: ObservableTunnel, name: String): String = withContext(Dispatchers.Main.immediate) {
+ if (Tunnel.isNameInvalid(name))
+ throw IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name))
+ if (tunnelMap.containsKey(name)) {
+ throw IllegalArgumentException(context.getString(R.string.tunnel_error_already_exists, name))
+ }
+ val originalState = tunnel.state
+ val wasLastUsed = tunnel == lastUsedTunnel
+ // Make sure nothing touches the tunnel.
+ if (wasLastUsed)
+ lastUsedTunnel = null
+ tunnelMap.remove(tunnel)
+ var throwable: Throwable? = null
+ var newName: String? = null
+ try {
+ if (originalState == Tunnel.State.UP)
+ withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.DOWN, null) }
+ withContext(Dispatchers.IO) { configStore.rename(tunnel.name, name) }
+ newName = tunnel.onNameChanged(name)
+ if (originalState == Tunnel.State.UP)
+ withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.UP, tunnel.config) }
+ } catch (e: Throwable) {
+ throwable = e
+ // On failure, we don't know what state the tunnel might be in. Fix that.
+ getTunnelState(tunnel)
+ }
+ // Add the tunnel back to the manager, under whatever name it thinks it has.
+ tunnelMap.add(tunnel)
+ if (wasLastUsed)
+ lastUsedTunnel = tunnel
+ if (throwable != null)
+ throw throwable
+ newName!!
+ }
+
+ suspend fun setTunnelState(tunnel: ObservableTunnel, state: Tunnel.State): Tunnel.State = withContext(Dispatchers.Main.immediate) {
+ var newState = tunnel.state
+ var throwable: Throwable? = null
+ try {
+ newState = withContext(Dispatchers.IO) { getBackend().setState(tunnel, state, tunnel.getConfigAsync()) }
+ if (newState == Tunnel.State.UP)
+ lastUsedTunnel = tunnel
+ } catch (e: Throwable) {
+ throwable = e
+ }
+ tunnel.onStateChanged(newState)
+ saveState()
+ if (throwable != null)
+ throw throwable
+ newState
+ }
+
+ class IntentReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent?) {
+ applicationScope.launch {
+ val manager = getTunnelManager()
+ if (intent == null) return@launch
+ val action = intent.action ?: return@launch
+ if ("com.wireguard.android.action.REFRESH_TUNNEL_STATES" == action) {
+ manager.refreshTunnelStates()
+ return@launch
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || !UserKnobs.allowRemoteControlIntents.first())
+ return@launch
+ val state: Tunnel.State
+ state = when (action) {
+ "com.wireguard.android.action.SET_TUNNEL_UP" -> Tunnel.State.UP
+ "com.wireguard.android.action.SET_TUNNEL_DOWN" -> Tunnel.State.DOWN
+ else -> return@launch
+ }
+ val tunnelName = intent.getStringExtra("tunnel") ?: return@launch
+ val tunnels = manager.getTunnels()
+ val tunnel = tunnels[tunnelName] ?: return@launch
+ try {
+ manager.setTunnelState(tunnel, state)
+ } catch (e: Throwable) {
+ Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+
+ suspend fun getTunnelState(tunnel: ObservableTunnel): Tunnel.State = withContext(Dispatchers.Main.immediate) {
+ tunnel.onStateChanged(withContext(Dispatchers.IO) { getBackend().getState(tunnel) })
+ }
+
+ suspend fun getTunnelStatistics(tunnel: ObservableTunnel): Statistics = withContext(Dispatchers.Main.immediate) {
+ tunnel.onStatisticsChanged(withContext(Dispatchers.IO) { getBackend().getStatistics(tunnel) })!!
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/TunnelManager"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt b/ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt
new file mode 100644
index 00000000..16920923
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.AttributeSet
+import android.widget.Toast
+import androidx.preference.Preference
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.wireguard.android.R
+import com.wireguard.android.updater.Updater
+import com.wireguard.android.util.ErrorMessages
+
+class DonatePreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ override fun getSummary() = context.getString(R.string.donate_summary)
+
+ override fun getTitle() = context.getString(R.string.donate_title)
+
+ override fun onClick() {
+ /* Google Play Store forbids links to our donation page. */
+ if (Updater.installerIsGooglePlay(context)) {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.donate_title)
+ .setMessage(R.string.donate_google_play_disappointment)
+ .show()
+ return
+ }
+
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = Uri.parse("https://www.wireguard.com/donations/")
+ try {
+ context.startActivity(intent)
+ } catch (e: Throwable) {
+ Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show()
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt b/ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt
new file mode 100644
index 00000000..20de8e93
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.content.Intent
+import android.util.AttributeSet
+import android.util.Log
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.Preference
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.activity.SettingsActivity
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.activity
+import com.wireguard.android.util.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.system.exitProcess
+
+class KernelModuleEnablerPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ private var state = State.UNKNOWN
+
+ init {
+ isVisible = false
+ lifecycleScope.launch {
+ setState(if (Application.getBackend() is WgQuickBackend) State.ENABLED else State.DISABLED)
+ }
+ }
+
+ override fun getSummary() = if (state == State.UNKNOWN) "" else context.getString(state.summaryResourceId)
+
+ override fun getTitle() = if (state == State.UNKNOWN) "" else context.getString(state.titleResourceId)
+
+ override fun onClick() {
+ activity.lifecycleScope.launch {
+ if (state == State.DISABLED) {
+ setState(State.ENABLING)
+ UserKnobs.setEnableKernelModule(true)
+ } else if (state == State.ENABLED) {
+ setState(State.DISABLING)
+ UserKnobs.setEnableKernelModule(false)
+ }
+ val observableTunnels = Application.getTunnelManager().getTunnels()
+ val downings = observableTunnels.map { async(SupervisorJob()) { it.setStateAsync(Tunnel.State.DOWN) } }
+ try {
+ downings.awaitAll()
+ withContext(Dispatchers.IO) {
+ val restartIntent = Intent(context, SettingsActivity::class.java)
+ restartIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ Application.get().startActivity(restartIntent)
+ exitProcess(0)
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ }
+
+ private fun setState(state: State) {
+ if (this.state == state) return
+ this.state = state
+ if (isEnabled != state.shouldEnableView) isEnabled = state.shouldEnableView
+ if (isVisible != state.visible) isVisible = state.visible
+ notifyChanged()
+ }
+
+ private enum class State(val titleResourceId: Int, val summaryResourceId: Int, val shouldEnableView: Boolean, val visible: Boolean) {
+ UNKNOWN(0, 0, false, false),
+ ENABLED(R.string.module_enabler_enabled_title, R.string.module_enabler_enabled_summary, true, true),
+ DISABLED(R.string.module_enabler_disabled_title, R.string.module_enabler_disabled_summary, true, true),
+ ENABLING(R.string.module_enabler_disabled_title, R.string.success_application_will_restart, false, true),
+ DISABLING(R.string.module_enabler_enabled_title, R.string.success_application_will_restart, false, true);
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/KernelModuleEnablerPreference"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt b/ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt
new file mode 100644
index 00000000..1a491684
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.preference.PreferenceDataStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+class PreferencesPreferenceDataStore(private val coroutineScope: CoroutineScope, private val dataStore: DataStore<Preferences>) : PreferenceDataStore() {
+ override fun putString(key: String?, value: String?) {
+ if (key == null) return
+ val pk = stringPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ if (value == null) it.remove(pk)
+ else it[pk] = value
+ }
+ }
+ }
+
+ override fun putStringSet(key: String?, values: Set<String?>?) {
+ if (key == null) return
+ val pk = stringSetPreferencesKey(key)
+ val filteredValues = values?.filterNotNull()?.toSet()
+ coroutineScope.launch {
+ dataStore.edit {
+ if (filteredValues == null || filteredValues.isEmpty()) it.remove(pk)
+ else it[pk] = filteredValues
+ }
+ }
+ }
+
+ override fun putInt(key: String?, value: Int) {
+ if (key == null) return
+ val pk = intPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ it[pk] = value
+ }
+ }
+ }
+
+ override fun putLong(key: String?, value: Long) {
+ if (key == null) return
+ val pk = longPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ it[pk] = value
+ }
+ }
+ }
+
+ override fun putFloat(key: String?, value: Float) {
+ if (key == null) return
+ val pk = floatPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ it[pk] = value
+ }
+ }
+ }
+
+ override fun putBoolean(key: String?, value: Boolean) {
+ if (key == null) return
+ val pk = booleanPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ it[pk] = value
+ }
+ }
+ }
+
+ override fun getString(key: String?, defValue: String?): String? {
+ if (key == null) return defValue
+ val pk = stringPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+
+ override fun getStringSet(key: String?, defValues: Set<String?>?): Set<String?>? {
+ if (key == null) return defValues
+ val pk = stringSetPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValues }.first()
+ }
+ }
+
+ override fun getInt(key: String?, defValue: Int): Int {
+ if (key == null) return defValue
+ val pk = intPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+
+ override fun getLong(key: String?, defValue: Long): Long {
+ if (key == null) return defValue
+ val pk = longPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+
+ override fun getFloat(key: String?, defValue: Float): Float {
+ if (key == null) return defValue
+ val pk = floatPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+
+ override fun getBoolean(key: String?, defValue: Boolean): Boolean {
+ if (key == null) return defValue
+ val pk = booleanPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt b/ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt
new file mode 100644
index 00000000..9081818b
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference
+
+import android.app.StatusBarManager
+import android.content.ComponentName
+import android.content.Context
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.util.AttributeSet
+import android.widget.Toast
+import androidx.annotation.RequiresApi
+import androidx.preference.Preference
+import com.wireguard.android.QuickTileService
+import com.wireguard.android.R
+
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+class QuickTilePreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ override fun getSummary() = context.getString(R.string.quick_settings_tile_add_summary)
+
+ override fun getTitle() = context.getString(R.string.quick_settings_tile_add_title)
+
+ override fun onClick() {
+ val statusBarManager = context.getSystemService(StatusBarManager::class.java)
+ statusBarManager.requestAddTileService(
+ ComponentName(context, QuickTileService::class.java),
+ context.getString(R.string.quick_settings_tile_action),
+ Icon.createWithResource(context, R.drawable.ic_tile),
+ context.mainExecutor
+ ) {
+ when (it) {
+ StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED,
+ StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED -> {
+ parent?.removePreference(this)
+ --preferenceManager.preferenceScreen.initialExpandedChildrenCount
+ }
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_MISMATCHED_PACKAGE,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_REQUEST_IN_PROGRESS,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_BAD_COMPONENT,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_NOT_CURRENT_USER,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_APP_NOT_IN_FOREGROUND,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_NO_STATUS_BAR_SERVICE ->
+ Toast.makeText(context, context.getString(R.string.quick_settings_tile_add_failure, it), Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt
new file mode 100644
index 00000000..dac80e88
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.preference.Preference
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.util.ToolsInstaller
+import com.wireguard.android.util.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Preference implementing a button that asynchronously runs `ToolsInstaller` and displays the
+ * result as the preference summary.
+ */
+class ToolsInstallerPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ private var state = State.INITIAL
+ override fun getSummary() = context.getString(state.messageResourceId)
+
+ override fun getTitle() = context.getString(R.string.tools_installer_title)
+
+ override fun onAttached() {
+ super.onAttached()
+ lifecycleScope.launch {
+ try {
+ val state = withContext(Dispatchers.IO) { Application.getToolsInstaller().areInstalled() }
+ when {
+ state == ToolsInstaller.ERROR -> setState(State.INITIAL)
+ state and ToolsInstaller.YES == ToolsInstaller.YES -> setState(State.ALREADY)
+ state and (ToolsInstaller.MAGISK or ToolsInstaller.NO) == ToolsInstaller.MAGISK or ToolsInstaller.NO -> setState(State.INITIAL_MAGISK)
+ state and (ToolsInstaller.SYSTEM or ToolsInstaller.NO) == ToolsInstaller.SYSTEM or ToolsInstaller.NO -> setState(State.INITIAL_SYSTEM)
+ else -> setState(State.INITIAL)
+ }
+ } catch (_: Throwable) {
+ setState(State.INITIAL)
+ }
+ }
+ }
+
+ override fun onClick() {
+ setState(State.WORKING)
+ lifecycleScope.launch {
+ try {
+ val result = withContext(Dispatchers.IO) { Application.getToolsInstaller().install() }
+ when {
+ result and (ToolsInstaller.YES or ToolsInstaller.MAGISK) == ToolsInstaller.YES or ToolsInstaller.MAGISK -> setState(State.SUCCESS_MAGISK)
+ result and (ToolsInstaller.YES or ToolsInstaller.SYSTEM) == ToolsInstaller.YES or ToolsInstaller.SYSTEM -> setState(State.SUCCESS_SYSTEM)
+ else -> setState(State.FAILURE)
+ }
+ } catch (_: Throwable) {
+ setState(State.FAILURE)
+ }
+ }
+ }
+
+ private fun setState(state: State) {
+ if (this.state == state) return
+ this.state = state
+ if (isEnabled != state.shouldEnableView) isEnabled = state.shouldEnableView
+ notifyChanged()
+ }
+
+ private enum class State(val messageResourceId: Int, val shouldEnableView: Boolean) {
+ INITIAL(R.string.tools_installer_initial, true),
+ ALREADY(R.string.tools_installer_already, false),
+ FAILURE(R.string.tools_installer_failure, true),
+ WORKING(R.string.tools_installer_working, false),
+ INITIAL_SYSTEM(R.string.tools_installer_initial_system, true),
+ SUCCESS_SYSTEM(R.string.tools_installer_success_system, false),
+ INITIAL_MAGISK(R.string.tools_installer_initial_magisk, true),
+ SUCCESS_MAGISK(R.string.tools_installer_success_magisk, false);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt
new file mode 100644
index 00000000..7d997e8a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.AttributeSet
+import android.widget.Toast
+import androidx.preference.Preference
+import com.wireguard.android.Application
+import com.wireguard.android.BuildConfig
+import com.wireguard.android.R
+import com.wireguard.android.backend.Backend
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class VersionPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ private var versionSummary: String? = null
+
+ override fun getSummary() = versionSummary
+
+ override fun getTitle() = context.getString(R.string.version_title, BuildConfig.VERSION_NAME)
+
+ override fun onClick() {
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = Uri.parse("https://www.wireguard.com/")
+ try {
+ context.startActivity(intent)
+ } catch (e: Throwable) {
+ Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ companion object {
+ private fun getBackendPrettyName(context: Context, backend: Backend) = when (backend) {
+ is WgQuickBackend -> context.getString(R.string.type_name_kernel_module)
+ is GoBackend -> context.getString(R.string.type_name_go_userspace)
+ else -> ""
+ }
+ }
+
+ init {
+ lifecycleScope.launch {
+ val backend = Application.getBackend()
+ versionSummary = getContext().getString(R.string.version_summary_checking, getBackendPrettyName(context, backend).lowercase())
+ notifyChanged()
+ versionSummary = try {
+ getContext().getString(R.string.version_summary, getBackendPrettyName(context, backend), withContext(Dispatchers.IO) { backend.version })
+ } catch (_: Throwable) {
+ getContext().getString(R.string.version_summary_unknown, getBackendPrettyName(context, backend).lowercase())
+ }
+ notifyChanged()
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt
new file mode 100644
index 00000000..220796e0
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.util.AttributeSet
+import android.util.Log
+import androidx.preference.Preference
+import com.google.android.material.snackbar.Snackbar
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.util.AdminKnobs
+import com.wireguard.android.util.BiometricAuthenticator
+import com.wireguard.android.util.DownloadsFileSaver
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.activity
+import com.wireguard.android.util.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.nio.charset.StandardCharsets
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+/**
+ * Preference implementing a button that asynchronously exports config zips.
+ */
+class ZipExporterPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ private var exportedFilePath: String? = null
+ private val downloadsFileSaver = DownloadsFileSaver(activity)
+
+ private fun exportZip() {
+ lifecycleScope.launch {
+ val tunnels = Application.getTunnelManager().getTunnels()
+ try {
+ exportedFilePath = withContext(Dispatchers.IO) {
+ val configs = tunnels.map { async(SupervisorJob()) { it.getConfigAsync() } }.awaitAll()
+ if (configs.isEmpty()) {
+ throw IllegalArgumentException(context.getString(R.string.no_tunnels_error))
+ }
+ val outputFile = downloadsFileSaver.save("wireguard-export.zip", "application/zip", true)
+ if (outputFile == null) {
+ withContext(Dispatchers.Main.immediate) {
+ isEnabled = true
+ }
+ return@withContext null
+ }
+ try {
+ ZipOutputStream(outputFile.outputStream).use { zip ->
+ for (i in configs.indices) {
+ zip.putNextEntry(ZipEntry(tunnels[i].name + ".conf"))
+ zip.write(configs[i].toWgQuickString().toByteArray(StandardCharsets.UTF_8))
+ }
+ zip.closeEntry()
+ }
+ } catch (e: Throwable) {
+ outputFile.delete()
+ throw e
+ }
+ outputFile.fileName
+ }
+ notifyChanged()
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val message = context.getString(R.string.zip_export_error, error)
+ Log.e(TAG, message, e)
+ Snackbar.make(
+ activity.findViewById(android.R.id.content),
+ message, Snackbar.LENGTH_LONG
+ ).show()
+ isEnabled = true
+ }
+ }
+ }
+
+ override fun getSummary() =
+ if (exportedFilePath == null) context.getString(R.string.zip_export_summary) else context.getString(R.string.zip_export_success, exportedFilePath)
+
+ override fun getTitle() = context.getString(R.string.zip_export_title)
+
+ override fun onClick() {
+ if (AdminKnobs.disableConfigExport) return
+ val fragment = activity.supportFragmentManager.fragments.first()
+ BiometricAuthenticator.authenticate(R.string.biometric_prompt_zip_exporter_title, fragment) {
+ when (it) {
+ // When we have successful authentication, or when there is no biometric hardware available.
+ is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
+ isEnabled = false
+ exportZip()
+ }
+
+ is BiometricAuthenticator.Result.Failure -> {
+ Snackbar.make(
+ activity.findViewById(android.R.id.content),
+ it.message,
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+
+ is BiometricAuthenticator.Result.Cancelled -> {}
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/ZipExporterPreference"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/updater/Ed25519.java b/ui/src/main/java/com/wireguard/android/updater/Ed25519.java
new file mode 100644
index 00000000..d27caf1c
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/updater/Ed25519.java
@@ -0,0 +1,2507 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.updater;
+
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.util.Arrays;
+
+/**
+ * Implementation of Ed25519 signature verification.
+ *
+ * <p>This implementation is based on the ed25519/ref10 implementation in NaCl.</p>
+ *
+ * <p>It implements this twisted Edwards curve:
+ *
+ * <pre>
+ * -x^2 + y^2 = 1 + (-121665 / 121666 mod 2^255-19)*x^2*y^2
+ * </pre>
+ *
+ * @see <a href="https://eprint.iacr.org/2008/013.pdf">Bernstein D.J., Birkner P., Joye M., Lange
+ * T., Peters C. (2008) Twisted Edwards Curves</a>
+ * @see <a href="https://eprint.iacr.org/2008/522.pdf">Hisil H., Wong K.KH., Carter G., Dawson E.
+ * (2008) Twisted Edwards Curves Revisited</a>
+ */
+final class Ed25519 {
+
+ // d = -121665 / 121666 mod 2^255-19
+ private static final long[] D;
+ // 2d
+ private static final long[] D2;
+ // 2^((p-1)/4) mod p where p = 2^255-19
+ private static final long[] SQRTM1;
+
+ /**
+ * Base point for the Edwards twisted curve = (x, 4/5) and its exponentiations. B_TABLE[i][j] =
+ * (j+1)*256^i*B for i in [0, 32) and j in [0, 8). Base point B = B_TABLE[0][0]
+ */
+ private static final CachedXYT[][] B_TABLE;
+ private static final CachedXYT[] B2;
+
+ private static final BigInteger P_BI =
+ BigInteger.valueOf(2).pow(255).subtract(BigInteger.valueOf(19));
+ private static final BigInteger D_BI =
+ BigInteger.valueOf(-121665).multiply(BigInteger.valueOf(121666).modInverse(P_BI)).mod(P_BI);
+ private static final BigInteger D2_BI = BigInteger.valueOf(2).multiply(D_BI).mod(P_BI);
+ private static final BigInteger SQRTM1_BI =
+ BigInteger.valueOf(2).modPow(P_BI.subtract(BigInteger.ONE).divide(BigInteger.valueOf(4)), P_BI);
+
+ private Ed25519() {
+ }
+
+ private static class Point {
+ private BigInteger x;
+ private BigInteger y;
+ }
+
+ private static BigInteger recoverX(BigInteger y) {
+ // x^2 = (y^2 - 1) / (d * y^2 + 1) mod 2^255-19
+ BigInteger xx =
+ y.pow(2)
+ .subtract(BigInteger.ONE)
+ .multiply(D_BI.multiply(y.pow(2)).add(BigInteger.ONE).modInverse(P_BI));
+ BigInteger x = xx.modPow(P_BI.add(BigInteger.valueOf(3)).divide(BigInteger.valueOf(8)), P_BI);
+ if (!x.pow(2).subtract(xx).mod(P_BI).equals(BigInteger.ZERO)) {
+ x = x.multiply(SQRTM1_BI).mod(P_BI);
+ }
+ if (x.testBit(0)) {
+ x = P_BI.subtract(x);
+ }
+ return x;
+ }
+
+ private static Point edwards(Point a, Point b) {
+ Point o = new Point();
+ BigInteger xxyy = D_BI.multiply(a.x.multiply(b.x).multiply(a.y).multiply(b.y)).mod(P_BI);
+ o.x =
+ (a.x.multiply(b.y).add(b.x.multiply(a.y)))
+ .multiply(BigInteger.ONE.add(xxyy).modInverse(P_BI))
+ .mod(P_BI);
+ o.y =
+ (a.y.multiply(b.y).add(a.x.multiply(b.x)))
+ .multiply(BigInteger.ONE.subtract(xxyy).modInverse(P_BI))
+ .mod(P_BI);
+ return o;
+ }
+
+ private static byte[] toLittleEndian(BigInteger n) {
+ byte[] b = new byte[32];
+ byte[] nBytes = n.toByteArray();
+ System.arraycopy(nBytes, 0, b, 32 - nBytes.length, nBytes.length);
+ for (int i = 0; i < b.length / 2; i++) {
+ byte t = b[i];
+ b[i] = b[b.length - i - 1];
+ b[b.length - i - 1] = t;
+ }
+ return b;
+ }
+
+ private static CachedXYT getCachedXYT(Point p) {
+ return new CachedXYT(
+ Field25519.expand(toLittleEndian(p.y.add(p.x).mod(P_BI))),
+ Field25519.expand(toLittleEndian(p.y.subtract(p.x).mod(P_BI))),
+ Field25519.expand(toLittleEndian(D2_BI.multiply(p.x).multiply(p.y).mod(P_BI))));
+ }
+
+ static {
+ Point b = new Point();
+ b.y = BigInteger.valueOf(4).multiply(BigInteger.valueOf(5).modInverse(P_BI)).mod(P_BI);
+ b.x = recoverX(b.y);
+
+ D = Field25519.expand(toLittleEndian(D_BI));
+ D2 = Field25519.expand(toLittleEndian(D2_BI));
+ SQRTM1 = Field25519.expand(toLittleEndian(SQRTM1_BI));
+
+ Point bi = b;
+ B_TABLE = new CachedXYT[32][8];
+ for (int i = 0; i < 32; i++) {
+ Point bij = bi;
+ for (int j = 0; j < 8; j++) {
+ B_TABLE[i][j] = getCachedXYT(bij);
+ bij = edwards(bij, bi);
+ }
+ for (int j = 0; j < 8; j++) {
+ bi = edwards(bi, bi);
+ }
+ }
+ bi = b;
+ Point b2 = edwards(b, b);
+ B2 = new CachedXYT[8];
+ for (int i = 0; i < 8; i++) {
+ B2[i] = getCachedXYT(bi);
+ bi = edwards(bi, b2);
+ }
+ }
+
+ private static final int PUBLIC_KEY_LEN = Field25519.FIELD_LEN;
+ private static final int SIGNATURE_LEN = Field25519.FIELD_LEN * 2;
+
+ /**
+ * Defines field 25519 function based on <a
+ * href="https://github.com/agl/curve25519-donna/blob/master/curve25519-donna.c">curve25519-donna C
+ * implementation</a> (mostly identical).
+ *
+ * <p>Field elements are written as an array of signed, 64-bit limbs (an array of longs), least
+ * significant first. The value of the field element is:
+ *
+ * <pre>
+ * x[0] + 2^26·x[1] + 2^51·x[2] + 2^77·x[3] + 2^102·x[4] + 2^128·x[5] + 2^153·x[6] + 2^179·x[7] +
+ * 2^204·x[8] + 2^230·x[9],
+ * </pre>
+ *
+ * <p>i.e. the limbs are 26, 25, 26, 25, ... bits wide.
+ */
+ private static final class Field25519 {
+ /**
+ * During Field25519 computation, the mixed radix representation may be in different forms:
+ * <ul>
+ * <li> Reduced-size form: the array has size at most 10.
+ * <li> Non-reduced-size form: the array is not reduced modulo 2^255 - 19 and has size at most
+ * 19.
+ * </ul>
+ * <p>
+ * TODO(quannguyen):
+ * <ul>
+ * <li> Clarify ill-defined terminologies.
+ * <li> The reduction procedure is different from DJB's paper
+ * (http://cr.yp.to/ecdh/curve25519-20060209.pdf). The coefficients after reducing degree and
+ * reducing coefficients aren't guaranteed to be in range {-2^25, ..., 2^25}. We should check to
+ * see what's going on.
+ * <li> Consider using method mult() everywhere and making product() private.
+ * </ul>
+ */
+
+ static final int FIELD_LEN = 32;
+ static final int LIMB_CNT = 10;
+ private static final long TWO_TO_25 = 1 << 25;
+ private static final long TWO_TO_26 = TWO_TO_25 << 1;
+
+ private static final int[] EXPAND_START = {0, 3, 6, 9, 12, 16, 19, 22, 25, 28};
+ private static final int[] EXPAND_SHIFT = {0, 2, 3, 5, 6, 0, 1, 3, 4, 6};
+ private static final int[] MASK = {0x3ffffff, 0x1ffffff};
+ private static final int[] SHIFT = {26, 25};
+
+ /**
+ * Sums two numbers: output = in1 + in2
+ * <p>
+ * On entry: in1, in2 are in reduced-size form.
+ */
+ static void sum(long[] output, long[] in1, long[] in2) {
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[i] = in1[i] + in2[i];
+ }
+ }
+
+ /**
+ * Sums two numbers: output += in
+ * <p>
+ * On entry: in is in reduced-size form.
+ */
+ static void sum(long[] output, long[] in) {
+ sum(output, output, in);
+ }
+
+ /**
+ * Find the difference of two numbers: output = in1 - in2
+ * (note the order of the arguments!).
+ * <p>
+ * On entry: in1, in2 are in reduced-size form.
+ */
+ static void sub(long[] output, long[] in1, long[] in2) {
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[i] = in1[i] - in2[i];
+ }
+ }
+
+ /**
+ * Find the difference of two numbers: output = in - output
+ * (note the order of the arguments!).
+ * <p>
+ * On entry: in, output are in reduced-size form.
+ */
+ static void sub(long[] output, long[] in) {
+ sub(output, in, output);
+ }
+
+ /**
+ * Multiply a number by a scalar: output = in * scalar
+ */
+ static void scalarProduct(long[] output, long[] in, long scalar) {
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[i] = in[i] * scalar;
+ }
+ }
+
+ /**
+ * Multiply two numbers: out = in2 * in
+ * <p>
+ * output must be distinct to both inputs. The inputs are reduced coefficient form,
+ * the output is not.
+ * <p>
+ * out[x] <= 14 * the largest product of the input limbs.
+ */
+ static void product(long[] out, long[] in2, long[] in) {
+ out[0] = in2[0] * in[0];
+ out[1] = in2[0] * in[1]
+ + in2[1] * in[0];
+ out[2] = 2 * in2[1] * in[1]
+ + in2[0] * in[2]
+ + in2[2] * in[0];
+ out[3] = in2[1] * in[2]
+ + in2[2] * in[1]
+ + in2[0] * in[3]
+ + in2[3] * in[0];
+ out[4] = in2[2] * in[2]
+ + 2 * (in2[1] * in[3] + in2[3] * in[1])
+ + in2[0] * in[4]
+ + in2[4] * in[0];
+ out[5] = in2[2] * in[3]
+ + in2[3] * in[2]
+ + in2[1] * in[4]
+ + in2[4] * in[1]
+ + in2[0] * in[5]
+ + in2[5] * in[0];
+ out[6] = 2 * (in2[3] * in[3] + in2[1] * in[5] + in2[5] * in[1])
+ + in2[2] * in[4]
+ + in2[4] * in[2]
+ + in2[0] * in[6]
+ + in2[6] * in[0];
+ out[7] = in2[3] * in[4]
+ + in2[4] * in[3]
+ + in2[2] * in[5]
+ + in2[5] * in[2]
+ + in2[1] * in[6]
+ + in2[6] * in[1]
+ + in2[0] * in[7]
+ + in2[7] * in[0];
+ out[8] = in2[4] * in[4]
+ + 2 * (in2[3] * in[5] + in2[5] * in[3] + in2[1] * in[7] + in2[7] * in[1])
+ + in2[2] * in[6]
+ + in2[6] * in[2]
+ + in2[0] * in[8]
+ + in2[8] * in[0];
+ out[9] = in2[4] * in[5]
+ + in2[5] * in[4]
+ + in2[3] * in[6]
+ + in2[6] * in[3]
+ + in2[2] * in[7]
+ + in2[7] * in[2]
+ + in2[1] * in[8]
+ + in2[8] * in[1]
+ + in2[0] * in[9]
+ + in2[9] * in[0];
+ out[10] =
+ 2 * (in2[5] * in[5] + in2[3] * in[7] + in2[7] * in[3] + in2[1] * in[9] + in2[9] * in[1])
+ + in2[4] * in[6]
+ + in2[6] * in[4]
+ + in2[2] * in[8]
+ + in2[8] * in[2];
+ out[11] = in2[5] * in[6]
+ + in2[6] * in[5]
+ + in2[4] * in[7]
+ + in2[7] * in[4]
+ + in2[3] * in[8]
+ + in2[8] * in[3]
+ + in2[2] * in[9]
+ + in2[9] * in[2];
+ out[12] = in2[6] * in[6]
+ + 2 * (in2[5] * in[7] + in2[7] * in[5] + in2[3] * in[9] + in2[9] * in[3])
+ + in2[4] * in[8]
+ + in2[8] * in[4];
+ out[13] = in2[6] * in[7]
+ + in2[7] * in[6]
+ + in2[5] * in[8]
+ + in2[8] * in[5]
+ + in2[4] * in[9]
+ + in2[9] * in[4];
+ out[14] = 2 * (in2[7] * in[7] + in2[5] * in[9] + in2[9] * in[5])
+ + in2[6] * in[8]
+ + in2[8] * in[6];
+ out[15] = in2[7] * in[8]
+ + in2[8] * in[7]
+ + in2[6] * in[9]
+ + in2[9] * in[6];
+ out[16] = in2[8] * in[8]
+ + 2 * (in2[7] * in[9] + in2[9] * in[7]);
+ out[17] = in2[8] * in[9]
+ + in2[9] * in[8];
+ out[18] = 2 * in2[9] * in[9];
+ }
+
+ /**
+ * Reduce a field element by calling reduceSizeByModularReduction and reduceCoefficients.
+ *
+ * @param input An input array of any length. If the array has 19 elements, it will be used as
+ * temporary buffer and its contents changed.
+ * @param output An output array of size LIMB_CNT. After the call |output[i]| < 2^26 will hold.
+ */
+ static void reduce(long[] input, long[] output) {
+ long[] tmp;
+ if (input.length == 19) {
+ tmp = input;
+ } else {
+ tmp = new long[19];
+ System.arraycopy(input, 0, tmp, 0, input.length);
+ }
+ reduceSizeByModularReduction(tmp);
+ reduceCoefficients(tmp);
+ System.arraycopy(tmp, 0, output, 0, LIMB_CNT);
+ }
+
+ /**
+ * Reduce a long form to a reduced-size form by taking the input mod 2^255 - 19.
+ * <p>
+ * On entry: |output[i]| < 14*2^54
+ * On exit: |output[0..8]| < 280*2^54
+ */
+ static void reduceSizeByModularReduction(long[] output) {
+ // The coefficients x[10], x[11],..., x[18] are eliminated by reduction modulo 2^255 - 19.
+ // For example, the coefficient x[18] is multiplied by 19 and added to the coefficient x[8].
+ //
+ // Each of these shifts and adds ends up multiplying the value by 19.
+ //
+ // For output[0..8], the absolute entry value is < 14*2^54 and we add, at most, 19*14*2^54 thus,
+ // on exit, |output[0..8]| < 280*2^54.
+ output[8] += output[18] << 4;
+ output[8] += output[18] << 1;
+ output[8] += output[18];
+ output[7] += output[17] << 4;
+ output[7] += output[17] << 1;
+ output[7] += output[17];
+ output[6] += output[16] << 4;
+ output[6] += output[16] << 1;
+ output[6] += output[16];
+ output[5] += output[15] << 4;
+ output[5] += output[15] << 1;
+ output[5] += output[15];
+ output[4] += output[14] << 4;
+ output[4] += output[14] << 1;
+ output[4] += output[14];
+ output[3] += output[13] << 4;
+ output[3] += output[13] << 1;
+ output[3] += output[13];
+ output[2] += output[12] << 4;
+ output[2] += output[12] << 1;
+ output[2] += output[12];
+ output[1] += output[11] << 4;
+ output[1] += output[11] << 1;
+ output[1] += output[11];
+ output[0] += output[10] << 4;
+ output[0] += output[10] << 1;
+ output[0] += output[10];
+ }
+
+ /**
+ * Reduce all coefficients of the short form input so that |x| < 2^26.
+ * <p>
+ * On entry: |output[i]| < 280*2^54
+ */
+ static void reduceCoefficients(long[] output) {
+ output[10] = 0;
+
+ for (int i = 0; i < LIMB_CNT; i += 2) {
+ long over = output[i] / TWO_TO_26;
+ // The entry condition (that |output[i]| < 280*2^54) means that over is, at most, 280*2^28 in
+ // the first iteration of this loop. This is added to the next limb and we can approximate the
+ // resulting bound of that limb by 281*2^54.
+ output[i] -= over << 26;
+ output[i + 1] += over;
+
+ // For the first iteration, |output[i+1]| < 281*2^54, thus |over| < 281*2^29. When this is
+ // added to the next limb, the resulting bound can be approximated as 281*2^54.
+ //
+ // For subsequent iterations of the loop, 281*2^54 remains a conservative bound and no
+ // overflow occurs.
+ over = output[i + 1] / TWO_TO_25;
+ output[i + 1] -= over << 25;
+ output[i + 2] += over;
+ }
+ // Now |output[10]| < 281*2^29 and all other coefficients are reduced.
+ output[0] += output[10] << 4;
+ output[0] += output[10] << 1;
+ output[0] += output[10];
+
+ output[10] = 0;
+ // Now output[1..9] are reduced, and |output[0]| < 2^26 + 19*281*2^29 so |over| will be no more
+ // than 2^16.
+ long over = output[0] / TWO_TO_26;
+ output[0] -= over << 26;
+ output[1] += over;
+ // Now output[0,2..9] are reduced, and |output[1]| < 2^25 + 2^16 < 2^26. The bound on
+ // |output[1]| is sufficient to meet our needs.
+ }
+
+ /**
+ * A helpful wrapper around {@ref Field25519#product}: output = in * in2.
+ * <p>
+ * On entry: |in[i]| < 2^27 and |in2[i]| < 2^27.
+ * <p>
+ * The output is reduced degree (indeed, one need only provide storage for 10 limbs) and
+ * |output[i]| < 2^26.
+ */
+ static void mult(long[] output, long[] in, long[] in2) {
+ long[] t = new long[19];
+ product(t, in, in2);
+ // |t[i]| < 2^26
+ reduce(t, output);
+ }
+
+ /**
+ * Square a number: out = in**2
+ * <p>
+ * output must be distinct from the input. The inputs are reduced coefficient form, the output is
+ * not.
+ * <p>
+ * out[x] <= 14 * the largest product of the input limbs.
+ */
+ private static void squareInner(long[] out, long[] in) {
+ out[0] = in[0] * in[0];
+ out[1] = 2 * in[0] * in[1];
+ out[2] = 2 * (in[1] * in[1] + in[0] * in[2]);
+ out[3] = 2 * (in[1] * in[2] + in[0] * in[3]);
+ out[4] = in[2] * in[2]
+ + 4 * in[1] * in[3]
+ + 2 * in[0] * in[4];
+ out[5] = 2 * (in[2] * in[3] + in[1] * in[4] + in[0] * in[5]);
+ out[6] = 2 * (in[3] * in[3] + in[2] * in[4] + in[0] * in[6] + 2 * in[1] * in[5]);
+ out[7] = 2 * (in[3] * in[4] + in[2] * in[5] + in[1] * in[6] + in[0] * in[7]);
+ out[8] = in[4] * in[4]
+ + 2 * (in[2] * in[6] + in[0] * in[8] + 2 * (in[1] * in[7] + in[3] * in[5]));
+ out[9] = 2 * (in[4] * in[5] + in[3] * in[6] + in[2] * in[7] + in[1] * in[8] + in[0] * in[9]);
+ out[10] = 2 * (in[5] * in[5]
+ + in[4] * in[6]
+ + in[2] * in[8]
+ + 2 * (in[3] * in[7] + in[1] * in[9]));
+ out[11] = 2 * (in[5] * in[6] + in[4] * in[7] + in[3] * in[8] + in[2] * in[9]);
+ out[12] = in[6] * in[6]
+ + 2 * (in[4] * in[8] + 2 * (in[5] * in[7] + in[3] * in[9]));
+ out[13] = 2 * (in[6] * in[7] + in[5] * in[8] + in[4] * in[9]);
+ out[14] = 2 * (in[7] * in[7] + in[6] * in[8] + 2 * in[5] * in[9]);
+ out[15] = 2 * (in[7] * in[8] + in[6] * in[9]);
+ out[16] = in[8] * in[8] + 4 * in[7] * in[9];
+ out[17] = 2 * in[8] * in[9];
+ out[18] = 2 * in[9] * in[9];
+ }
+
+ /**
+ * Returns in^2.
+ * <p>
+ * On entry: The |in| argument is in reduced coefficients form and |in[i]| < 2^27.
+ * <p>
+ * On exit: The |output| argument is in reduced coefficients form (indeed, one need only provide
+ * storage for 10 limbs) and |out[i]| < 2^26.
+ */
+ static void square(long[] output, long[] in) {
+ long[] t = new long[19];
+ squareInner(t, in);
+ // |t[i]| < 14*2^54 because the largest product of two limbs will be < 2^(27+27) and SquareInner
+ // adds together, at most, 14 of those products.
+ reduce(t, output);
+ }
+
+ /**
+ * Takes a little-endian, 32-byte number and expands it into mixed radix form.
+ */
+ static long[] expand(byte[] input) {
+ long[] output = new long[LIMB_CNT];
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[i] = ((((long) (input[EXPAND_START[i]] & 0xff))
+ | ((long) (input[EXPAND_START[i] + 1] & 0xff)) << 8
+ | ((long) (input[EXPAND_START[i] + 2] & 0xff)) << 16
+ | ((long) (input[EXPAND_START[i] + 3] & 0xff)) << 24) >> EXPAND_SHIFT[i]) & MASK[i & 1];
+ }
+ return output;
+ }
+
+ /**
+ * Takes a fully reduced mixed radix form number and contract it into a little-endian, 32-byte
+ * array.
+ * <p>
+ * On entry: |input_limbs[i]| < 2^26
+ */
+ @SuppressWarnings("NarrowingCompoundAssignment")
+ static byte[] contract(long[] inputLimbs) {
+ long[] input = Arrays.copyOf(inputLimbs, LIMB_CNT);
+ for (int j = 0; j < 2; j++) {
+ for (int i = 0; i < 9; i++) {
+ // This calculation is a time-invariant way to make input[i] non-negative by borrowing
+ // from the next-larger limb.
+ int carry = -(int) ((input[i] & (input[i] >> 31)) >> SHIFT[i & 1]);
+ input[i] = input[i] + (carry << SHIFT[i & 1]);
+ input[i + 1] -= carry;
+ }
+
+ // There's no greater limb for input[9] to borrow from, but we can multiply by 19 and borrow
+ // from input[0], which is valid mod 2^255-19.
+ {
+ int carry = -(int) ((input[9] & (input[9] >> 31)) >> 25);
+ input[9] += (carry << 25);
+ input[0] -= (carry * 19);
+ }
+
+ // After the first iteration, input[1..9] are non-negative and fit within 25 or 26 bits,
+ // depending on position. However, input[0] may be negative.
+ }
+
+ // The first borrow-propagation pass above ended with every limb except (possibly) input[0]
+ // non-negative.
+ //
+ // If input[0] was negative after the first pass, then it was because of a carry from input[9].
+ // On entry, input[9] < 2^26 so the carry was, at most, one, since (2**26-1) >> 25 = 1. Thus
+ // input[0] >= -19.
+ //
+ // In the second pass, each limb is decreased by at most one. Thus the second borrow-propagation
+ // pass could only have wrapped around to decrease input[0] again if the first pass left
+ // input[0] negative *and* input[1] through input[9] were all zero. In that case, input[1] is
+ // now 2^25 - 1, and this last borrow-propagation step will leave input[1] non-negative.
+ {
+ int carry = -(int) ((input[0] & (input[0] >> 31)) >> 26);
+ input[0] += (carry << 26);
+ input[1] -= carry;
+ }
+
+ // All input[i] are now non-negative. However, there might be values between 2^25 and 2^26 in a
+ // limb which is, nominally, 25 bits wide.
+ for (int j = 0; j < 2; j++) {
+ for (int i = 0; i < 9; i++) {
+ int carry = (int) (input[i] >> SHIFT[i & 1]);
+ input[i] &= MASK[i & 1];
+ input[i + 1] += carry;
+ }
+ }
+
+ {
+ int carry = (int) (input[9] >> 25);
+ input[9] &= 0x1ffffff;
+ input[0] += 19 * carry;
+ }
+
+ // If the first carry-chain pass, just above, ended up with a carry from input[9], and that
+ // caused input[0] to be out-of-bounds, then input[0] was < 2^26 + 2*19, because the carry was,
+ // at most, two.
+ //
+ // If the second pass carried from input[9] again then input[0] is < 2*19 and the input[9] ->
+ // input[0] carry didn't push input[0] out of bounds.
+
+ // It still remains the case that input might be between 2^255-19 and 2^255. In this case,
+ // input[1..9] must take their maximum value and input[0] must be >= (2^255-19) & 0x3ffffff,
+ // which is 0x3ffffed.
+ int mask = gte((int) input[0], 0x3ffffed);
+ for (int i = 1; i < LIMB_CNT; i++) {
+ mask &= eq((int) input[i], MASK[i & 1]);
+ }
+
+ // mask is either 0xffffffff (if input >= 2^255-19) and zero otherwise. Thus this conditionally
+ // subtracts 2^255-19.
+ input[0] -= mask & 0x3ffffed;
+ input[1] -= mask & 0x1ffffff;
+ for (int i = 2; i < LIMB_CNT; i += 2) {
+ input[i] -= mask & 0x3ffffff;
+ input[i + 1] -= mask & 0x1ffffff;
+ }
+
+ for (int i = 0; i < LIMB_CNT; i++) {
+ input[i] <<= EXPAND_SHIFT[i];
+ }
+ byte[] output = new byte[FIELD_LEN];
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[EXPAND_START[i]] |= input[i] & 0xff;
+ output[EXPAND_START[i] + 1] |= (input[i] >> 8) & 0xff;
+ output[EXPAND_START[i] + 2] |= (input[i] >> 16) & 0xff;
+ output[EXPAND_START[i] + 3] |= (input[i] >> 24) & 0xff;
+ }
+ return output;
+ }
+
+ /**
+ * Computes inverse of z = z(2^255 - 21)
+ * <p>
+ * Shamelessly copied from agl's code which was shamelessly copied from djb's code. Only the
+ * comment format and the variable namings are different from those.
+ */
+ static void inverse(long[] out, long[] z) {
+ long[] z2 = new long[Field25519.LIMB_CNT];
+ long[] z9 = new long[Field25519.LIMB_CNT];
+ long[] z11 = new long[Field25519.LIMB_CNT];
+ long[] z2To5Minus1 = new long[Field25519.LIMB_CNT];
+ long[] z2To10Minus1 = new long[Field25519.LIMB_CNT];
+ long[] z2To20Minus1 = new long[Field25519.LIMB_CNT];
+ long[] z2To50Minus1 = new long[Field25519.LIMB_CNT];
+ long[] z2To100Minus1 = new long[Field25519.LIMB_CNT];
+ long[] t0 = new long[Field25519.LIMB_CNT];
+ long[] t1 = new long[Field25519.LIMB_CNT];
+
+ square(z2, z); // 2
+ square(t1, z2); // 4
+ square(t0, t1); // 8
+ mult(z9, t0, z); // 9
+ mult(z11, z9, z2); // 11
+ square(t0, z11); // 22
+ mult(z2To5Minus1, t0, z9); // 2^5 - 2^0 = 31
+
+ square(t0, z2To5Minus1); // 2^6 - 2^1
+ square(t1, t0); // 2^7 - 2^2
+ square(t0, t1); // 2^8 - 2^3
+ square(t1, t0); // 2^9 - 2^4
+ square(t0, t1); // 2^10 - 2^5
+ mult(z2To10Minus1, t0, z2To5Minus1); // 2^10 - 2^0
+
+ square(t0, z2To10Minus1); // 2^11 - 2^1
+ square(t1, t0); // 2^12 - 2^2
+ for (int i = 2; i < 10; i += 2) { // 2^20 - 2^10
+ square(t0, t1);
+ square(t1, t0);
+ }
+ mult(z2To20Minus1, t1, z2To10Minus1); // 2^20 - 2^0
+
+ square(t0, z2To20Minus1); // 2^21 - 2^1
+ square(t1, t0); // 2^22 - 2^2
+ for (int i = 2; i < 20; i += 2) { // 2^40 - 2^20
+ square(t0, t1);
+ square(t1, t0);
+ }
+ mult(t0, t1, z2To20Minus1); // 2^40 - 2^0
+
+ square(t1, t0); // 2^41 - 2^1
+ square(t0, t1); // 2^42 - 2^2
+ for (int i = 2; i < 10; i += 2) { // 2^50 - 2^10
+ square(t1, t0);
+ square(t0, t1);
+ }
+ mult(z2To50Minus1, t0, z2To10Minus1); // 2^50 - 2^0
+
+ square(t0, z2To50Minus1); // 2^51 - 2^1
+ square(t1, t0); // 2^52 - 2^2
+ for (int i = 2; i < 50; i += 2) { // 2^100 - 2^50
+ square(t0, t1);
+ square(t1, t0);
+ }
+ mult(z2To100Minus1, t1, z2To50Minus1); // 2^100 - 2^0
+
+ square(t1, z2To100Minus1); // 2^101 - 2^1
+ square(t0, t1); // 2^102 - 2^2
+ for (int i = 2; i < 100; i += 2) { // 2^200 - 2^100
+ square(t1, t0);
+ square(t0, t1);
+ }
+ mult(t1, t0, z2To100Minus1); // 2^200 - 2^0
+
+ square(t0, t1); // 2^201 - 2^1
+ square(t1, t0); // 2^202 - 2^2
+ for (int i = 2; i < 50; i += 2) { // 2^250 - 2^50
+ square(t0, t1);
+ square(t1, t0);
+ }
+ mult(t0, t1, z2To50Minus1); // 2^250 - 2^0
+
+ square(t1, t0); // 2^251 - 2^1
+ square(t0, t1); // 2^252 - 2^2
+ square(t1, t0); // 2^253 - 2^3
+ square(t0, t1); // 2^254 - 2^4
+ square(t1, t0); // 2^255 - 2^5
+ mult(out, t1, z11); // 2^255 - 21
+ }
+
+
+ /**
+ * Returns 0xffffffff iff a == b and zero otherwise.
+ */
+ private static int eq(int a, int b) {
+ a = ~(a ^ b);
+ a &= a << 16;
+ a &= a << 8;
+ a &= a << 4;
+ a &= a << 2;
+ a &= a << 1;
+ return a >> 31;
+ }
+
+ /**
+ * returns 0xffffffff if a >= b and zero otherwise, where a and b are both non-negative.
+ */
+ private static int gte(int a, int b) {
+ a -= b;
+ // a >= 0 iff a >= b.
+ return ~(a >> 31);
+ }
+ }
+
+ // (x = 0, y = 1) point
+ private static final CachedXYT CACHED_NEUTRAL = new CachedXYT(
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ new long[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0});
+ private static final PartialXYZT NEUTRAL = new PartialXYZT(
+ new XYZ(new long[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}),
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0});
+
+ /**
+ * Projective point representation (X:Y:Z) satisfying x = X/Z, y = Y/Z
+ * <p>
+ * Note that this is referred as ge_p2 in ref10 impl.
+ * Also note that x = X, y = Y and z = Z below following Java coding style.
+ * <p>
+ * See
+ * Koyama K., Tsuruoka Y. (1993) Speeding up Elliptic Cryptosystems by Using a Signed Binary
+ * Window Method.
+ * <p>
+ * https://hyperelliptic.org/EFD/g1p/auto-twisted-projective.html
+ */
+ private static class XYZ {
+
+ final long[] x;
+ final long[] y;
+ final long[] z;
+
+ XYZ() {
+ this(new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT]);
+ }
+
+ XYZ(long[] x, long[] y, long[] z) {
+ this.x = x;
+ this.y = y;
+ this.z = z;
+ }
+
+ XYZ(XYZ xyz) {
+ x = Arrays.copyOf(xyz.x, Field25519.LIMB_CNT);
+ y = Arrays.copyOf(xyz.y, Field25519.LIMB_CNT);
+ z = Arrays.copyOf(xyz.z, Field25519.LIMB_CNT);
+ }
+
+ XYZ(PartialXYZT partialXYZT) {
+ this();
+ fromPartialXYZT(this, partialXYZT);
+ }
+
+ /**
+ * ge_p1p1_to_p2.c
+ */
+ static XYZ fromPartialXYZT(XYZ out, PartialXYZT in) {
+ Field25519.mult(out.x, in.xyz.x, in.t);
+ Field25519.mult(out.y, in.xyz.y, in.xyz.z);
+ Field25519.mult(out.z, in.xyz.z, in.t);
+ return out;
+ }
+
+ /**
+ * Encodes this point to bytes.
+ */
+ byte[] toBytes() {
+ long[] recip = new long[Field25519.LIMB_CNT];
+ long[] x = new long[Field25519.LIMB_CNT];
+ long[] y = new long[Field25519.LIMB_CNT];
+ Field25519.inverse(recip, z);
+ Field25519.mult(x, this.x, recip);
+ Field25519.mult(y, this.y, recip);
+ byte[] s = Field25519.contract(y);
+ s[31] = (byte) (s[31] ^ (getLsb(x) << 7));
+ return s;
+ }
+
+
+ /**
+ * Best effort fix-timing array comparison.
+ *
+ * @return true if two arrays are equal.
+ */
+ private static boolean bytesEqual(final byte[] x, final byte[] y) {
+ if (x == null || y == null) {
+ return false;
+ }
+ if (x.length != y.length) {
+ return false;
+ }
+ int res = 0;
+ for (int i = 0; i < x.length; i++) {
+ res |= x[i] ^ y[i];
+ }
+ return res == 0;
+ }
+
+ /**
+ * Checks that the point is on curve
+ */
+ boolean isOnCurve() {
+ long[] x2 = new long[Field25519.LIMB_CNT];
+ Field25519.square(x2, x);
+ long[] y2 = new long[Field25519.LIMB_CNT];
+ Field25519.square(y2, y);
+ long[] z2 = new long[Field25519.LIMB_CNT];
+ Field25519.square(z2, z);
+ long[] z4 = new long[Field25519.LIMB_CNT];
+ Field25519.square(z4, z2);
+ long[] lhs = new long[Field25519.LIMB_CNT];
+ // lhs = y^2 - x^2
+ Field25519.sub(lhs, y2, x2);
+ // lhs = z^2 * (y2 - x2)
+ Field25519.mult(lhs, lhs, z2);
+ long[] rhs = new long[Field25519.LIMB_CNT];
+ // rhs = x^2 * y^2
+ Field25519.mult(rhs, x2, y2);
+ // rhs = D * x^2 * y^2
+ Field25519.mult(rhs, rhs, D);
+ // rhs = z^4 + D * x^2 * y^2
+ Field25519.sum(rhs, z4);
+ // Field25519.mult reduces its output, but Field25519.sum does not, so we have to manually
+ // reduce it here.
+ Field25519.reduce(rhs, rhs);
+ // z^2 (y^2 - x^2) == z^4 + D * x^2 * y^2
+ return bytesEqual(Field25519.contract(lhs), Field25519.contract(rhs));
+ }
+ }
+
+ /**
+ * Represents extended projective point representation (X:Y:Z:T) satisfying x = X/Z, y = Y/Z,
+ * XY = ZT
+ * <p>
+ * Note that this is referred as ge_p3 in ref10 impl.
+ * Also note that t = T below following Java coding style.
+ * <p>
+ * See
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ * <p>
+ * https://hyperelliptic.org/EFD/g1p/auto-twisted-extended.html
+ */
+ private static class XYZT {
+
+ final XYZ xyz;
+ final long[] t;
+
+ XYZT() {
+ this(new XYZ(), new long[Field25519.LIMB_CNT]);
+ }
+
+ XYZT(XYZ xyz, long[] t) {
+ this.xyz = xyz;
+ this.t = t;
+ }
+
+ XYZT(PartialXYZT partialXYZT) {
+ this();
+ fromPartialXYZT(this, partialXYZT);
+ }
+
+ /**
+ * ge_p1p1_to_p2.c
+ */
+ private static XYZT fromPartialXYZT(XYZT out, PartialXYZT in) {
+ Field25519.mult(out.xyz.x, in.xyz.x, in.t);
+ Field25519.mult(out.xyz.y, in.xyz.y, in.xyz.z);
+ Field25519.mult(out.xyz.z, in.xyz.z, in.t);
+ Field25519.mult(out.t, in.xyz.x, in.xyz.y);
+ return out;
+ }
+
+ /**
+ * Decodes {@code s} into an extented projective point.
+ * See Section 5.1.3 Decoding in https://tools.ietf.org/html/rfc8032#section-5.1.3
+ */
+ private static XYZT fromBytesNegateVarTime(byte[] s) throws GeneralSecurityException {
+ long[] x = new long[Field25519.LIMB_CNT];
+ long[] y = Field25519.expand(s);
+ long[] z = new long[Field25519.LIMB_CNT];
+ z[0] = 1;
+ long[] t = new long[Field25519.LIMB_CNT];
+ long[] u = new long[Field25519.LIMB_CNT];
+ long[] v = new long[Field25519.LIMB_CNT];
+ long[] vxx = new long[Field25519.LIMB_CNT];
+ long[] check = new long[Field25519.LIMB_CNT];
+ Field25519.square(u, y);
+ Field25519.mult(v, u, D);
+ Field25519.sub(u, u, z); // u = y^2 - 1
+ Field25519.sum(v, v, z); // v = dy^2 + 1
+
+ long[] v3 = new long[Field25519.LIMB_CNT];
+ Field25519.square(v3, v);
+ Field25519.mult(v3, v3, v); // v3 = v^3
+ Field25519.square(x, v3);
+ Field25519.mult(x, x, v);
+ Field25519.mult(x, x, u); // x = uv^7
+
+ pow2252m3(x, x); // x = (uv^7)^((q-5)/8)
+ Field25519.mult(x, x, v3);
+ Field25519.mult(x, x, u); // x = uv^3(uv^7)^((q-5)/8)
+
+ Field25519.square(vxx, x);
+ Field25519.mult(vxx, vxx, v);
+ Field25519.sub(check, vxx, u); // vx^2-u
+ if (isNonZeroVarTime(check)) {
+ Field25519.sum(check, vxx, u); // vx^2+u
+ if (isNonZeroVarTime(check)) {
+ throw new GeneralSecurityException("Cannot convert given bytes to extended projective "
+ + "coordinates. No square root exists for modulo 2^255-19");
+ }
+ Field25519.mult(x, x, SQRTM1);
+ }
+
+ if (!isNonZeroVarTime(x) && (s[31] & 0xff) >> 7 != 0) {
+ throw new GeneralSecurityException("Cannot convert given bytes to extended projective "
+ + "coordinates. Computed x is zero and encoded x's least significant bit is not zero");
+ }
+ if (getLsb(x) == ((s[31] & 0xff) >> 7)) {
+ neg(x, x);
+ }
+
+ Field25519.mult(t, x, y);
+ return new XYZT(new XYZ(x, y, z), t);
+ }
+ }
+
+ /**
+ * Partial projective point representation ((X:Z),(Y:T)) satisfying x=X/Z, y=Y/T
+ * <p>
+ * Note that this is referred as complete form in the original ref10 impl (ge_p1p1).
+ * Also note that t = T below following Java coding style.
+ * <p>
+ * Although this has the same types as XYZT, it is redefined to have its own type so that it is
+ * readable and 1:1 corresponds to ref10 impl.
+ * <p>
+ * Can be converted to XYZT as follows:
+ * X1 = X * T = x * Z * T = x * Z1
+ * Y1 = Y * Z = y * T * Z = y * Z1
+ * Z1 = Z * T = Z * T
+ * T1 = X * Y = x * Z * y * T = x * y * Z1 = X1Y1 / Z1
+ */
+ private static class PartialXYZT {
+
+ final XYZ xyz;
+ final long[] t;
+
+ PartialXYZT() {
+ this(new XYZ(), new long[Field25519.LIMB_CNT]);
+ }
+
+ PartialXYZT(XYZ xyz, long[] t) {
+ this.xyz = xyz;
+ this.t = t;
+ }
+
+ PartialXYZT(PartialXYZT other) {
+ xyz = new XYZ(other.xyz);
+ t = Arrays.copyOf(other.t, Field25519.LIMB_CNT);
+ }
+ }
+
+ /**
+ * Corresponds to the caching mentioned in the last paragraph of Section 3.1 of
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ * with Z = 1.
+ */
+ private static class CachedXYT {
+
+ final long[] yPlusX;
+ final long[] yMinusX;
+ final long[] t2d;
+
+ /**
+ * Creates a cached XYZT with Z = 1
+ *
+ * @param yPlusX y + x
+ * @param yMinusX y - x
+ * @param t2d 2d * xy
+ */
+ CachedXYT(long[] yPlusX, long[] yMinusX, long[] t2d) {
+ this.yPlusX = yPlusX;
+ this.yMinusX = yMinusX;
+ this.t2d = t2d;
+ }
+
+ CachedXYT(CachedXYT other) {
+ yPlusX = Arrays.copyOf(other.yPlusX, Field25519.LIMB_CNT);
+ yMinusX = Arrays.copyOf(other.yMinusX, Field25519.LIMB_CNT);
+ t2d = Arrays.copyOf(other.t2d, Field25519.LIMB_CNT);
+ }
+
+ // z is one implicitly, so this just copies {@code in} to {@code output}.
+ void multByZ(long[] output, long[] in) {
+ System.arraycopy(in, 0, output, 0, Field25519.LIMB_CNT);
+ }
+
+ /**
+ * If icopy is 1, copies {@code other} into this point. Time invariant wrt to icopy value.
+ */
+ void copyConditional(CachedXYT other, int icopy) {
+ copyConditional(yPlusX, other.yPlusX, icopy);
+ copyConditional(yMinusX, other.yMinusX, icopy);
+ copyConditional(t2d, other.t2d, icopy);
+ }
+
+ /**
+ * Conditionally copies a reduced-form limb arrays {@code b} into {@code a} if {@code icopy} is 1,
+ * but leave {@code a} unchanged if 'iswap' is 0. Runs in data-invariant time to avoid
+ * side-channel attacks.
+ *
+ * <p>NOTE that this function requires that {@code icopy} be 1 or 0; other values give wrong
+ * results. Also, the two limb arrays must be in reduced-coefficient, reduced-degree form: the
+ * values in a[10..19] or b[10..19] aren't swapped, and all all values in a[0..9],b[0..9] must
+ * have magnitude less than Integer.MAX_VALUE.
+ */
+ static void copyConditional(long[] a, long[] b, int icopy) {
+ int copy = -icopy;
+ for (int i = 0; i < Field25519.LIMB_CNT; i++) {
+ int x = copy & (((int) a[i]) ^ ((int) b[i]));
+ a[i] = ((int) a[i]) ^ x;
+ }
+ }
+ }
+
+ private static class CachedXYZT extends CachedXYT {
+
+ private final long[] z;
+
+ CachedXYZT() {
+ this(new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT]);
+ }
+
+ /**
+ * ge_p3_to_cached.c
+ */
+ CachedXYZT(XYZT xyzt) {
+ this();
+ Field25519.sum(yPlusX, xyzt.xyz.y, xyzt.xyz.x);
+ Field25519.sub(yMinusX, xyzt.xyz.y, xyzt.xyz.x);
+ System.arraycopy(xyzt.xyz.z, 0, z, 0, Field25519.LIMB_CNT);
+ Field25519.mult(t2d, xyzt.t, D2);
+ }
+
+ /**
+ * Creates a cached XYZT
+ *
+ * @param yPlusX Y + X
+ * @param yMinusX Y - X
+ * @param z Z
+ * @param t2d 2d * (XY/Z)
+ */
+ CachedXYZT(long[] yPlusX, long[] yMinusX, long[] z, long[] t2d) {
+ super(yPlusX, yMinusX, t2d);
+ this.z = z;
+ }
+
+ @Override
+ public void multByZ(long[] output, long[] in) {
+ Field25519.mult(output, in, z);
+ }
+ }
+
+ /**
+ * Addition defined in Section 3.1 of
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ * <p>
+ * Please note that this is a partial of the operation listed there leaving out the final
+ * conversion from PartialXYZT to XYZT.
+ *
+ * @param extended extended projective point input
+ * @param cached cached projective point input
+ */
+ private static void add(PartialXYZT partialXYZT, XYZT extended, CachedXYT cached) {
+ long[] t = new long[Field25519.LIMB_CNT];
+
+ // Y1 + X1
+ Field25519.sum(partialXYZT.xyz.x, extended.xyz.y, extended.xyz.x);
+
+ // Y1 - X1
+ Field25519.sub(partialXYZT.xyz.y, extended.xyz.y, extended.xyz.x);
+
+ // A = (Y1 - X1) * (Y2 - X2)
+ Field25519.mult(partialXYZT.xyz.y, partialXYZT.xyz.y, cached.yMinusX);
+
+ // B = (Y1 + X1) * (Y2 + X2)
+ Field25519.mult(partialXYZT.xyz.z, partialXYZT.xyz.x, cached.yPlusX);
+
+ // C = T1 * 2d * T2 = 2d * T1 * T2 (2d is written as k in the paper)
+ Field25519.mult(partialXYZT.t, extended.t, cached.t2d);
+
+ // Z1 * Z2
+ cached.multByZ(partialXYZT.xyz.x, extended.xyz.z);
+
+ // D = 2 * Z1 * Z2
+ Field25519.sum(t, partialXYZT.xyz.x, partialXYZT.xyz.x);
+
+ // X3 = B - A
+ Field25519.sub(partialXYZT.xyz.x, partialXYZT.xyz.z, partialXYZT.xyz.y);
+
+ // Y3 = B + A
+ Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.y);
+
+ // Z3 = D + C
+ Field25519.sum(partialXYZT.xyz.z, t, partialXYZT.t);
+
+ // T3 = D - C
+ Field25519.sub(partialXYZT.t, t, partialXYZT.t);
+ }
+
+ /**
+ * Based on the addition defined in Section 3.1 of
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ * <p>
+ * Please note that this is a partial of the operation listed there leaving out the final
+ * conversion from PartialXYZT to XYZT.
+ *
+ * @param extended extended projective point input
+ * @param cached cached projective point input
+ */
+ private static void sub(PartialXYZT partialXYZT, XYZT extended, CachedXYT cached) {
+ long[] t = new long[Field25519.LIMB_CNT];
+
+ // Y1 + X1
+ Field25519.sum(partialXYZT.xyz.x, extended.xyz.y, extended.xyz.x);
+
+ // Y1 - X1
+ Field25519.sub(partialXYZT.xyz.y, extended.xyz.y, extended.xyz.x);
+
+ // A = (Y1 - X1) * (Y2 + X2)
+ Field25519.mult(partialXYZT.xyz.y, partialXYZT.xyz.y, cached.yPlusX);
+
+ // B = (Y1 + X1) * (Y2 - X2)
+ Field25519.mult(partialXYZT.xyz.z, partialXYZT.xyz.x, cached.yMinusX);
+
+ // C = T1 * 2d * T2 = 2d * T1 * T2 (2d is written as k in the paper)
+ Field25519.mult(partialXYZT.t, extended.t, cached.t2d);
+
+ // Z1 * Z2
+ cached.multByZ(partialXYZT.xyz.x, extended.xyz.z);
+
+ // D = 2 * Z1 * Z2
+ Field25519.sum(t, partialXYZT.xyz.x, partialXYZT.xyz.x);
+
+ // X3 = B - A
+ Field25519.sub(partialXYZT.xyz.x, partialXYZT.xyz.z, partialXYZT.xyz.y);
+
+ // Y3 = B + A
+ Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.y);
+
+ // Z3 = D - C
+ Field25519.sub(partialXYZT.xyz.z, t, partialXYZT.t);
+
+ // T3 = D + C
+ Field25519.sum(partialXYZT.t, t, partialXYZT.t);
+ }
+
+ /**
+ * Doubles {@code p} and puts the result into this PartialXYZT.
+ * <p>
+ * This is based on the addition defined in formula 7 in Section 3.3 of
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ * <p>
+ * Please note that this is a partial of the operation listed there leaving out the final
+ * conversion from PartialXYZT to XYZT and also this fixes a typo in calculation of Y3 and T3 in
+ * the paper, H should be replaced with A+B.
+ */
+ private static void doubleXYZ(PartialXYZT partialXYZT, XYZ p) {
+ long[] t0 = new long[Field25519.LIMB_CNT];
+
+ // XX = X1^2
+ Field25519.square(partialXYZT.xyz.x, p.x);
+
+ // YY = Y1^2
+ Field25519.square(partialXYZT.xyz.z, p.y);
+
+ // B' = Z1^2
+ Field25519.square(partialXYZT.t, p.z);
+
+ // B = 2 * B'
+ Field25519.sum(partialXYZT.t, partialXYZT.t, partialXYZT.t);
+
+ // A = X1 + Y1
+ Field25519.sum(partialXYZT.xyz.y, p.x, p.y);
+
+ // AA = A^2
+ Field25519.square(t0, partialXYZT.xyz.y);
+
+ // Y3 = YY + XX
+ Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.x);
+
+ // Z3 = YY - XX
+ Field25519.sub(partialXYZT.xyz.z, partialXYZT.xyz.z, partialXYZT.xyz.x);
+
+ // X3 = AA - Y3
+ Field25519.sub(partialXYZT.xyz.x, t0, partialXYZT.xyz.y);
+
+ // T3 = B - Z3
+ Field25519.sub(partialXYZT.t, partialXYZT.t, partialXYZT.xyz.z);
+ }
+
+ /**
+ * Doubles {@code p} and puts the result into this PartialXYZT.
+ */
+ private static void doubleXYZT(PartialXYZT partialXYZT, XYZT p) {
+ doubleXYZ(partialXYZT, p.xyz);
+ }
+
+ /**
+ * Compares two byte values in constant time.
+ */
+ private static int eq(int a, int b) {
+ int r = ~(a ^ b) & 0xff;
+ r &= r << 4;
+ r &= r << 2;
+ r &= r << 1;
+ return (r >> 7) & 1;
+ }
+
+ /**
+ * This is a constant time operation where point b*B*256^pos is stored in {@code t}.
+ * When b is 0, t remains the same (i.e., neutral point).
+ * <p>
+ * Although B_TABLE[32][8] (B_TABLE[i][j] = (j+1)*B*256^i) has j values in [0, 7], the select
+ * method negates the corresponding point if b is negative (which is straight forward in elliptic
+ * curves by just negating y coordinate). Therefore we can get multiples of B with the half of
+ * memory requirements.
+ *
+ * @param t neutral element (i.e., point 0), also serves as output.
+ * @param pos in B[pos][j] = (j+1)*B*256^pos
+ * @param b value in [-8, 8] range.
+ */
+ private static void select(CachedXYT t, int pos, byte b) {
+ int bnegative = (b & 0xff) >> 7;
+ int babs = b - (((-bnegative) & b) << 1);
+
+ t.copyConditional(B_TABLE[pos][0], eq(babs, 1));
+ t.copyConditional(B_TABLE[pos][1], eq(babs, 2));
+ t.copyConditional(B_TABLE[pos][2], eq(babs, 3));
+ t.copyConditional(B_TABLE[pos][3], eq(babs, 4));
+ t.copyConditional(B_TABLE[pos][4], eq(babs, 5));
+ t.copyConditional(B_TABLE[pos][5], eq(babs, 6));
+ t.copyConditional(B_TABLE[pos][6], eq(babs, 7));
+ t.copyConditional(B_TABLE[pos][7], eq(babs, 8));
+
+ long[] yPlusX = Arrays.copyOf(t.yMinusX, Field25519.LIMB_CNT);
+ long[] yMinusX = Arrays.copyOf(t.yPlusX, Field25519.LIMB_CNT);
+ long[] t2d = Arrays.copyOf(t.t2d, Field25519.LIMB_CNT);
+ neg(t2d, t2d);
+ CachedXYT minust = new CachedXYT(yPlusX, yMinusX, t2d);
+ t.copyConditional(minust, bnegative);
+ }
+
+ /**
+ * Computes {@code a}*B
+ * where a = a[0]+256*a[1]+...+256^31 a[31] and
+ * B is the Ed25519 base point (x,4/5) with x positive.
+ * <p>
+ * Preconditions:
+ * a[31] <= 127
+ *
+ * @throws IllegalStateException iff there is arithmetic error.
+ */
+ @SuppressWarnings("NarrowingCompoundAssignment")
+ private static XYZ scalarMultWithBase(byte[] a) {
+ byte[] e = new byte[2 * Field25519.FIELD_LEN];
+ for (int i = 0; i < Field25519.FIELD_LEN; i++) {
+ e[2 * i + 0] = (byte) (((a[i] & 0xff) >> 0) & 0xf);
+ e[2 * i + 1] = (byte) (((a[i] & 0xff) >> 4) & 0xf);
+ }
+ // each e[i] is between 0 and 15
+ // e[63] is between 0 and 7
+
+ // Rewrite e in a way that each e[i] is in [-8, 8].
+ // This can be done since a[63] is in [0, 7], the carry-over onto the most significant byte
+ // a[63] can be at most 1.
+ int carry = 0;
+ for (int i = 0; i < e.length - 1; i++) {
+ e[i] += carry;
+ carry = e[i] + 8;
+ carry >>= 4;
+ e[i] -= carry << 4;
+ }
+ e[e.length - 1] += carry;
+
+ PartialXYZT ret = new PartialXYZT(NEUTRAL);
+ XYZT xyzt = new XYZT();
+ // Although B_TABLE's i can be at most 31 (stores only 32 4bit multiples of B) and we have 64
+ // 4bit values in e array, the below for loop adds cached values by iterating e by two in odd
+ // indices. After the result, we can double the result point 4 times to shift the multiplication
+ // scalar by 4 bits.
+ for (int i = 1; i < e.length; i += 2) {
+ CachedXYT t = new CachedXYT(CACHED_NEUTRAL);
+ select(t, i / 2, e[i]);
+ add(ret, XYZT.fromPartialXYZT(xyzt, ret), t);
+ }
+
+ // Doubles the result 4 times to shift the multiplication scalar 4 bits to get the actual result
+ // for the odd indices in e.
+ XYZ xyz = new XYZ();
+ doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret));
+ doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret));
+ doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret));
+ doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret));
+
+ // Add multiples of B for even indices of e.
+ for (int i = 0; i < e.length; i += 2) {
+ CachedXYT t = new CachedXYT(CACHED_NEUTRAL);
+ select(t, i / 2, e[i]);
+ add(ret, XYZT.fromPartialXYZT(xyzt, ret), t);
+ }
+
+ // This check is to protect against flaws, i.e. if there is a computation error through a
+ // faulty CPU or if the implementation contains a bug.
+ XYZ result = new XYZ(ret);
+ if (!result.isOnCurve()) {
+ throw new IllegalStateException("arithmetic error in scalar multiplication");
+ }
+ return result;
+ }
+
+ @SuppressWarnings("NarrowingCompoundAssignment")
+ private static byte[] slide(byte[] a) {
+ byte[] r = new byte[256];
+ // Writes each bit in a[0..31] into r[0..255]:
+ // a = a[0]+256*a[1]+...+256^31*a[31] is equal to
+ // r = r[0]+2*r[1]+...+2^255*r[255]
+ for (int i = 0; i < 256; i++) {
+ r[i] = (byte) (1 & ((a[i >> 3] & 0xff) >> (i & 7)));
+ }
+
+ // Transforms r[i] as odd values in [-15, 15]
+ for (int i = 0; i < 256; i++) {
+ if (r[i] != 0) {
+ for (int b = 1; b <= 6 && i + b < 256; b++) {
+ if (r[i + b] != 0) {
+ if (r[i] + (r[i + b] << b) <= 15) {
+ r[i] += r[i + b] << b;
+ r[i + b] = 0;
+ } else if (r[i] - (r[i + b] << b) >= -15) {
+ r[i] -= r[i + b] << b;
+ for (int k = i + b; k < 256; k++) {
+ if (r[k] == 0) {
+ r[k] = 1;
+ break;
+ }
+ r[k] = 0;
+ }
+ } else {
+ break;
+ }
+ }
+ }
+ }
+ }
+ return r;
+ }
+
+ /**
+ * Computes {@code a}*{@code pointA}+{@code b}*B
+ * where a = a[0]+256*a[1]+...+256^31*a[31].
+ * and b = b[0]+256*b[1]+...+256^31*b[31].
+ * B is the Ed25519 base point (x,4/5) with x positive.
+ * <p>
+ * Note that execution time varies based on the input since this will only be used in verification
+ * of signatures.
+ */
+ private static XYZ doubleScalarMultVarTime(byte[] a, XYZT pointA, byte[] b) {
+ // pointA, 3*pointA, 5*pointA, 7*pointA, 9*pointA, 11*pointA, 13*pointA, 15*pointA
+ CachedXYZT[] pointAArray = new CachedXYZT[8];
+ pointAArray[0] = new CachedXYZT(pointA);
+ PartialXYZT t = new PartialXYZT();
+ doubleXYZT(t, pointA);
+ XYZT doubleA = new XYZT(t);
+ for (int i = 1; i < pointAArray.length; i++) {
+ add(t, doubleA, pointAArray[i - 1]);
+ pointAArray[i] = new CachedXYZT(new XYZT(t));
+ }
+
+ byte[] aSlide = slide(a);
+ byte[] bSlide = slide(b);
+ t = new PartialXYZT(NEUTRAL);
+ XYZT u = new XYZT();
+ int i = 255;
+ for (; i >= 0; i--) {
+ if (aSlide[i] != 0 || bSlide[i] != 0) {
+ break;
+ }
+ }
+ for (; i >= 0; i--) {
+ doubleXYZ(t, new XYZ(t));
+ if (aSlide[i] > 0) {
+ add(t, XYZT.fromPartialXYZT(u, t), pointAArray[aSlide[i] / 2]);
+ } else if (aSlide[i] < 0) {
+ sub(t, XYZT.fromPartialXYZT(u, t), pointAArray[-aSlide[i] / 2]);
+ }
+ if (bSlide[i] > 0) {
+ add(t, XYZT.fromPartialXYZT(u, t), B2[bSlide[i] / 2]);
+ } else if (bSlide[i] < 0) {
+ sub(t, XYZT.fromPartialXYZT(u, t), B2[-bSlide[i] / 2]);
+ }
+ }
+
+ return new XYZ(t);
+ }
+
+ /**
+ * Returns true if {@code in} is nonzero.
+ * <p>
+ * Note that execution time might depend on the input {@code in}.
+ */
+ private static boolean isNonZeroVarTime(long[] in) {
+ long[] inCopy = new long[in.length + 1];
+ System.arraycopy(in, 0, inCopy, 0, in.length);
+ Field25519.reduceCoefficients(inCopy);
+ byte[] bytes = Field25519.contract(inCopy);
+ for (byte b : bytes) {
+ if (b != 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns the least significant bit of {@code in}.
+ */
+ private static int getLsb(long[] in) {
+ return Field25519.contract(in)[0] & 1;
+ }
+
+ /**
+ * Negates all values in {@code in} and store it in {@code out}.
+ */
+ private static void neg(long[] out, long[] in) {
+ for (int i = 0; i < in.length; i++) {
+ out[i] = -in[i];
+ }
+ }
+
+ /**
+ * Computes {@code in}^(2^252-3) mod 2^255-19 and puts the result in {@code out}.
+ */
+ private static void pow2252m3(long[] out, long[] in) {
+ long[] t0 = new long[Field25519.LIMB_CNT];
+ long[] t1 = new long[Field25519.LIMB_CNT];
+ long[] t2 = new long[Field25519.LIMB_CNT];
+
+ // z2 = z1^2^1
+ Field25519.square(t0, in);
+
+ // z8 = z2^2^2
+ Field25519.square(t1, t0);
+ for (int i = 1; i < 2; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z9 = z1*z8
+ Field25519.mult(t1, in, t1);
+
+ // z11 = z2*z9
+ Field25519.mult(t0, t0, t1);
+
+ // z22 = z11^2^1
+ Field25519.square(t0, t0);
+
+ // z_5_0 = z9*z22
+ Field25519.mult(t0, t1, t0);
+
+ // z_10_5 = z_5_0^2^5
+ Field25519.square(t1, t0);
+ for (int i = 1; i < 5; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_10_0 = z_10_5*z_5_0
+ Field25519.mult(t0, t1, t0);
+
+ // z_20_10 = z_10_0^2^10
+ Field25519.square(t1, t0);
+ for (int i = 1; i < 10; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_20_0 = z_20_10*z_10_0
+ Field25519.mult(t1, t1, t0);
+
+ // z_40_20 = z_20_0^2^20
+ Field25519.square(t2, t1);
+ for (int i = 1; i < 20; i++) {
+ Field25519.square(t2, t2);
+ }
+
+ // z_40_0 = z_40_20*z_20_0
+ Field25519.mult(t1, t2, t1);
+
+ // z_50_10 = z_40_0^2^10
+ Field25519.square(t1, t1);
+ for (int i = 1; i < 10; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_50_0 = z_50_10*z_10_0
+ Field25519.mult(t0, t1, t0);
+
+ // z_100_50 = z_50_0^2^50
+ Field25519.square(t1, t0);
+ for (int i = 1; i < 50; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_100_0 = z_100_50*z_50_0
+ Field25519.mult(t1, t1, t0);
+
+ // z_200_100 = z_100_0^2^100
+ Field25519.square(t2, t1);
+ for (int i = 1; i < 100; i++) {
+ Field25519.square(t2, t2);
+ }
+
+ // z_200_0 = z_200_100*z_100_0
+ Field25519.mult(t1, t2, t1);
+
+ // z_250_50 = z_200_0^2^50
+ Field25519.square(t1, t1);
+ for (int i = 1; i < 50; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_250_0 = z_250_50*z_50_0
+ Field25519.mult(t0, t1, t0);
+
+ // z_252_2 = z_250_0^2^2
+ Field25519.square(t0, t0);
+ for (int i = 1; i < 2; i++) {
+ Field25519.square(t0, t0);
+ }
+
+ // z_252_3 = z_252_2*z1
+ Field25519.mult(out, t0, in);
+ }
+
+ /**
+ * Returns 3 bytes of {@code in} starting from {@code idx} in Little-Endian format.
+ */
+ private static long load3(byte[] in, int idx) {
+ long result;
+ result = (long) in[idx] & 0xff;
+ result |= (long) (in[idx + 1] & 0xff) << 8;
+ result |= (long) (in[idx + 2] & 0xff) << 16;
+ return result;
+ }
+
+ /**
+ * Returns 4 bytes of {@code in} starting from {@code idx} in Little-Endian format.
+ */
+ private static long load4(byte[] in, int idx) {
+ long result = load3(in, idx);
+ result |= (long) (in[idx + 3] & 0xff) << 24;
+ return result;
+ }
+
+ /**
+ * Input:
+ * s[0]+256*s[1]+...+256^63*s[63] = s
+ * <p>
+ * Output:
+ * s[0]+256*s[1]+...+256^31*s[31] = s mod l
+ * where l = 2^252 + 27742317777372353535851937790883648493.
+ * Overwrites s in place.
+ */
+ private static void reduce(byte[] s) {
+ // Observation:
+ // 2^252 mod l is equivalent to -27742317777372353535851937790883648493 mod l
+ // Let m = -27742317777372353535851937790883648493
+ // Thus a*2^252+b mod l is equivalent to a*m+b mod l
+ //
+ // First s is divided into chunks of 21 bits as follows:
+ // s0+2^21*s1+2^42*s3+...+2^462*s23 = s[0]+256*s[1]+...+256^63*s[63]
+ long s0 = 2097151 & load3(s, 0);
+ long s1 = 2097151 & (load4(s, 2) >> 5);
+ long s2 = 2097151 & (load3(s, 5) >> 2);
+ long s3 = 2097151 & (load4(s, 7) >> 7);
+ long s4 = 2097151 & (load4(s, 10) >> 4);
+ long s5 = 2097151 & (load3(s, 13) >> 1);
+ long s6 = 2097151 & (load4(s, 15) >> 6);
+ long s7 = 2097151 & (load3(s, 18) >> 3);
+ long s8 = 2097151 & load3(s, 21);
+ long s9 = 2097151 & (load4(s, 23) >> 5);
+ long s10 = 2097151 & (load3(s, 26) >> 2);
+ long s11 = 2097151 & (load4(s, 28) >> 7);
+ long s12 = 2097151 & (load4(s, 31) >> 4);
+ long s13 = 2097151 & (load3(s, 34) >> 1);
+ long s14 = 2097151 & (load4(s, 36) >> 6);
+ long s15 = 2097151 & (load3(s, 39) >> 3);
+ long s16 = 2097151 & load3(s, 42);
+ long s17 = 2097151 & (load4(s, 44) >> 5);
+ long s18 = 2097151 & (load3(s, 47) >> 2);
+ long s19 = 2097151 & (load4(s, 49) >> 7);
+ long s20 = 2097151 & (load4(s, 52) >> 4);
+ long s21 = 2097151 & (load3(s, 55) >> 1);
+ long s22 = 2097151 & (load4(s, 57) >> 6);
+ long s23 = (load4(s, 60) >> 3);
+ long carry0;
+ long carry1;
+ long carry2;
+ long carry3;
+ long carry4;
+ long carry5;
+ long carry6;
+ long carry7;
+ long carry8;
+ long carry9;
+ long carry10;
+ long carry11;
+ long carry12;
+ long carry13;
+ long carry14;
+ long carry15;
+ long carry16;
+
+ // s23*2^462 = s23*2^210*2^252 is equivalent to s23*2^210*m in mod l
+ // As m is a 125 bit number, the result needs to scattered to 6 limbs (125/21 ceil is 6)
+ // starting from s11 (s11*2^210)
+ // m = [666643, 470296, 654183, -997805, 136657, -683901] in 21-bit limbs
+ s11 += s23 * 666643;
+ s12 += s23 * 470296;
+ s13 += s23 * 654183;
+ s14 -= s23 * 997805;
+ s15 += s23 * 136657;
+ s16 -= s23 * 683901;
+ // s23 = 0;
+
+ s10 += s22 * 666643;
+ s11 += s22 * 470296;
+ s12 += s22 * 654183;
+ s13 -= s22 * 997805;
+ s14 += s22 * 136657;
+ s15 -= s22 * 683901;
+ // s22 = 0;
+
+ s9 += s21 * 666643;
+ s10 += s21 * 470296;
+ s11 += s21 * 654183;
+ s12 -= s21 * 997805;
+ s13 += s21 * 136657;
+ s14 -= s21 * 683901;
+ // s21 = 0;
+
+ s8 += s20 * 666643;
+ s9 += s20 * 470296;
+ s10 += s20 * 654183;
+ s11 -= s20 * 997805;
+ s12 += s20 * 136657;
+ s13 -= s20 * 683901;
+ // s20 = 0;
+
+ s7 += s19 * 666643;
+ s8 += s19 * 470296;
+ s9 += s19 * 654183;
+ s10 -= s19 * 997805;
+ s11 += s19 * 136657;
+ s12 -= s19 * 683901;
+ // s19 = 0;
+
+ s6 += s18 * 666643;
+ s7 += s18 * 470296;
+ s8 += s18 * 654183;
+ s9 -= s18 * 997805;
+ s10 += s18 * 136657;
+ s11 -= s18 * 683901;
+ // s18 = 0;
+
+ // Reduce the bit length of limbs from s6 to s15 to 21-bits.
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry12 = (s12 + (1 << 20)) >> 21;
+ s13 += carry12;
+ s12 -= carry12 << 21;
+ carry14 = (s14 + (1 << 20)) >> 21;
+ s15 += carry14;
+ s14 -= carry14 << 21;
+ carry16 = (s16 + (1 << 20)) >> 21;
+ s17 += carry16;
+ s16 -= carry16 << 21;
+
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+ carry13 = (s13 + (1 << 20)) >> 21;
+ s14 += carry13;
+ s13 -= carry13 << 21;
+ carry15 = (s15 + (1 << 20)) >> 21;
+ s16 += carry15;
+ s15 -= carry15 << 21;
+
+ // Resume reduction where we left off.
+ s5 += s17 * 666643;
+ s6 += s17 * 470296;
+ s7 += s17 * 654183;
+ s8 -= s17 * 997805;
+ s9 += s17 * 136657;
+ s10 -= s17 * 683901;
+ // s17 = 0;
+
+ s4 += s16 * 666643;
+ s5 += s16 * 470296;
+ s6 += s16 * 654183;
+ s7 -= s16 * 997805;
+ s8 += s16 * 136657;
+ s9 -= s16 * 683901;
+ // s16 = 0;
+
+ s3 += s15 * 666643;
+ s4 += s15 * 470296;
+ s5 += s15 * 654183;
+ s6 -= s15 * 997805;
+ s7 += s15 * 136657;
+ s8 -= s15 * 683901;
+ // s15 = 0;
+
+ s2 += s14 * 666643;
+ s3 += s14 * 470296;
+ s4 += s14 * 654183;
+ s5 -= s14 * 997805;
+ s6 += s14 * 136657;
+ s7 -= s14 * 683901;
+ // s14 = 0;
+
+ s1 += s13 * 666643;
+ s2 += s13 * 470296;
+ s3 += s13 * 654183;
+ s4 -= s13 * 997805;
+ s5 += s13 * 136657;
+ s6 -= s13 * 683901;
+ // s13 = 0;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ s12 = 0;
+
+ // Reduce the range of limbs from s0 to s11 to 21-bits.
+ carry0 = (s0 + (1 << 20)) >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry2 = (s2 + (1 << 20)) >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry4 = (s4 + (1 << 20)) >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+
+ carry1 = (s1 + (1 << 20)) >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry3 = (s3 + (1 << 20)) >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry5 = (s5 + (1 << 20)) >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ s12 = 0;
+
+ // Carry chain reduction to propagate excess bits from s0 to s5 to the most significant limbs.
+ carry0 = s0 >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry1 = s1 >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry2 = s2 >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry3 = s3 >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry4 = s4 >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry5 = s5 >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry6 = s6 >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry7 = s7 >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry8 = s8 >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry9 = s9 >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry10 = s10 >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry11 = s11 >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+
+ // Do one last reduction as s12 might be 1.
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ // s12 = 0;
+
+ carry0 = s0 >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry1 = s1 >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry2 = s2 >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry3 = s3 >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry4 = s4 >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry5 = s5 >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry6 = s6 >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry7 = s7 >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry8 = s8 >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry9 = s9 >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry10 = s10 >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+
+ // Serialize the result into the s.
+ s[0] = (byte) s0;
+ s[1] = (byte) (s0 >> 8);
+ s[2] = (byte) ((s0 >> 16) | (s1 << 5));
+ s[3] = (byte) (s1 >> 3);
+ s[4] = (byte) (s1 >> 11);
+ s[5] = (byte) ((s1 >> 19) | (s2 << 2));
+ s[6] = (byte) (s2 >> 6);
+ s[7] = (byte) ((s2 >> 14) | (s3 << 7));
+ s[8] = (byte) (s3 >> 1);
+ s[9] = (byte) (s3 >> 9);
+ s[10] = (byte) ((s3 >> 17) | (s4 << 4));
+ s[11] = (byte) (s4 >> 4);
+ s[12] = (byte) (s4 >> 12);
+ s[13] = (byte) ((s4 >> 20) | (s5 << 1));
+ s[14] = (byte) (s5 >> 7);
+ s[15] = (byte) ((s5 >> 15) | (s6 << 6));
+ s[16] = (byte) (s6 >> 2);
+ s[17] = (byte) (s6 >> 10);
+ s[18] = (byte) ((s6 >> 18) | (s7 << 3));
+ s[19] = (byte) (s7 >> 5);
+ s[20] = (byte) (s7 >> 13);
+ s[21] = (byte) s8;
+ s[22] = (byte) (s8 >> 8);
+ s[23] = (byte) ((s8 >> 16) | (s9 << 5));
+ s[24] = (byte) (s9 >> 3);
+ s[25] = (byte) (s9 >> 11);
+ s[26] = (byte) ((s9 >> 19) | (s10 << 2));
+ s[27] = (byte) (s10 >> 6);
+ s[28] = (byte) ((s10 >> 14) | (s11 << 7));
+ s[29] = (byte) (s11 >> 1);
+ s[30] = (byte) (s11 >> 9);
+ s[31] = (byte) (s11 >> 17);
+ }
+
+ /**
+ * Input:
+ * a[0]+256*a[1]+...+256^31*a[31] = a
+ * b[0]+256*b[1]+...+256^31*b[31] = b
+ * c[0]+256*c[1]+...+256^31*c[31] = c
+ * <p>
+ * Output:
+ * s[0]+256*s[1]+...+256^31*s[31] = (ab+c) mod l
+ * where l = 2^252 + 27742317777372353535851937790883648493.
+ */
+ private static void mulAdd(byte[] s, byte[] a, byte[] b, byte[] c) {
+ // This is very similar to Ed25519.reduce, the difference in here is that it computes ab+c
+ // See Ed25519.reduce for related comments.
+ long a0 = 2097151 & load3(a, 0);
+ long a1 = 2097151 & (load4(a, 2) >> 5);
+ long a2 = 2097151 & (load3(a, 5) >> 2);
+ long a3 = 2097151 & (load4(a, 7) >> 7);
+ long a4 = 2097151 & (load4(a, 10) >> 4);
+ long a5 = 2097151 & (load3(a, 13) >> 1);
+ long a6 = 2097151 & (load4(a, 15) >> 6);
+ long a7 = 2097151 & (load3(a, 18) >> 3);
+ long a8 = 2097151 & load3(a, 21);
+ long a9 = 2097151 & (load4(a, 23) >> 5);
+ long a10 = 2097151 & (load3(a, 26) >> 2);
+ long a11 = (load4(a, 28) >> 7);
+ long b0 = 2097151 & load3(b, 0);
+ long b1 = 2097151 & (load4(b, 2) >> 5);
+ long b2 = 2097151 & (load3(b, 5) >> 2);
+ long b3 = 2097151 & (load4(b, 7) >> 7);
+ long b4 = 2097151 & (load4(b, 10) >> 4);
+ long b5 = 2097151 & (load3(b, 13) >> 1);
+ long b6 = 2097151 & (load4(b, 15) >> 6);
+ long b7 = 2097151 & (load3(b, 18) >> 3);
+ long b8 = 2097151 & load3(b, 21);
+ long b9 = 2097151 & (load4(b, 23) >> 5);
+ long b10 = 2097151 & (load3(b, 26) >> 2);
+ long b11 = (load4(b, 28) >> 7);
+ long c0 = 2097151 & load3(c, 0);
+ long c1 = 2097151 & (load4(c, 2) >> 5);
+ long c2 = 2097151 & (load3(c, 5) >> 2);
+ long c3 = 2097151 & (load4(c, 7) >> 7);
+ long c4 = 2097151 & (load4(c, 10) >> 4);
+ long c5 = 2097151 & (load3(c, 13) >> 1);
+ long c6 = 2097151 & (load4(c, 15) >> 6);
+ long c7 = 2097151 & (load3(c, 18) >> 3);
+ long c8 = 2097151 & load3(c, 21);
+ long c9 = 2097151 & (load4(c, 23) >> 5);
+ long c10 = 2097151 & (load3(c, 26) >> 2);
+ long c11 = (load4(c, 28) >> 7);
+ long s0;
+ long s1;
+ long s2;
+ long s3;
+ long s4;
+ long s5;
+ long s6;
+ long s7;
+ long s8;
+ long s9;
+ long s10;
+ long s11;
+ long s12;
+ long s13;
+ long s14;
+ long s15;
+ long s16;
+ long s17;
+ long s18;
+ long s19;
+ long s20;
+ long s21;
+ long s22;
+ long s23;
+ long carry0;
+ long carry1;
+ long carry2;
+ long carry3;
+ long carry4;
+ long carry5;
+ long carry6;
+ long carry7;
+ long carry8;
+ long carry9;
+ long carry10;
+ long carry11;
+ long carry12;
+ long carry13;
+ long carry14;
+ long carry15;
+ long carry16;
+ long carry17;
+ long carry18;
+ long carry19;
+ long carry20;
+ long carry21;
+ long carry22;
+
+ s0 = c0 + a0 * b0;
+ s1 = c1 + a0 * b1 + a1 * b0;
+ s2 = c2 + a0 * b2 + a1 * b1 + a2 * b0;
+ s3 = c3 + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0;
+ s4 = c4 + a0 * b4 + a1 * b3 + a2 * b2 + a3 * b1 + a4 * b0;
+ s5 = c5 + a0 * b5 + a1 * b4 + a2 * b3 + a3 * b2 + a4 * b1 + a5 * b0;
+ s6 = c6 + a0 * b6 + a1 * b5 + a2 * b4 + a3 * b3 + a4 * b2 + a5 * b1 + a6 * b0;
+ s7 = c7 + a0 * b7 + a1 * b6 + a2 * b5 + a3 * b4 + a4 * b3 + a5 * b2 + a6 * b1 + a7 * b0;
+ s8 = c8 + a0 * b8 + a1 * b7 + a2 * b6 + a3 * b5 + a4 * b4 + a5 * b3 + a6 * b2 + a7 * b1
+ + a8 * b0;
+ s9 = c9 + a0 * b9 + a1 * b8 + a2 * b7 + a3 * b6 + a4 * b5 + a5 * b4 + a6 * b3 + a7 * b2
+ + a8 * b1 + a9 * b0;
+ s10 = c10 + a0 * b10 + a1 * b9 + a2 * b8 + a3 * b7 + a4 * b6 + a5 * b5 + a6 * b4 + a7 * b3
+ + a8 * b2 + a9 * b1 + a10 * b0;
+ s11 = c11 + a0 * b11 + a1 * b10 + a2 * b9 + a3 * b8 + a4 * b7 + a5 * b6 + a6 * b5 + a7 * b4
+ + a8 * b3 + a9 * b2 + a10 * b1 + a11 * b0;
+ s12 = a1 * b11 + a2 * b10 + a3 * b9 + a4 * b8 + a5 * b7 + a6 * b6 + a7 * b5 + a8 * b4 + a9 * b3
+ + a10 * b2 + a11 * b1;
+ s13 = a2 * b11 + a3 * b10 + a4 * b9 + a5 * b8 + a6 * b7 + a7 * b6 + a8 * b5 + a9 * b4 + a10 * b3
+ + a11 * b2;
+ s14 = a3 * b11 + a4 * b10 + a5 * b9 + a6 * b8 + a7 * b7 + a8 * b6 + a9 * b5 + a10 * b4
+ + a11 * b3;
+ s15 = a4 * b11 + a5 * b10 + a6 * b9 + a7 * b8 + a8 * b7 + a9 * b6 + a10 * b5 + a11 * b4;
+ s16 = a5 * b11 + a6 * b10 + a7 * b9 + a8 * b8 + a9 * b7 + a10 * b6 + a11 * b5;
+ s17 = a6 * b11 + a7 * b10 + a8 * b9 + a9 * b8 + a10 * b7 + a11 * b6;
+ s18 = a7 * b11 + a8 * b10 + a9 * b9 + a10 * b8 + a11 * b7;
+ s19 = a8 * b11 + a9 * b10 + a10 * b9 + a11 * b8;
+ s20 = a9 * b11 + a10 * b10 + a11 * b9;
+ s21 = a10 * b11 + a11 * b10;
+ s22 = a11 * b11;
+ s23 = 0;
+
+ carry0 = (s0 + (1 << 20)) >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry2 = (s2 + (1 << 20)) >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry4 = (s4 + (1 << 20)) >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry12 = (s12 + (1 << 20)) >> 21;
+ s13 += carry12;
+ s12 -= carry12 << 21;
+ carry14 = (s14 + (1 << 20)) >> 21;
+ s15 += carry14;
+ s14 -= carry14 << 21;
+ carry16 = (s16 + (1 << 20)) >> 21;
+ s17 += carry16;
+ s16 -= carry16 << 21;
+ carry18 = (s18 + (1 << 20)) >> 21;
+ s19 += carry18;
+ s18 -= carry18 << 21;
+ carry20 = (s20 + (1 << 20)) >> 21;
+ s21 += carry20;
+ s20 -= carry20 << 21;
+ carry22 = (s22 + (1 << 20)) >> 21;
+ s23 += carry22;
+ s22 -= carry22 << 21;
+
+ carry1 = (s1 + (1 << 20)) >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry3 = (s3 + (1 << 20)) >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry5 = (s5 + (1 << 20)) >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+ carry13 = (s13 + (1 << 20)) >> 21;
+ s14 += carry13;
+ s13 -= carry13 << 21;
+ carry15 = (s15 + (1 << 20)) >> 21;
+ s16 += carry15;
+ s15 -= carry15 << 21;
+ carry17 = (s17 + (1 << 20)) >> 21;
+ s18 += carry17;
+ s17 -= carry17 << 21;
+ carry19 = (s19 + (1 << 20)) >> 21;
+ s20 += carry19;
+ s19 -= carry19 << 21;
+ carry21 = (s21 + (1 << 20)) >> 21;
+ s22 += carry21;
+ s21 -= carry21 << 21;
+
+ s11 += s23 * 666643;
+ s12 += s23 * 470296;
+ s13 += s23 * 654183;
+ s14 -= s23 * 997805;
+ s15 += s23 * 136657;
+ s16 -= s23 * 683901;
+ // s23 = 0;
+
+ s10 += s22 * 666643;
+ s11 += s22 * 470296;
+ s12 += s22 * 654183;
+ s13 -= s22 * 997805;
+ s14 += s22 * 136657;
+ s15 -= s22 * 683901;
+ // s22 = 0;
+
+ s9 += s21 * 666643;
+ s10 += s21 * 470296;
+ s11 += s21 * 654183;
+ s12 -= s21 * 997805;
+ s13 += s21 * 136657;
+ s14 -= s21 * 683901;
+ // s21 = 0;
+
+ s8 += s20 * 666643;
+ s9 += s20 * 470296;
+ s10 += s20 * 654183;
+ s11 -= s20 * 997805;
+ s12 += s20 * 136657;
+ s13 -= s20 * 683901;
+ // s20 = 0;
+
+ s7 += s19 * 666643;
+ s8 += s19 * 470296;
+ s9 += s19 * 654183;
+ s10 -= s19 * 997805;
+ s11 += s19 * 136657;
+ s12 -= s19 * 683901;
+ // s19 = 0;
+
+ s6 += s18 * 666643;
+ s7 += s18 * 470296;
+ s8 += s18 * 654183;
+ s9 -= s18 * 997805;
+ s10 += s18 * 136657;
+ s11 -= s18 * 683901;
+ // s18 = 0;
+
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry12 = (s12 + (1 << 20)) >> 21;
+ s13 += carry12;
+ s12 -= carry12 << 21;
+ carry14 = (s14 + (1 << 20)) >> 21;
+ s15 += carry14;
+ s14 -= carry14 << 21;
+ carry16 = (s16 + (1 << 20)) >> 21;
+ s17 += carry16;
+ s16 -= carry16 << 21;
+
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+ carry13 = (s13 + (1 << 20)) >> 21;
+ s14 += carry13;
+ s13 -= carry13 << 21;
+ carry15 = (s15 + (1 << 20)) >> 21;
+ s16 += carry15;
+ s15 -= carry15 << 21;
+
+ s5 += s17 * 666643;
+ s6 += s17 * 470296;
+ s7 += s17 * 654183;
+ s8 -= s17 * 997805;
+ s9 += s17 * 136657;
+ s10 -= s17 * 683901;
+ // s17 = 0;
+
+ s4 += s16 * 666643;
+ s5 += s16 * 470296;
+ s6 += s16 * 654183;
+ s7 -= s16 * 997805;
+ s8 += s16 * 136657;
+ s9 -= s16 * 683901;
+ // s16 = 0;
+
+ s3 += s15 * 666643;
+ s4 += s15 * 470296;
+ s5 += s15 * 654183;
+ s6 -= s15 * 997805;
+ s7 += s15 * 136657;
+ s8 -= s15 * 683901;
+ // s15 = 0;
+
+ s2 += s14 * 666643;
+ s3 += s14 * 470296;
+ s4 += s14 * 654183;
+ s5 -= s14 * 997805;
+ s6 += s14 * 136657;
+ s7 -= s14 * 683901;
+ // s14 = 0;
+
+ s1 += s13 * 666643;
+ s2 += s13 * 470296;
+ s3 += s13 * 654183;
+ s4 -= s13 * 997805;
+ s5 += s13 * 136657;
+ s6 -= s13 * 683901;
+ // s13 = 0;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ s12 = 0;
+
+ carry0 = (s0 + (1 << 20)) >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry2 = (s2 + (1 << 20)) >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry4 = (s4 + (1 << 20)) >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+
+ carry1 = (s1 + (1 << 20)) >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry3 = (s3 + (1 << 20)) >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry5 = (s5 + (1 << 20)) >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ s12 = 0;
+
+ carry0 = s0 >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry1 = s1 >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry2 = s2 >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry3 = s3 >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry4 = s4 >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry5 = s5 >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry6 = s6 >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry7 = s7 >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry8 = s8 >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry9 = s9 >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry10 = s10 >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry11 = s11 >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ // s12 = 0;
+
+ carry0 = s0 >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry1 = s1 >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry2 = s2 >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry3 = s3 >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry4 = s4 >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry5 = s5 >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry6 = s6 >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry7 = s7 >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry8 = s8 >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry9 = s9 >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry10 = s10 >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+
+ s[0] = (byte) s0;
+ s[1] = (byte) (s0 >> 8);
+ s[2] = (byte) ((s0 >> 16) | (s1 << 5));
+ s[3] = (byte) (s1 >> 3);
+ s[4] = (byte) (s1 >> 11);
+ s[5] = (byte) ((s1 >> 19) | (s2 << 2));
+ s[6] = (byte) (s2 >> 6);
+ s[7] = (byte) ((s2 >> 14) | (s3 << 7));
+ s[8] = (byte) (s3 >> 1);
+ s[9] = (byte) (s3 >> 9);
+ s[10] = (byte) ((s3 >> 17) | (s4 << 4));
+ s[11] = (byte) (s4 >> 4);
+ s[12] = (byte) (s4 >> 12);
+ s[13] = (byte) ((s4 >> 20) | (s5 << 1));
+ s[14] = (byte) (s5 >> 7);
+ s[15] = (byte) ((s5 >> 15) | (s6 << 6));
+ s[16] = (byte) (s6 >> 2);
+ s[17] = (byte) (s6 >> 10);
+ s[18] = (byte) ((s6 >> 18) | (s7 << 3));
+ s[19] = (byte) (s7 >> 5);
+ s[20] = (byte) (s7 >> 13);
+ s[21] = (byte) s8;
+ s[22] = (byte) (s8 >> 8);
+ s[23] = (byte) ((s8 >> 16) | (s9 << 5));
+ s[24] = (byte) (s9 >> 3);
+ s[25] = (byte) (s9 >> 11);
+ s[26] = (byte) ((s9 >> 19) | (s10 << 2));
+ s[27] = (byte) (s10 >> 6);
+ s[28] = (byte) ((s10 >> 14) | (s11 << 7));
+ s[29] = (byte) (s11 >> 1);
+ s[30] = (byte) (s11 >> 9);
+ s[31] = (byte) (s11 >> 17);
+ }
+
+ // The order of the generator as unsigned bytes in little endian order.
+ // (2^252 + 0x14def9dea2f79cd65812631a5cf5d3ed, cf. RFC 7748)
+ private static final byte[] GROUP_ORDER = {
+ (byte) 0xed, (byte) 0xd3, (byte) 0xf5, (byte) 0x5c,
+ (byte) 0x1a, (byte) 0x63, (byte) 0x12, (byte) 0x58,
+ (byte) 0xd6, (byte) 0x9c, (byte) 0xf7, (byte) 0xa2,
+ (byte) 0xde, (byte) 0xf9, (byte) 0xde, (byte) 0x14,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10};
+
+ // Checks whether s represents an integer smaller than the order of the group.
+ // This is needed to ensure that EdDSA signatures are non-malleable, as failing to check
+ // the range of S allows to modify signatures (cf. RFC 8032, Section 5.2.7 and Section 8.4.)
+ // @param s an integer in little-endian order.
+ private static boolean isSmallerThanGroupOrder(byte[] s) {
+ for (int j = Field25519.FIELD_LEN - 1; j >= 0; j--) {
+ // compare unsigned bytes
+ int a = s[j] & 0xff;
+ int b = GROUP_ORDER[j] & 0xff;
+ if (a != b) {
+ return a < b;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the EdDSA {@code signature} with {@code message}, can be verified with
+ * {@code publicKey}.
+ */
+ public static boolean verify(final byte[] message, final byte[] signature,
+ final byte[] publicKey) {
+ try {
+ if (signature.length != SIGNATURE_LEN) {
+ return false;
+ }
+ if (publicKey.length != PUBLIC_KEY_LEN) {
+ return false;
+ }
+ byte[] s = Arrays.copyOfRange(signature, Field25519.FIELD_LEN, SIGNATURE_LEN);
+ if (!isSmallerThanGroupOrder(s)) {
+ return false;
+ }
+ MessageDigest digest = MessageDigest.getInstance("SHA-512");
+ digest.update(signature, 0, Field25519.FIELD_LEN);
+ digest.update(publicKey);
+ digest.update(message);
+ byte[] h = digest.digest();
+ reduce(h);
+
+ XYZT negPublicKey = XYZT.fromBytesNegateVarTime(publicKey);
+ XYZ xyz = doubleScalarMultVarTime(h, negPublicKey, s);
+ byte[] expectedR = xyz.toBytes();
+ for (int i = 0; i < Field25519.FIELD_LEN; i++) {
+ if (expectedR[i] != signature[i]) {
+ return false;
+ }
+ }
+ return true;
+ } catch (final GeneralSecurityException ignored) {
+ return false;
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt b/ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt
new file mode 100644
index 00000000..30da3b07
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.updater
+
+import android.content.Intent
+import android.net.Uri
+import android.view.View
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.BaseTransientBottomBar
+import com.google.android.material.snackbar.Snackbar
+import com.wireguard.android.R
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.QuantityFormatter
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.seconds
+
+class SnackbarUpdateShower(private val fragment: Fragment) {
+ private var lastUserIntervention: Updater.Progress.NeedsUserIntervention? = null
+ private val intentLauncher = fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ lastUserIntervention?.markAsDone()
+ }
+
+ private class SwapableSnackbar(fragment: Fragment, view: View, anchor: View?) {
+ private val actionSnackbar = makeSnackbar(fragment, view, anchor)
+ private val statusSnackbar = makeSnackbar(fragment, view, anchor)
+ private var showingAction: Boolean = false
+ private var showingStatus: Boolean = false
+
+ private fun makeSnackbar(fragment: Fragment, view: View, anchor: View?): Snackbar {
+ val snackbar = Snackbar.make(fragment.requireContext(), view, "", Snackbar.LENGTH_INDEFINITE)
+ if (anchor != null)
+ snackbar.anchorView = anchor
+ snackbar.setTextMaxLines(6)
+ snackbar.behavior = object : BaseTransientBottomBar.Behavior() {
+ override fun canSwipeDismissView(child: View): Boolean {
+ return false
+ }
+ }
+ snackbar.addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
+ override fun onDismissed(snackbar: Snackbar?, @DismissEvent event: Int) {
+ super.onDismissed(snackbar, event)
+ if (event == DISMISS_EVENT_MANUAL || event == DISMISS_EVENT_ACTION ||
+ (snackbar == actionSnackbar && !showingAction) || (snackbar == statusSnackbar && !showingStatus)
+ )
+ return
+ fragment.lifecycleScope.launch {
+ delay(5.seconds)
+ snackbar?.show()
+ }
+ }
+ })
+ return snackbar
+ }
+
+ fun showAction(text: String, action: String, listener: View.OnClickListener) {
+ if (showingStatus) {
+ showingStatus = false
+ statusSnackbar.dismiss()
+ }
+ actionSnackbar.setText(text)
+ actionSnackbar.setAction(action, listener)
+ if (!showingAction) {
+ actionSnackbar.show()
+ showingAction = true
+ }
+ }
+
+ fun showText(text: String) {
+ if (showingAction) {
+ showingAction = false
+ actionSnackbar.dismiss()
+ }
+ statusSnackbar.setText(text)
+ if (!showingStatus) {
+ statusSnackbar.show()
+ showingStatus = true
+ }
+ }
+
+ fun dismiss() {
+ actionSnackbar.dismiss()
+ statusSnackbar.dismiss()
+ showingAction = false
+ showingStatus = false
+ }
+ }
+
+ fun attach(view: View, anchor: View?) {
+ val snackbar = SwapableSnackbar(fragment, view, anchor)
+ val context = fragment.requireContext()
+
+ Updater.state.onEach { progress ->
+ when (progress) {
+ is Updater.Progress.Complete ->
+ snackbar.dismiss()
+
+ is Updater.Progress.Available ->
+ snackbar.showAction(context.getString(R.string.updater_avalable), context.getString(R.string.updater_action)) {
+ progress.update()
+ }
+
+ is Updater.Progress.NeedsUserIntervention -> {
+ lastUserIntervention = progress
+ intentLauncher.launch(progress.intent)
+ }
+
+ is Updater.Progress.Installing ->
+ snackbar.showText(context.getString(R.string.updater_installing))
+
+ is Updater.Progress.Rechecking ->
+ snackbar.showText(context.getString(R.string.updater_rechecking))
+
+ is Updater.Progress.Downloading -> {
+ if (progress.bytesTotal != 0UL) {
+ snackbar.showText(
+ context.getString(
+ R.string.updater_download_progress,
+ QuantityFormatter.formatBytes(progress.bytesDownloaded.toLong()),
+ QuantityFormatter.formatBytes(progress.bytesTotal.toLong()),
+ progress.bytesDownloaded.toFloat() * 100.0 / progress.bytesTotal.toFloat()
+ )
+ )
+ } else {
+ snackbar.showText(
+ context.getString(
+ R.string.updater_download_progress_nototal,
+ QuantityFormatter.formatBytes(progress.bytesDownloaded.toLong())
+ )
+ )
+ }
+ }
+
+ is Updater.Progress.Failure -> {
+ snackbar.showText(context.getString(R.string.updater_failure, ErrorMessages[progress.error]))
+ delay(5.seconds)
+ progress.retry()
+ }
+
+ is Updater.Progress.Corrupt -> {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.updater_corrupt_title)
+ .setMessage(R.string.updater_corrupt_message)
+ .setPositiveButton(R.string.updater_corrupt_navigate) { _, _ ->
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = Uri.parse(progress.downloadUrl)
+ try {
+ context.startActivity(intent)
+ } catch (e: Throwable) {
+ Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show()
+ }
+ }.setCancelable(false).setOnDismissListener {
+ val intent = Intent(Intent.ACTION_MAIN)
+ intent.addCategory(Intent.CATEGORY_HOME)
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ System.exit(0)
+ }.show()
+ }
+ }
+ }.launchIn(fragment.lifecycleScope)
+ }
+} \ No newline at end of file
diff --git a/ui/src/main/java/com/wireguard/android/updater/Updater.kt b/ui/src/main/java/com/wireguard/android/updater/Updater.kt
new file mode 100644
index 00000000..651e3cd7
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/updater/Updater.kt
@@ -0,0 +1,451 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.updater
+
+import android.Manifest
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInstaller
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Base64
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.core.content.IntentCompat
+import com.wireguard.android.Application
+import com.wireguard.android.BuildConfig
+import com.wireguard.android.activity.MainActivity
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.applicationScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import java.nio.charset.StandardCharsets
+import java.security.InvalidKeyException
+import java.security.InvalidParameterException
+import java.security.MessageDigest
+import java.util.UUID
+import kotlin.math.max
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+object Updater {
+ private const val TAG = "WireGuard/Updater"
+ private const val UPDATE_URL_FMT = "https://download.wireguard.com/android-client/%s"
+ private const val APK_NAME_PREFIX = BuildConfig.APPLICATION_ID + "-"
+ private const val APK_NAME_SUFFIX = ".apk"
+ private const val LATEST_FILE = "latest.sig"
+ private const val RELEASE_PUBLIC_KEY_BASE64 = "RWTAzwGRYr3EC9px0Ia3fbttz8WcVN6wrOwWp2delz4el6SI8XmkKSMp"
+ private val CURRENT_VERSION by lazy { Version(BuildConfig.VERSION_NAME) }
+
+ private val updaterScope = CoroutineScope(Job() + Dispatchers.IO)
+
+ private fun installer(context: Context): String = try {
+ val packageName = context.packageName
+ val pm = context.packageManager
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ pm.getInstallSourceInfo(packageName).installingPackageName ?: ""
+ } else {
+ @Suppress("DEPRECATION")
+ pm.getInstallerPackageName(packageName) ?: ""
+ }
+ } catch (_: Throwable) {
+ ""
+ }
+
+ fun installerIsGooglePlay(context: Context): Boolean = installer(context) == "com.android.vending"
+
+ sealed class Progress {
+ object Complete : Progress()
+ class Available(val version: String) : Progress() {
+ fun update() {
+ applicationScope.launch {
+ UserKnobs.setUpdaterNewerVersionConsented(version)
+ }
+ }
+ }
+
+ object Rechecking : Progress()
+ class Downloading(val bytesDownloaded: ULong, val bytesTotal: ULong) : Progress()
+ object Installing : Progress()
+ class NeedsUserIntervention(val intent: Intent, private val id: Int) : Progress() {
+
+ private suspend fun installerActive(): Boolean {
+ if (mutableState.firstOrNull() != this@NeedsUserIntervention)
+ return true
+ try {
+ if (Application.get().packageManager.packageInstaller.getSessionInfo(id)?.isActive == true)
+ return true
+ } catch (_: SecurityException) {
+ return true
+ }
+ return false
+ }
+
+ fun markAsDone() {
+ applicationScope.launch {
+ if (installerActive())
+ return@launch
+ delay(7.seconds)
+ if (installerActive())
+ return@launch
+ emitProgress(Failure(Exception("Ignored by user")))
+ }
+ }
+ }
+
+ class Failure(val error: Throwable) : Progress() {
+ fun retry() {
+ updaterScope.launch {
+ downloadAndUpdateWrapErrors()
+ }
+ }
+ }
+
+ class Corrupt(private val betterFile: String?) : Progress() {
+ val downloadUrl: String
+ get() = UPDATE_URL_FMT.format(betterFile ?: "")
+ }
+ }
+
+ private val mutableState = MutableStateFlow<Progress>(Progress.Complete)
+ val state = mutableState.asStateFlow()
+
+ private suspend fun emitProgress(progress: Progress, force: Boolean = false) {
+ if (force || mutableState.firstOrNull()?.javaClass != progress.javaClass)
+ mutableState.emit(progress)
+ }
+
+ private class Sha256Digest(hex: String) {
+ val bytes: ByteArray
+
+ init {
+ if (hex.length != 64)
+ throw InvalidParameterException("SHA256 hashes must be 32 bytes long")
+ bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ }
+ }
+
+ @OptIn(ExperimentalUnsignedTypes::class)
+ private class Version(version: String) : Comparable<Version> {
+ val parts: ULongArray
+
+ init {
+ val strParts = version.split(".")
+ if (strParts.isEmpty())
+ throw InvalidParameterException("Version has no parts")
+ parts = ULongArray(strParts.size)
+ for (i in parts.indices) {
+ parts[i] = strParts[i].toULong()
+ }
+ }
+
+ override fun toString(): String {
+ return parts.joinToString(".")
+ }
+
+ override fun compareTo(other: Version): Int {
+ for (i in 0 until max(parts.size, other.parts.size)) {
+ val lhsPart = if (i < parts.size) parts[i] else 0UL
+ val rhsPart = if (i < other.parts.size) other.parts[i] else 0UL
+ if (lhsPart > rhsPart)
+ return 1
+ else if (lhsPart < rhsPart)
+ return -1
+ }
+ return 0
+ }
+ }
+
+ private class Update(val fileName: String, val version: Version, val hash: Sha256Digest)
+
+ private fun versionOfFile(name: String): Version? {
+ if (!name.startsWith(APK_NAME_PREFIX) || !name.endsWith(APK_NAME_SUFFIX))
+ return null
+ return try {
+ Version(name.substring(APK_NAME_PREFIX.length, name.length - APK_NAME_SUFFIX.length))
+ } catch (_: Throwable) {
+ null
+ }
+ }
+
+ private fun verifySignedFileList(signifyDigest: String): List<Update> {
+ val updates = ArrayList<Update>(1)
+ val publicKeyBytes = Base64.decode(RELEASE_PUBLIC_KEY_BASE64, Base64.DEFAULT)
+ if (publicKeyBytes == null || publicKeyBytes.size != 32 + 10 || publicKeyBytes[0] != 'E'.code.toByte() || publicKeyBytes[1] != 'd'.code.toByte())
+ throw InvalidKeyException("Invalid public key")
+ val lines = signifyDigest.split("\n", limit = 3)
+ if (lines.size != 3)
+ throw InvalidParameterException("Invalid signature format: too few lines")
+ if (!lines[0].startsWith("untrusted comment: "))
+ throw InvalidParameterException("Invalid signature format: missing comment")
+ val signatureBytes = Base64.decode(lines[1], Base64.DEFAULT)
+ if (signatureBytes == null || signatureBytes.size != 64 + 10)
+ throw InvalidParameterException("Invalid signature format: wrong sized or missing signature")
+ for (i in 0..9) {
+ if (signatureBytes[i] != publicKeyBytes[i])
+ throw InvalidParameterException("Invalid signature format: wrong signer")
+ }
+ if (!Ed25519.verify(
+ lines[2].toByteArray(StandardCharsets.UTF_8),
+ signatureBytes.sliceArray(10 until 10 + 64),
+ publicKeyBytes.sliceArray(10 until 10 + 32)
+ )
+ )
+ throw SecurityException("Invalid signature")
+ for (line in lines[2].split("\n").dropLastWhile { it.isEmpty() }) {
+ val components = line.split(" ", limit = 2)
+ if (components.size != 2)
+ throw InvalidParameterException("Invalid file list format: too few components")
+ /* If version is null, it's not a file we understand, but still a legitimate entry, so don't throw. */
+ val version = versionOfFile(components[1]) ?: continue
+ updates.add(Update(components[1], version, Sha256Digest(components[0])))
+ }
+ return updates
+ }
+
+ private fun checkForUpdates(): Update? {
+ val connection = URL(UPDATE_URL_FMT.format(LATEST_FILE)).openConnection() as HttpURLConnection
+ connection.setRequestProperty("User-Agent", Application.USER_AGENT)
+ connection.connect()
+ if (connection.responseCode != HttpURLConnection.HTTP_OK)
+ throw IOException(connection.responseMessage)
+ var fileListBytes = ByteArray(1024 * 512 /* 512 KiB */)
+ connection.inputStream.use {
+ val len = it.read(fileListBytes)
+ if (len <= 0)
+ throw IOException("File list is empty")
+ fileListBytes = fileListBytes.sliceArray(0 until len)
+ }
+ return verifySignedFileList(fileListBytes.decodeToString()).maxByOrNull { it.version }
+ }
+
+ private suspend fun downloadAndUpdate() = withContext(Dispatchers.IO) {
+ val receiver = InstallReceiver()
+ val context = Application.get().applicationContext
+ val pendingIntent = withContext(Dispatchers.Main) {
+ ContextCompat.registerReceiver(context, receiver, IntentFilter(receiver.sessionId), ContextCompat.RECEIVER_NOT_EXPORTED)
+ PendingIntent.getBroadcast(
+ context,
+ 0,
+ Intent(receiver.sessionId).setPackage(context.packageName),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+ }
+
+ emitProgress(Progress.Rechecking)
+ val update = checkForUpdates()
+ if (update == null || update.version <= CURRENT_VERSION) {
+ emitProgress(Progress.Complete)
+ return@withContext
+ }
+
+ emitProgress(Progress.Downloading(0UL, 0UL), true)
+ val connection = URL(UPDATE_URL_FMT.format(update.fileName)).openConnection() as HttpURLConnection
+ connection.setRequestProperty("User-Agent", Application.USER_AGENT)
+ connection.connect()
+ if (connection.responseCode != HttpURLConnection.HTTP_OK)
+ throw IOException("Update could not be fetched: ${connection.responseCode}")
+
+ var downloadedByteLen: ULong = 0UL
+ val totalByteLen = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) connection.contentLengthLong else connection.contentLength).toLong().toULong()
+ val fileBytes = ByteArray(1024 * 32 /* 32 KiB */)
+ val digest = MessageDigest.getInstance("SHA-256")
+ emitProgress(Progress.Downloading(downloadedByteLen, totalByteLen), true)
+
+ val installer = context.packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
+ params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
+ params.setAppPackageName(context.packageName) /* Enforces updates; disallows new apps. */
+ val session = installer.openSession(installer.createSession(params))
+ var sessionFailure = true
+ try {
+ val installDest = session.openWrite(receiver.sessionId, 0, -1)
+
+ installDest.use { dest ->
+ connection.inputStream.use { src ->
+ while (true) {
+ val readLen = src.read(fileBytes)
+ if (readLen <= 0)
+ break
+
+ digest.update(fileBytes, 0, readLen)
+ dest.write(fileBytes, 0, readLen)
+
+ downloadedByteLen += readLen.toUInt()
+ emitProgress(Progress.Downloading(downloadedByteLen, totalByteLen), true)
+
+ if (downloadedByteLen >= 1024UL * 1024UL * 100UL /* 100 MiB */)
+ throw IOException("File too large")
+ }
+ }
+ }
+
+ emitProgress(Progress.Installing)
+ if (!digest.digest().contentEquals(update.hash.bytes))
+ throw SecurityException("Update has invalid hash")
+ sessionFailure = false
+ } finally {
+ if (sessionFailure) {
+ session.abandon()
+ session.close()
+ }
+ }
+ session.commit(pendingIntent.intentSender)
+ session.close()
+ }
+
+ private var updating = false
+ private suspend fun downloadAndUpdateWrapErrors() {
+ if (updating)
+ return
+ updating = true
+ try {
+ downloadAndUpdate()
+ } catch (e: Throwable) {
+ Log.e(TAG, "Update failure", e)
+ emitProgress(Progress.Failure(e))
+ }
+ updating = false
+ }
+
+ private class InstallReceiver : BroadcastReceiver() {
+ val sessionId = UUID.randomUUID().toString()
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (sessionId != intent.action)
+ return
+
+ when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE_INVALID)) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ val id = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, 0)
+ val userIntervention = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)!!
+ applicationScope.launch {
+ emitProgress(Progress.NeedsUserIntervention(userIntervention, id))
+ }
+ }
+
+ PackageInstaller.STATUS_SUCCESS -> {
+ applicationScope.launch {
+ emitProgress(Progress.Complete)
+ }
+ context.applicationContext.unregisterReceiver(this)
+ }
+
+ else -> {
+ val id = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, 0)
+ try {
+ context.applicationContext.packageManager.packageInstaller.abandonSession(id)
+ } catch (_: SecurityException) {
+ }
+ val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "Installation error $status"
+ applicationScope.launch {
+ val e = Exception(message)
+ Log.e(TAG, "Update failure", e)
+ emitProgress(Progress.Failure(e))
+ }
+ context.applicationContext.unregisterReceiver(this)
+ }
+ }
+ }
+ }
+
+ fun monitorForUpdates() {
+ if (BuildConfig.DEBUG)
+ return
+
+ val context = Application.get()
+
+ if (installerIsGooglePlay(context))
+ return
+
+ if (!if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ @Suppress("DEPRECATION")
+ context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
+ } else {
+ context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()))
+ }.requestedPermissions.contains(Manifest.permission.REQUEST_INSTALL_PACKAGES)
+ ) {
+ if (installer(context).isNotEmpty()) {
+ updaterScope.launch {
+ val update = try {
+ checkForUpdates()
+ } catch (_: Throwable) {
+ null
+ }
+ emitProgress(Progress.Corrupt(update?.fileName))
+ }
+ }
+ return
+ }
+
+ updaterScope.launch {
+ if (UserKnobs.updaterNewerVersionSeen.firstOrNull()?.let { Version(it) > CURRENT_VERSION } == true)
+ return@launch
+
+ var waitTime = 15
+ while (true) {
+ try {
+ val update = checkForUpdates() ?: continue
+ if (update.version > CURRENT_VERSION) {
+ Log.i(TAG, "Update available: ${update.version}")
+ UserKnobs.setUpdaterNewerVersionSeen(update.version.toString())
+ return@launch
+ }
+ } catch (_: Throwable) {
+ }
+ delay(waitTime.minutes)
+ waitTime = 45
+ }
+ }
+
+ UserKnobs.updaterNewerVersionSeen.onEach { ver ->
+ if (
+ ver != null &&
+ Version(ver) > CURRENT_VERSION &&
+ UserKnobs.updaterNewerVersionConsented.firstOrNull()?.let { Version(it) > CURRENT_VERSION } != true
+ )
+ emitProgress(Progress.Available(ver))
+ }.launchIn(applicationScope)
+
+ UserKnobs.updaterNewerVersionConsented.onEach { ver ->
+ if (ver != null && Version(ver) > CURRENT_VERSION)
+ updaterScope.launch {
+ downloadAndUpdateWrapErrors()
+ }
+ }.launchIn(applicationScope)
+ }
+
+ class AppUpdatedReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED)
+ return
+
+ if (installer(context) != context.packageName)
+ return
+
+ /* TODO: does not work because of restrictions placed on broadcast receivers. */
+ val start = Intent(context, MainActivity::class.java)
+ start.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ start.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(start)
+ }
+ }
+}
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..2f90b2bb
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright © 2017-2023 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/BiometricAuthenticator.kt b/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt
new file mode 100644
index 00000000..54d4da87
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util
+
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.annotation.StringRes
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricManager.Authenticators
+import androidx.biometric.BiometricPrompt
+import androidx.fragment.app.Fragment
+import com.wireguard.android.R
+
+
+object BiometricAuthenticator {
+ private const val TAG = "WireGuard/BiometricAuthenticator"
+
+ // 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()
+ data class Failure(val code: Int?, val message: CharSequence) : Result()
+ object HardwareUnavailableOrDisabled : Result()
+ object Cancelled : Result()
+ }
+
+ fun authenticate(
+ @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) {
+ 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))
+ }
+ )
+ }
+
+ override fun onAuthenticationFailed() {
+ super.onAuthenticationFailed()
+ callback(Result.Failure(null, fragment.getString(R.string.biometric_auth_error)))
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+ callback(Result.Success(result.cryptoObject))
+ }
+ }
+ val biometricPrompt = BiometricPrompt(fragment, { Handler(Looper.getMainLooper()).post(it) }, authCallback)
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .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
new file mode 100644
index 00000000..c9a7f59d
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright © 2017-2023 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.
+ */
+object ClipboardUtils {
+ @JvmStatic
+ fun copyTextView(view: View) {
+ val data = when (view) {
+ is TextInputEditText -> Pair(view.editableText, view.hint)
+ is TextView -> Pair(view.text, view.contentDescription)
+ else -> return
+ }
+ if (data.first == null || data.first.isEmpty()) {
+ return
+ }
+ val service = view.context.getSystemService<ClipboardManager>() ?: return
+ service.setPrimaryClip(ClipData.newPlainText(data.second, data.first))
+ 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
new file mode 100644
index 00000000..ace1dc05
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright © 2017-2023 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
+
+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)
+ ?: 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)
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst())
+ path += cursor.getString(0)
+ } finally {
+ cursor.close()
+ }
+ }
+ }
+ DownloadsFile(context, contentStream, path, contentUri)
+ }
+ } else {
+ 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?) {
+ suspend fun delete() = withContext(Dispatchers.IO) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ context.contentResolver.delete(uri!!, null, null)
+ else
+ File(fileName).delete()
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt
new file mode 100644
index 00000000..d617adec
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright © 2017-2023 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.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
+import com.wireguard.config.BadConfigException
+import com.wireguard.config.InetEndpoint
+import com.wireguard.config.InetNetwork
+import com.wireguard.config.ParseException
+import com.wireguard.crypto.Key
+import com.wireguard.crypto.KeyFormatException
+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
+ )
+ 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.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
+ )
+ private val KFE_TYPE_MAP = mapOf(
+ 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
+ )
+ 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
+ )
+
+ operator fun get(throwable: Throwable?): String {
+ val resources = Application.get().resources
+ if (throwable == null) return resources.getString(R.string.unknown_error)
+ val rootCause = rootCause(throwable)
+ return when {
+ rootCause is BadConfigException -> {
+ val reason = getBadConfigExceptionReason(resources, rootCause)
+ val context = if (rootCause.location == BadConfigException.Location.TOP_LEVEL) {
+ resources.getString(R.string.bad_config_context_top_level, rootCause.section.getName())
+ } else {
+ resources.getString(R.string.bad_config_context, rootCause.section.getName(), rootCause.location.getName())
+ }
+ 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 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)
+ }
+ }
+ }
+
+ 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!!.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) {
+ return resources.getString(R.string.bad_config_explanation_positive_number)
+ } else if (bce.location == BadConfigException.Location.PERSISTENT_KEEPALIVE) {
+ return resources.getString(R.string.bad_config_explanation_pka)
+ }
+ return ""
+ }
+
+ 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))
+ } else if (bce.cause is ParseException) {
+ val pe = bce.cause as ParseException?
+ val type = resources.getString((if (PE_CLASS_MAP.containsKey(pe!!.parsingClass)) PE_CLASS_MAP[pe.parsingClass] else R.string.parse_error_generic)!!)
+ return resources.getString(R.string.parse_error_reason, type, pe.text)
+ }
+ return resources.getString(BCE_REASON_MAP.getValue(bce.reason), bce.text)
+ }
+
+ private fun rootCause(throwable: Throwable): Throwable {
+ var cause = throwable
+ while (cause.cause != null) {
+ if (cause is BadConfigException || cause is BackendException ||
+ cause is RootShellException
+ ) break
+ val nextCause = cause.cause!!
+ if (nextCause is RemoteException) break
+ cause = nextCause
+ }
+ return cause
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/Extensions.kt b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
new file mode 100644
index 00000000..3bc85051
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util
+
+import android.content.Context
+import android.util.TypedValue
+import androidx.annotation.AttrRes
+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()
+ theme.resolveAttribute(attrRes, typedValue, true)
+ return typedValue.data
+}
+
+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/QrCodeFromFileScanner.kt b/ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt
new file mode 100644
index 00000000..abc025a4
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright © 2017-2023 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 downscaleBitmap(source: Bitmap, scaledSize: Int): Bitmap {
+
+ val originalWidth = source.width
+ val originalHeight = source.height
+
+ var newWidth = -1
+ var newHeight = -1
+ val multFactor: Float
+
+ when {
+ originalHeight > originalWidth -> {
+ newHeight = scaledSize
+ multFactor = originalWidth.toFloat() / originalHeight.toFloat()
+ newWidth = (newHeight * multFactor).toInt()
+ }
+
+ originalWidth > originalHeight -> {
+ newWidth = scaledSize
+ multFactor = originalHeight.toFloat() / originalWidth.toFloat()
+ newHeight = (newWidth * multFactor).toInt()
+ }
+
+ originalHeight == originalWidth -> {
+ newHeight = scaledSize
+ newWidth = scaledSize
+ }
+ }
+ return Bitmap.createScaledBitmap(source, newWidth, newHeight, false)
+ }
+
+ private fun doScan(data: Uri): Result {
+ Log.d(TAG, "Starting to scan an image: $data")
+ contentResolver.openInputStream(data).use { inputStream ->
+ val originalBitmap = BitmapFactory.decodeStream(inputStream)
+ ?: throw IllegalArgumentException("Can't decode stream to Bitmap")
+
+ return try {
+ scanBitmapForResult(originalBitmap).also {
+ Log.d(TAG, "Found result in original image")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Original image scan finished with error: $e, will try downscaled image")
+ val scaleBitmap = downscaleBitmap(originalBitmap, 500)
+ scanBitmapForResult(originalBitmap).also { scaleBitmap.recycle() }
+ } finally {
+ originalBitmap.recycle()
+ }
+ }
+
+ }
+
+ /**
+ * 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..f7de2465
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/QuantityFormatter.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright © 2017-2023 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..daefc378
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright © 2017-2023 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..2c6ca8c3
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/UserKnobs.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright © 2017-2023 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
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.kt b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.kt
new file mode 100644
index 00000000..c73b1efc
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.viewmodel
+
+import android.os.Build
+import android.os.Parcel
+import android.os.Parcelable
+import androidx.core.os.ParcelCompat
+import androidx.databinding.ObservableArrayList
+import androidx.databinding.ObservableList
+import com.wireguard.config.BadConfigException
+import com.wireguard.config.Config
+import com.wireguard.config.Peer
+
+class ConfigProxy : Parcelable {
+ val `interface`: InterfaceProxy
+ val peers: ObservableList<PeerProxy> = ObservableArrayList()
+
+ private constructor(parcel: Parcel) {
+ `interface` = ParcelCompat.readParcelable(parcel, InterfaceProxy::class.java.classLoader, InterfaceProxy::class.java) ?: InterfaceProxy()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ParcelCompat.readParcelableList(parcel, peers, PeerProxy::class.java.classLoader, PeerProxy::class.java)
+ } else {
+ parcel.readTypedList(peers, PeerProxy.CREATOR)
+ }
+ peers.forEach { it.bind(this) }
+ }
+
+ constructor(other: Config) {
+ `interface` = InterfaceProxy(other.getInterface())
+ other.peers.forEach {
+ val proxy = PeerProxy(it)
+ peers.add(proxy)
+ proxy.bind(this)
+ }
+ }
+
+ constructor() {
+ `interface` = InterfaceProxy()
+ }
+
+ fun addPeer(): PeerProxy {
+ val proxy = PeerProxy()
+ peers.add(proxy)
+ proxy.bind(this)
+ return proxy
+ }
+
+ override fun describeContents() = 0
+
+ @Throws(BadConfigException::class)
+ fun resolve(): Config {
+ val resolvedPeers: MutableCollection<Peer> = ArrayList()
+ peers.forEach { resolvedPeers.add(it.resolve()) }
+ return Config.Builder()
+ .setInterface(`interface`.resolve())
+ .addPeers(resolvedPeers)
+ .build()
+ }
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ dest.writeParcelable(`interface`, flags)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ dest.writeParcelableList(peers, flags)
+ } else {
+ dest.writeTypedList(peers)
+ }
+ }
+
+ private class ConfigProxyCreator : Parcelable.Creator<ConfigProxy> {
+ override fun createFromParcel(parcel: Parcel): ConfigProxy {
+ return ConfigProxy(parcel)
+ }
+
+ override fun newArray(size: Int): Array<ConfigProxy?> {
+ return arrayOfNulls(size)
+ }
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR: Parcelable.Creator<ConfigProxy> = ConfigProxyCreator()
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.kt b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.kt
new file mode 100644
index 00000000..004ebed1
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.kt
@@ -0,0 +1,142 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.viewmodel
+
+import android.os.Parcel
+import android.os.Parcelable
+import androidx.databinding.BaseObservable
+import androidx.databinding.Bindable
+import androidx.databinding.ObservableArrayList
+import androidx.databinding.ObservableList
+import com.wireguard.android.BR
+import com.wireguard.config.Attribute
+import com.wireguard.config.BadConfigException
+import com.wireguard.config.Interface
+import com.wireguard.crypto.Key
+import com.wireguard.crypto.KeyFormatException
+import com.wireguard.crypto.KeyPair
+
+class InterfaceProxy : BaseObservable, Parcelable {
+ @get:Bindable
+ val excludedApplications: ObservableList<String> = ObservableArrayList()
+
+ @get:Bindable
+ val includedApplications: ObservableList<String> = ObservableArrayList()
+
+ @get:Bindable
+ var addresses: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.addresses)
+ }
+
+ @get:Bindable
+ var dnsServers: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.dnsServers)
+ }
+
+ @get:Bindable
+ var listenPort: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.listenPort)
+ }
+
+ @get:Bindable
+ var mtu: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.mtu)
+ }
+
+ @get:Bindable
+ var privateKey: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.privateKey)
+ notifyPropertyChanged(BR.publicKey)
+ }
+
+ @get:Bindable
+ val publicKey: String
+ get() = try {
+ KeyPair(Key.fromBase64(privateKey)).publicKey.toBase64()
+ } catch (ignored: KeyFormatException) {
+ ""
+ }
+
+ private constructor(parcel: Parcel) {
+ addresses = parcel.readString() ?: ""
+ dnsServers = parcel.readString() ?: ""
+ parcel.readStringList(excludedApplications)
+ parcel.readStringList(includedApplications)
+ listenPort = parcel.readString() ?: ""
+ mtu = parcel.readString() ?: ""
+ privateKey = parcel.readString() ?: ""
+ }
+
+ constructor(other: Interface) {
+ addresses = Attribute.join(other.addresses)
+ val dnsServerStrings = other.dnsServers.map { it.hostAddress }.plus(other.dnsSearchDomains)
+ dnsServers = Attribute.join(dnsServerStrings)
+ excludedApplications.addAll(other.excludedApplications)
+ includedApplications.addAll(other.includedApplications)
+ listenPort = other.listenPort.map { it.toString() }.orElse("")
+ mtu = other.mtu.map { it.toString() }.orElse("")
+ val keyPair = other.keyPair
+ privateKey = keyPair.privateKey.toBase64()
+ }
+
+ constructor()
+
+ override fun describeContents() = 0
+
+ fun generateKeyPair() {
+ val keyPair = KeyPair()
+ privateKey = keyPair.privateKey.toBase64()
+ notifyPropertyChanged(BR.privateKey)
+ notifyPropertyChanged(BR.publicKey)
+ }
+
+ @Throws(BadConfigException::class)
+ fun resolve(): Interface {
+ val builder = Interface.Builder()
+ if (addresses.isNotEmpty()) builder.parseAddresses(addresses)
+ if (dnsServers.isNotEmpty()) builder.parseDnsServers(dnsServers)
+ if (excludedApplications.isNotEmpty()) builder.excludeApplications(excludedApplications)
+ if (includedApplications.isNotEmpty()) builder.includeApplications(includedApplications)
+ if (listenPort.isNotEmpty()) builder.parseListenPort(listenPort)
+ if (mtu.isNotEmpty()) builder.parseMtu(mtu)
+ if (privateKey.isNotEmpty()) builder.parsePrivateKey(privateKey)
+ return builder.build()
+ }
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ dest.writeString(addresses)
+ dest.writeString(dnsServers)
+ dest.writeStringList(excludedApplications)
+ dest.writeStringList(includedApplications)
+ dest.writeString(listenPort)
+ dest.writeString(mtu)
+ dest.writeString(privateKey)
+ }
+
+ private class InterfaceProxyCreator : Parcelable.Creator<InterfaceProxy> {
+ override fun createFromParcel(parcel: Parcel): InterfaceProxy {
+ return InterfaceProxy(parcel)
+ }
+
+ override fun newArray(size: Int): Array<InterfaceProxy?> {
+ return arrayOfNulls(size)
+ }
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR: Parcelable.Creator<InterfaceProxy> = InterfaceProxyCreator()
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt
new file mode 100644
index 00000000..e78d0826
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.viewmodel
+
+import android.os.Parcel
+import android.os.Parcelable
+import androidx.databinding.BaseObservable
+import androidx.databinding.Bindable
+import androidx.databinding.Observable
+import androidx.databinding.Observable.OnPropertyChangedCallback
+import androidx.databinding.ObservableList
+import com.wireguard.android.BR
+import com.wireguard.config.Attribute
+import com.wireguard.config.BadConfigException
+import com.wireguard.config.Peer
+import java.lang.ref.WeakReference
+
+class PeerProxy : BaseObservable, Parcelable {
+ private val dnsRoutes: MutableList<String?> = ArrayList()
+ private var allowedIpsState = AllowedIpsState.INVALID
+ private var interfaceDnsListener: InterfaceDnsListener? = null
+ private var peerListListener: PeerListListener? = null
+ private var owner: ConfigProxy? = null
+ private var totalPeers = 0
+
+ @get:Bindable
+ var allowedIps: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.allowedIps)
+ calculateAllowedIpsState()
+ }
+
+ @get:Bindable
+ var endpoint: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.endpoint)
+ }
+
+ @get:Bindable
+ var persistentKeepalive: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.persistentKeepalive)
+ }
+
+ @get:Bindable
+ var preSharedKey: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.preSharedKey)
+ }
+
+ @get:Bindable
+ var publicKey: String = ""
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.publicKey)
+ }
+
+ @get:Bindable
+ val isAbleToExcludePrivateIps: Boolean
+ get() = allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS || allowedIpsState == AllowedIpsState.CONTAINS_IPV4_WILDCARD
+
+ @get:Bindable
+ val isExcludingPrivateIps: Boolean
+ get() = allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS
+
+ private constructor(parcel: Parcel) {
+ allowedIps = parcel.readString() ?: ""
+ endpoint = parcel.readString() ?: ""
+ persistentKeepalive = parcel.readString() ?: ""
+ preSharedKey = parcel.readString() ?: ""
+ publicKey = parcel.readString() ?: ""
+ }
+
+ constructor(other: Peer) {
+ allowedIps = Attribute.join(other.allowedIps)
+ endpoint = other.endpoint.map { it.toString() }.orElse("")
+ persistentKeepalive = other.persistentKeepalive.map { it.toString() }.orElse("")
+ preSharedKey = other.preSharedKey.map { it.toBase64() }.orElse("")
+ publicKey = other.publicKey.toBase64()
+ }
+
+ constructor()
+
+ fun bind(owner: ConfigProxy) {
+ val interfaze: InterfaceProxy = owner.`interface`
+ val peers = owner.peers
+ if (interfaceDnsListener == null) interfaceDnsListener = InterfaceDnsListener(this)
+ interfaze.addOnPropertyChangedCallback(interfaceDnsListener!!)
+ setInterfaceDns(interfaze.dnsServers)
+ if (peerListListener == null) peerListListener = PeerListListener(this)
+ peers.addOnListChangedCallback(peerListListener)
+ setTotalPeers(peers.size)
+ this.owner = owner
+ }
+
+ private fun calculateAllowedIpsState() {
+ val newState: AllowedIpsState
+ newState = if (totalPeers == 1) {
+ // String comparison works because we only care if allowedIps is a superset of one of
+ // the above sets of (valid) *networks*. We are not checking for a superset based on
+ // the individual addresses in each set.
+ val networkStrings: Collection<String> = getAllowedIpsSet()
+ // If allowedIps contains both the wildcard and the public networks, then private
+ // networks aren't excluded!
+ if (networkStrings.containsAll(IPV4_WILDCARD))
+ AllowedIpsState.CONTAINS_IPV4_WILDCARD
+ else if (networkStrings.containsAll(IPV4_PUBLIC_NETWORKS))
+ AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS
+ else
+ AllowedIpsState.OTHER
+ } else {
+ AllowedIpsState.INVALID
+ }
+ if (newState != allowedIpsState) {
+ allowedIpsState = newState
+ notifyPropertyChanged(BR.ableToExcludePrivateIps)
+ notifyPropertyChanged(BR.excludingPrivateIps)
+ }
+ }
+
+ override fun describeContents() = 0
+
+ private fun getAllowedIpsSet() = setOf(*Attribute.split(allowedIps))
+
+ // Replace the first instance of the wildcard with the public network list, or vice versa.
+ // DNS servers only need to handled specially when we're excluding private IPs.
+ fun setExcludingPrivateIps(excludingPrivateIps: Boolean) {
+ if (!isAbleToExcludePrivateIps || isExcludingPrivateIps == excludingPrivateIps) return
+ val oldNetworks = if (excludingPrivateIps) IPV4_WILDCARD else IPV4_PUBLIC_NETWORKS
+ val newNetworks = if (excludingPrivateIps) IPV4_PUBLIC_NETWORKS else IPV4_WILDCARD
+ val input: Collection<String> = getAllowedIpsSet()
+ val outputSize = input.size - oldNetworks.size + newNetworks.size
+ val output: MutableCollection<String?> = LinkedHashSet(outputSize)
+ var replaced = false
+ // Replace the first instance of the wildcard with the public network list, or vice versa.
+ for (network in input) {
+ if (oldNetworks.contains(network)) {
+ if (!replaced) {
+ for (replacement in newNetworks) if (!output.contains(replacement)) output.add(replacement)
+ replaced = true
+ }
+ } else if (!output.contains(network)) {
+ output.add(network)
+ }
+ }
+ // DNS servers only need to handled specially when we're excluding private IPs.
+ if (excludingPrivateIps) output.addAll(dnsRoutes) else output.removeAll(dnsRoutes)
+ allowedIps = Attribute.join(output)
+ allowedIpsState = if (excludingPrivateIps) AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS else AllowedIpsState.CONTAINS_IPV4_WILDCARD
+ notifyPropertyChanged(BR.allowedIps)
+ notifyPropertyChanged(BR.excludingPrivateIps)
+ }
+
+ @Throws(BadConfigException::class)
+ fun resolve(): Peer {
+ val builder = Peer.Builder()
+ if (allowedIps.isNotEmpty()) builder.parseAllowedIPs(allowedIps)
+ if (endpoint.isNotEmpty()) builder.parseEndpoint(endpoint)
+ if (persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(persistentKeepalive)
+ if (preSharedKey.isNotEmpty()) builder.parsePreSharedKey(preSharedKey)
+ if (publicKey.isNotEmpty()) builder.parsePublicKey(publicKey)
+ return builder.build()
+ }
+
+ private fun setInterfaceDns(dnsServers: CharSequence) {
+ val newDnsRoutes = Attribute.split(dnsServers).filter { !it.contains(":") }.map { "$it/32" }
+ if (allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS) {
+ val input = getAllowedIpsSet()
+ // Yes, this is quadratic in the number of DNS servers, but most users have 1 or 2.
+ val output = input.filter { !dnsRoutes.contains(it) || newDnsRoutes.contains(it) }.plus(newDnsRoutes).distinct()
+ // None of the public networks are /32s, so this cannot change the AllowedIPs state.
+ allowedIps = Attribute.join(output)
+ notifyPropertyChanged(BR.allowedIps)
+ }
+ dnsRoutes.clear()
+ dnsRoutes.addAll(newDnsRoutes)
+ }
+
+ private fun setTotalPeers(totalPeers: Int) {
+ if (this.totalPeers == totalPeers) return
+ this.totalPeers = totalPeers
+ calculateAllowedIpsState()
+ }
+
+ fun unbind() {
+ if (owner == null) return
+ val interfaze: InterfaceProxy = owner!!.`interface`
+ val peers = owner!!.peers
+ if (interfaceDnsListener != null) interfaze.removeOnPropertyChangedCallback(interfaceDnsListener!!)
+ if (peerListListener != null) peers.removeOnListChangedCallback(peerListListener)
+ peers.remove(this)
+ setInterfaceDns("")
+ setTotalPeers(0)
+ owner = null
+ }
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ dest.writeString(allowedIps)
+ dest.writeString(endpoint)
+ dest.writeString(persistentKeepalive)
+ dest.writeString(preSharedKey)
+ dest.writeString(publicKey)
+ }
+
+ private enum class AllowedIpsState {
+ CONTAINS_IPV4_PUBLIC_NETWORKS, CONTAINS_IPV4_WILDCARD, INVALID, OTHER
+ }
+
+ private class InterfaceDnsListener constructor(peerProxy: PeerProxy) : OnPropertyChangedCallback() {
+ private val weakPeerProxy: WeakReference<PeerProxy> = WeakReference(peerProxy)
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ val peerProxy = weakPeerProxy.get()
+ if (peerProxy == null) {
+ sender.removeOnPropertyChangedCallback(this)
+ return
+ }
+ // This shouldn't be possible, but try to avoid a ClassCastException anyway.
+ if (sender !is InterfaceProxy) return
+ if (!(propertyId == BR._all || propertyId == BR.dnsServers)) return
+ peerProxy.setInterfaceDns(sender.dnsServers)
+ }
+ }
+
+ private class PeerListListener(peerProxy: PeerProxy) : ObservableList.OnListChangedCallback<ObservableList<PeerProxy?>>() {
+ private val weakPeerProxy: WeakReference<PeerProxy> = WeakReference(peerProxy)
+ override fun onChanged(sender: ObservableList<PeerProxy?>) {
+ val peerProxy = weakPeerProxy.get()
+ if (peerProxy == null) {
+ sender.removeOnListChangedCallback(this)
+ return
+ }
+ peerProxy.setTotalPeers(sender.size)
+ }
+
+ override fun onItemRangeChanged(
+ sender: ObservableList<PeerProxy?>,
+ positionStart: Int, itemCount: Int
+ ) {
+ // Do nothing.
+ }
+
+ override fun onItemRangeInserted(
+ sender: ObservableList<PeerProxy?>,
+ positionStart: Int, itemCount: Int
+ ) {
+ onChanged(sender)
+ }
+
+ override fun onItemRangeMoved(
+ sender: ObservableList<PeerProxy?>,
+ fromPosition: Int, toPosition: Int,
+ itemCount: Int
+ ) {
+ // Do nothing.
+ }
+
+ override fun onItemRangeRemoved(
+ sender: ObservableList<PeerProxy?>,
+ positionStart: Int, itemCount: Int
+ ) {
+ onChanged(sender)
+ }
+ }
+
+ private class PeerProxyCreator : Parcelable.Creator<PeerProxy> {
+ override fun createFromParcel(parcel: Parcel): PeerProxy {
+ return PeerProxy(parcel)
+ }
+
+ override fun newArray(size: Int): Array<PeerProxy?> {
+ return arrayOfNulls(size)
+ }
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR: Parcelable.Creator<PeerProxy> = PeerProxyCreator()
+ private val IPV4_PUBLIC_NETWORKS = setOf(
+ "0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3",
+ "64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12",
+ "172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7",
+ "176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16",
+ "192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10",
+ "193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4"
+ )
+ private val IPV4_WILDCARD = setOf("0.0.0.0/0")
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.kt b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.kt
new file mode 100644
index 00000000..8c822dcb
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.widget
+
+import android.text.InputFilter
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import com.wireguard.crypto.Key
+
+/**
+ * InputFilter for entering WireGuard private/public keys encoded with base64.
+ */
+class KeyInputFilter : InputFilter {
+ override fun filter(
+ source: CharSequence,
+ sStart: Int, sEnd: Int,
+ dest: Spanned,
+ dStart: Int, dEnd: Int
+ ): CharSequence? {
+ var replacement: SpannableStringBuilder? = null
+ var rIndex = 0
+ val dLength = dest.length
+ for (sIndex in sStart until sEnd) {
+ val c = source[sIndex]
+ val dIndex = dStart + (sIndex - sStart)
+ // Restrict characters to the base64 character set.
+ // Ensure adding this character does not push the length over the limit.
+ if ((dIndex + 1 < Key.Format.BASE64.length && isAllowed(c) ||
+ dIndex + 1 == Key.Format.BASE64.length && c == '=') &&
+ dLength + (sIndex - sStart) < Key.Format.BASE64.length
+ ) {
+ ++rIndex
+ } else {
+ if (replacement == null) replacement = SpannableStringBuilder(source, sStart, sEnd)
+ replacement.delete(rIndex, rIndex + 1)
+ }
+ }
+ return replacement
+ }
+
+ companion object {
+ private fun isAllowed(c: Char) = Character.isLetterOrDigit(c) || c == '+' || c == '/'
+
+ @JvmStatic
+ fun newInstance() = KeyInputFilter()
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.kt b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.kt
new file mode 100644
index 00000000..91c7da0c
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.widget.RelativeLayout
+import com.wireguard.android.R
+
+class MultiselectableRelativeLayout @JvmOverloads constructor(
+ context: Context? = null,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0
+) : RelativeLayout(context, attrs, defStyleAttr, defStyleRes) {
+ private var multiselected = false
+
+ override fun onCreateDrawableState(extraSpace: Int): IntArray {
+ if (multiselected) {
+ val drawableState = super.onCreateDrawableState(extraSpace + 1)
+ View.mergeDrawableStates(drawableState, STATE_MULTISELECTED)
+ return drawableState
+ }
+ return super.onCreateDrawableState(extraSpace)
+ }
+
+ fun setMultiSelected(on: Boolean) {
+ if (!multiselected) {
+ multiselected = true
+ refreshDrawableState()
+ }
+ isActivated = on
+ }
+
+ fun setSingleSelected(on: Boolean) {
+ if (multiselected) {
+ multiselected = false
+ refreshDrawableState()
+ }
+ isActivated = on
+ }
+
+ companion object {
+ private val STATE_MULTISELECTED = intArrayOf(R.attr.state_multiselected)
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt
new file mode 100644
index 00000000..e21ebaba
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.widget
+
+import android.text.InputFilter
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import com.wireguard.android.backend.Tunnel
+
+/**
+ * InputFilter for entering WireGuard configuration names (Linux interface names).
+ */
+class NameInputFilter : InputFilter {
+ override fun filter(
+ source: CharSequence,
+ sStart: Int, sEnd: Int,
+ dest: Spanned,
+ dStart: Int, dEnd: Int
+ ): CharSequence? {
+ var replacement: SpannableStringBuilder? = null
+ var rIndex = 0
+ val dLength = dest.length
+ for (sIndex in sStart until sEnd) {
+ val c = source[sIndex]
+ val dIndex = dStart + (sIndex - sStart)
+ // Restrict characters to those valid in interfaces.
+ // Ensure adding this character does not push the length over the limit.
+ if (dIndex < Tunnel.NAME_MAX_LENGTH && isAllowed(c) &&
+ dLength + (sIndex - sStart) < Tunnel.NAME_MAX_LENGTH
+ ) {
+ ++rIndex
+ } else {
+ if (replacement == null) replacement = SpannableStringBuilder(source, sStart, sEnd)
+ replacement.delete(rIndex, rIndex + 1)
+ }
+ }
+ return replacement
+ }
+
+ companion object {
+ private fun isAllowed(c: Char) = Character.isLetterOrDigit(c) || "_=+.-".indexOf(c) >= 0
+
+ @JvmStatic
+ fun newInstance() = NameInputFilter()
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt
new file mode 100644
index 00000000..0e2eeff1
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt
@@ -0,0 +1,175 @@
+/*
+ * Copyright © 2018 The Android Open Source Project
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.widget
+
+import android.animation.ObjectAnimator
+import android.content.res.ColorStateList
+import android.graphics.*
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.util.FloatProperty
+import androidx.annotation.ColorInt
+import androidx.annotation.IntRange
+import androidx.annotation.RequiresApi
+
+@RequiresApi(Build.VERSION_CODES.N)
+class SlashDrawable(private val mDrawable: Drawable) : Drawable() {
+ private val mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val mPath = Path()
+ private val mSlashRect = RectF()
+ private var mAnimationEnabled = true
+
+ // Animate this value on change
+ private var mCurrentSlashLength = 0f
+ private var mRotation = 0f
+ private var mSlashed = false
+
+ override fun draw(canvas: Canvas) {
+ canvas.save()
+ val m = Matrix()
+ val width = bounds.width()
+ val height = bounds.height()
+ val radiusX = scale(CORNER_RADIUS, width)
+ val radiusY = scale(CORNER_RADIUS, height)
+ updateRect(
+ scale(LEFT, width),
+ scale(TOP, height),
+ scale(RIGHT, width),
+ scale(TOP + mCurrentSlashLength, height)
+ )
+ mPath.reset()
+ // Draw the slash vertically
+ mPath.addRoundRect(mSlashRect, radiusX, radiusY, Path.Direction.CW)
+ // Rotate -45 + desired rotation
+ m.setRotate(mRotation + DEFAULT_ROTATION, width / 2f, height / 2f)
+ mPath.transform(m)
+ canvas.drawPath(mPath, mPaint)
+
+ // Rotate back to vertical
+ m.setRotate(-mRotation - DEFAULT_ROTATION, width / 2f, height / 2f)
+ mPath.transform(m)
+
+ // Draw another rect right next to the first, for clipping
+ m.setTranslate(mSlashRect.width(), 0f)
+ mPath.transform(m)
+ mPath.addRoundRect(mSlashRect, 1f * width, 1f * height, Path.Direction.CW)
+ m.setRotate(mRotation + DEFAULT_ROTATION, width / 2f, height / 2f)
+ mPath.transform(m)
+ @Suppress("DEPRECATION")
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
+ canvas.clipPath(mPath, Region.Op.DIFFERENCE) else canvas.clipOutPath(mPath)
+ mDrawable.draw(canvas)
+ canvas.restore()
+ }
+
+ override fun getIntrinsicHeight() = mDrawable.intrinsicHeight
+
+ override fun getIntrinsicWidth() = mDrawable.intrinsicWidth
+
+ override fun getOpacity() = PixelFormat.OPAQUE
+
+ override fun onBoundsChange(bounds: Rect) {
+ super.onBoundsChange(bounds)
+ mDrawable.bounds = bounds
+ }
+
+ private fun scale(frac: Float, width: Int) = frac * width
+
+ override fun setAlpha(@IntRange(from = 0, to = 255) alpha: Int) {
+ mDrawable.alpha = alpha
+ mPaint.alpha = alpha
+ }
+
+ fun setAnimationEnabled(enabled: Boolean) {
+ mAnimationEnabled = enabled
+ }
+
+ override fun setColorFilter(colorFilter: ColorFilter?) {
+ mDrawable.colorFilter = colorFilter
+ mPaint.colorFilter = colorFilter
+ }
+
+ private fun setDrawableTintList(tint: ColorStateList?) {
+ mDrawable.setTintList(tint)
+ }
+
+ fun setRotation(rotation: Float) {
+ if (mRotation == rotation) return
+ mRotation = rotation
+ invalidateSelf()
+ }
+
+ fun setSlashed(slashed: Boolean) {
+ if (mSlashed == slashed) return
+ mSlashed = slashed
+ val end = if (mSlashed) SLASH_HEIGHT / SCALE else 0f
+ val start = if (mSlashed) 0f else SLASH_HEIGHT / SCALE
+ if (mAnimationEnabled) {
+ val anim = ObjectAnimator.ofFloat(this, mSlashLengthProp, start, end)
+ anim.addUpdateListener { _ -> invalidateSelf() }
+ anim.duration = QS_ANIM_LENGTH
+ anim.start()
+ } else {
+ mCurrentSlashLength = end
+ invalidateSelf()
+ }
+ }
+
+ override fun setTint(@ColorInt tintColor: Int) {
+ super.setTint(tintColor)
+ mDrawable.setTint(tintColor)
+ mPaint.color = tintColor
+ }
+
+ override fun setTintList(tint: ColorStateList?) {
+ super.setTintList(tint)
+ setDrawableTintList(tint)
+ mPaint.color = tint?.defaultColor ?: 0
+ invalidateSelf()
+ }
+
+ override fun setTintMode(tintMode: PorterDuff.Mode?) {
+ super.setTintMode(tintMode)
+ mDrawable.setTintMode(tintMode)
+ }
+
+ private fun updateRect(left: Float, top: Float, right: Float, bottom: Float) {
+ mSlashRect.left = left
+ mSlashRect.top = top
+ mSlashRect.right = right
+ mSlashRect.bottom = bottom
+ }
+
+ companion object {
+ private const val CENTER_X = 10.65f
+ private const val CENTER_Y = 11.869239f
+ private val CORNER_RADIUS = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) 0f else 1f
+
+ // Draw the slash washington-monument style; rotate to no-u-turn style
+ private const val DEFAULT_ROTATION = -45f
+ private const val QS_ANIM_LENGTH: Long = 350
+ private const val SCALE = 24f
+ private const val SLASH_HEIGHT = 28f
+
+ // These values are derived in un-rotated (vertical) orientation
+ private const val SLASH_WIDTH = 1.8384776f
+
+ // Bottom is derived during animation
+ private const val LEFT = (CENTER_X - SLASH_WIDTH / 2) / SCALE
+ private const val RIGHT = (CENTER_X + SLASH_WIDTH / 2) / SCALE
+ private const val TOP = (CENTER_Y - SLASH_HEIGHT / 2) / SCALE
+ private val mSlashLengthProp: FloatProperty<SlashDrawable> = object : FloatProperty<SlashDrawable>("slashLength") {
+ override fun get(obj: SlashDrawable): Float {
+ return obj.mCurrentSlashLength
+ }
+
+ override fun setValue(obj: SlashDrawable, value: Float) {
+ obj.mCurrentSlashLength = value
+ }
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.kt b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.kt
new file mode 100644
index 00000000..0bde810d
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2013 The Android Open Source Project
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.widget
+
+import android.content.Context
+import android.os.Parcelable
+import android.util.AttributeSet
+import com.google.android.material.materialswitch.MaterialSwitch
+
+class ToggleSwitch @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : MaterialSwitch(context, attrs) {
+ private var isRestoringState = false
+ private var listener: OnBeforeCheckedChangeListener? = null
+ override fun onRestoreInstanceState(state: Parcelable) {
+ isRestoringState = true
+ super.onRestoreInstanceState(state)
+ isRestoringState = false
+ }
+
+ override fun setChecked(checked: Boolean) {
+ if (checked == isChecked) return
+ if (isRestoringState || listener == null) {
+ super.setChecked(checked)
+ return
+ }
+ isEnabled = false
+ listener!!.onBeforeCheckedChanged(this, checked)
+ }
+
+ fun setCheckedInternal(checked: Boolean) {
+ super.setChecked(checked)
+ isEnabled = true
+ }
+
+ fun setOnBeforeCheckedChangeListener(listener: OnBeforeCheckedChangeListener?) {
+ this.listener = listener
+ }
+
+ interface OnBeforeCheckedChangeListener {
+ fun onBeforeCheckedChanged(toggleSwitch: ToggleSwitch?, checked: Boolean)
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/TvCardView.kt b/ui/src/main/java/com/wireguard/android/widget/TvCardView.kt
new file mode 100644
index 00000000..9f7d7011
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/TvCardView.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import com.google.android.material.card.MaterialCardView
+import com.wireguard.android.R
+
+class TvCardView(context: Context?, attrs: AttributeSet?) : MaterialCardView(context, attrs) {
+ var isUp: Boolean = false
+ set(value) {
+ field = value
+ refreshDrawableState()
+ }
+ var isDeleting: Boolean = false
+ set(value) {
+ field = value
+ refreshDrawableState()
+ }
+
+ override fun onCreateDrawableState(extraSpace: Int): IntArray {
+ if (isUp || isDeleting) {
+ val drawableState = super.onCreateDrawableState(extraSpace + (if (isUp) 1 else 0) + (if (isDeleting) 1 else 0))
+ if (isUp) {
+ View.mergeDrawableStates(drawableState, STATE_IS_UP)
+ }
+ if (isDeleting) {
+ View.mergeDrawableStates(drawableState, STATE_IS_DELETING)
+ }
+ return drawableState
+ }
+ return super.onCreateDrawableState(extraSpace)
+ }
+
+ companion object {
+ private val STATE_IS_UP = intArrayOf(R.attr.state_isUp)
+ private val STATE_IS_DELETING = intArrayOf(R.attr.state_isDeleting)
+ }
+} \ No newline at end of file