Repo cloned

This commit is contained in:
Fr4nz D13trich 2026-02-10 16:31:45 +01:00
parent b280361250
commit db901828a8
235 changed files with 27925 additions and 2 deletions

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">WireGuard β</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:node="remove" />
</manifest>

View file

@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.SYSTEM_ALERT_WINDOW"
android:minSdkVersion="34" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.camera.any"
android:required="false" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<permission
android:name="${applicationId}.permission.CONTROL_TUNNELS"
android:description="@string/permission_description"
android:icon="@mipmap/ic_launcher"
android:label="@string/permission_label"
android:protectionLevel="dangerous" />
<application
android:name=".Application"
android:allowBackup="false"
android:banner="@mipmap/banner"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".activity.TunnelToggleActivity"
android:excludeFromRecents="true"
android:theme="@style/NoBackgroundTheme" />
<activity
android:name=".activity.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name=".activity.TvMainActivity"
android:exported="true"
android:theme="@style/TvTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".activity.SettingsActivity"
android:label="@string/settings"
android:parentActivityName=".activity.MainActivity" />
<activity
android:name=".activity.TunnelCreatorActivity"
android:label="@string/create_activity_title"
android:parentActivityName=".activity.MainActivity" />
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
<activity
android:name=".activity.LogViewerActivity"
android:exported="false"
android:label="@string/log_viewer_title">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
</intent-filter>
</activity>
<provider
android:name=".activity.LogViewerActivity$ExportedLogContentProvider"
android:authorities="${applicationId}.exported-log"
android:exported="false"
android:grantUriPermissions="true" />
<receiver
android:name=".BootShutdownReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.ACTION_SHUTDOWN" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".updater.Updater$AppUpdatedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".model.TunnelManager$IntentReceiver"
android:exported="true"
android:permission="${applicationId}.permission.CONTROL_TUNNELS">
<intent-filter>
<action android:name="com.wireguard.android.action.REFRESH_TUNNEL_STATES" />
<action android:name="com.wireguard.android.action.SET_TUNNEL_UP" />
<action android:name="com.wireguard.android.action.SET_TUNNEL_DOWN" />
</intent-filter>
</receiver>
<service
android:name=".QuickTileService"
android:exported="true"
android:icon="@drawable/ic_tile"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="false" />
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
</manifest>

View file

@ -0,0 +1,157 @@
/*
* Copyright © 2017-2025 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)
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright © 2017-2025 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"
}
}

View file

@ -0,0 +1,203 @@
/*
* Copyright © 2017-2025 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.
*/
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() {
applicationScope.launch {
if (tunnel == null) {
Application.getTunnelManager().getTunnels()
updateTile()
}
when (val tunnel = tunnel) {
null -> {
Log.d(TAG, "No tunnel set, so launching main activity")
val intent = Intent(this@QuickTileService, 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@QuickTileService, 0, intent, PendingIntent.FLAG_IMMUTABLE))
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
else -> {
unlockAndRun {
applicationScope.launch {
try {
tunnel.setStateAsync(Tunnel.State.TOGGLE)
updateTile()
} catch (e: Throwable) {
Log.d(TAG, "Failed to set state, so falling back", e)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !Settings.canDrawOverlays(this@QuickTileService)) {
Log.d(TAG, "Need overlay permissions")
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
}
}

View file

@ -0,0 +1,96 @@
/*
* Copyright © 2017-2025 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"
}
}

View file

@ -0,0 +1,382 @@
/*
* Copyright © 2017-2025 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<Any>("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) {
}
}
}
}
}

View file

@ -0,0 +1,129 @@
/*
* Copyright © 2017-2025 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(if (isTwoPaneLayout) R.id.detail_container else R.id.list_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(if (isTwoPaneLayout) R.id.detail_container else R.id.list_detail_container, TunnelDetailFragment())
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
addToBackStack(null)
}
}
return true
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.activity
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
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() {
// Since this is pretty much abandoned by androidx, it never got updated for proper EdgeToEdge support,
// which is enabled everywhere for API 35. So handle the insets manually here.
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = super.onCreateView(inflater, container, savedInstanceState)
view.fitsSystemWindows = true
return view
}
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 (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)
}
}
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.activity
import android.os.Bundle
import com.wireguard.android.R
import com.wireguard.android.model.ObservableTunnel
/**
* Standalone activity for creating tunnels.
*/
class TunnelCreatorActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.tunnel_creator_activity)
}
override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?): Boolean {
finish()
return true
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright © 2017-2025 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
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) {
try {
val intent = GoBackend.VpnService.prepare(this@TunnelToggleActivity)
if (intent != null) {
permissionActivityResultLauncher.launch(intent)
return@launch
}
} catch (e: Exception) {
Toast.makeText(this@TunnelToggleActivity, ErrorMessages[e], Toast.LENGTH_LONG).show()
}
}
toggleTunnelWithPermissionsResult()
}
}
companion object {
private const val TAG = "WireGuard/TunnelToggleActivity"
}
}

View file

@ -0,0 +1,431 @@
/*
* Copyright © 2017-2025 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.OpenDocument() {
override fun createIntent(context: Context, input: Array<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(arrayOf("*/*"))
} 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).apply {
data = Uri.parse("https://play.google.com/store/apps/details?id=com.cxinventor.file.explorer")
setPackage("com.android.vending")
})
} 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>()
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))
}
})
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"
}
}

View file

@ -0,0 +1,68 @@
/*
* Copyright © 2017-2025 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
}

View file

@ -0,0 +1,82 @@
/*
* Copyright © 2017-2025 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"
}
}

View file

@ -0,0 +1,194 @@
/*
* Copyright © 2017-2025 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
}
}

View file

@ -0,0 +1,122 @@
/*
* Copyright © 2017-2025 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)
}
}
}
}

View file

@ -0,0 +1,12 @@
/*
* Copyright © 2017-2025 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
}

View file

@ -0,0 +1,32 @@
/*
* Copyright © 2017-2025 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
}
}

View file

@ -0,0 +1,106 @@
/*
* Copyright © 2017-2025 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)
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright © 2017-2025 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()
}
}

View file

@ -0,0 +1,104 @@
/*
* Copyright © 2017-2025 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"
}
}

View file

@ -0,0 +1,170 @@
/*
* Copyright © 2017-2025 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 ?: return@forEach
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
}
}
}

View file

@ -0,0 +1,114 @@
/*
* Copyright © 2017-2025 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"
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright © 2017-2025 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
}
}
}

View file

@ -0,0 +1,150 @@
/*
* Copyright © 2017-2025 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
}
}
}
}

View file

@ -0,0 +1,333 @@
/*
* Copyright © 2017-2025 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"
}
}

View file

@ -0,0 +1,342 @@
/*
* Copyright © 2017-2025 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"
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright © 2017-2025 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)
}
}

View file

@ -0,0 +1,146 @@
/*
* Copyright © 2017-2025 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"
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright © 2017-2025 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
}
}

View file

@ -0,0 +1,254 @@
/*
* Copyright © 2017-2025 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 (!UserKnobs.allowRemoteControlIntents.first())
return@launch
val 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"
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright © 2017-2025 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
import androidx.core.net.toUri
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 = "https://www.wireguard.com/donations/".toUri()
try {
context.startActivity(intent)
} catch (e: Throwable) {
Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show()
}
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright © 2017-2025 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"
}
}

View file

@ -0,0 +1,135 @@
/*
* Copyright © 2017-2025 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()
}
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright © 2017-2025 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()
}
}
}
}

View file

@ -0,0 +1,79 @@
/*
* Copyright © 2017-2025 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);
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright © 2017-2025 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()
}
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright © 2017-2025 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"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,173 @@
/*
* Copyright © 2017-2025 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)
}
}

View file

@ -0,0 +1,460 @@
/*
* Copyright © 2017-2025 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 = connection.contentLengthLong.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 (BuildConfig.BUILD_TYPE == "googleplay") {
if (installer(context).isNotEmpty()) {
applicationScope.launch {
emitProgress(Progress.Corrupt(null))
}
}
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) != true
) {
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)
}
}
}

View file

@ -0,0 +1,17 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.content.RestrictionsManager
import androidx.core.content.getSystemService
import com.wireguard.android.Application
object AdminKnobs {
private val restrictions: RestrictionsManager? = Application.get().getSystemService()
val disableConfigExport: Boolean
get() = restrictions?.applicationRestrictions?.getBoolean("disable_config_export", false)
?: false
}

View file

@ -0,0 +1,80 @@
/*
* Copyright © 2017-2025 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)
}
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Build
import android.view.View
import android.widget.TextView
import androidx.core.content.getSystemService
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
import com.wireguard.android.R
/**
* Standalone utilities for interacting with the system clipboard.
*/
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()
}
}
}

View file

@ -0,0 +1,104 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.Manifest
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.wireguard.android.R
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
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()
}
}
}

View file

@ -0,0 +1,158 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.content.res.Resources
import android.os.RemoteException
import com.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
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright © 2017-2025 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

View file

@ -0,0 +1,84 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Log
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.NotFoundException
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.Reader
import com.google.zxing.Result
import com.google.zxing.common.HybridBinarizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Encapsulates the logic of scanning a barcode from a file,
* @property contentResolver - Resolver to read the incoming data
* @property reader - An instance of zxing's [Reader] class to parse the image
*/
class QrCodeFromFileScanner(
private val contentResolver: ContentResolver,
private val reader: Reader,
) {
private fun scanBitmapForResult(source: Bitmap): Result {
val width = source.width
val height = source.height
val pixels = IntArray(width * height)
source.getPixels(pixels, 0, width, 0, 0, width, height)
val bBitmap = BinaryBitmap(HybridBinarizer(RGBLuminanceSource(width, height, pixels)))
return reader.decode(bBitmap, mapOf(DecodeHintType.TRY_HARDER to true))
}
private fun doScan(data: Uri): Result {
Log.d(TAG, "Starting to scan an image: $data")
contentResolver.openInputStream(data).use { inputStream ->
var bitmap: Bitmap? = null
var firstException: Throwable? = null
for (i in arrayOf(1, 2, 4, 8, 16, 32, 64, 128)) {
try {
val options = BitmapFactory.Options()
options.inSampleSize = i
bitmap = BitmapFactory.decodeStream(inputStream, null, options)
?: throw IllegalArgumentException("Can't decode stream for bitmap")
return scanBitmapForResult(bitmap)
} catch (e: Throwable) {
bitmap?.recycle()
System.gc()
Log.e(TAG, "Original image scan at scale factor $i finished with error: $e")
if (firstException == null)
firstException = e
}
}
throw Exception(firstException)
}
}
/**
* Attempts to parse incoming data
* @return result of the decoding operation
* @throws NotFoundException when parser didn't find QR code in the image
*/
suspend fun scan(data: Uri) = withContext(Dispatchers.Default) { doScan(data) }
companion object {
private const val TAG = "QrCodeFromFileScanner"
/**
* Given a reference to a file, check if this file could be parsed by this class
* @return true if the file can be parsed, false if not
*/
fun validContentType(contentResolver: ContentResolver, data: Uri): Boolean {
return contentResolver.getType(data)?.startsWith("image/") == true
}
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.icu.text.ListFormatter
import android.icu.text.MeasureFormat
import android.icu.text.RelativeDateTimeFormatter
import android.icu.util.Measure
import android.icu.util.MeasureUnit
import android.os.Build
import com.wireguard.android.Application
import com.wireguard.android.R
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
object QuantityFormatter {
fun formatBytes(bytes: Long): String {
val context = Application.get().applicationContext
return when {
bytes < 1024 -> context.getString(R.string.transfer_bytes, bytes)
bytes < 1024 * 1024 -> context.getString(R.string.transfer_kibibytes, bytes / 1024.0)
bytes < 1024 * 1024 * 1024 -> context.getString(R.string.transfer_mibibytes, bytes / (1024.0 * 1024.0))
bytes < 1024 * 1024 * 1024 * 1024L -> context.getString(R.string.transfer_gibibytes, bytes / (1024.0 * 1024.0 * 1024.0))
else -> context.getString(R.string.transfer_tibibytes, bytes / (1024.0 * 1024.0 * 1024.0) / 1024.0)
}
}
fun formatEpochAgo(epochMillis: Long): String {
var span = (System.currentTimeMillis() - epochMillis) / 1000
if (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)
}
}

View file

@ -0,0 +1,152 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import androidx.fragment.app.FragmentManager
import com.wireguard.android.Application
import com.wireguard.android.R
import com.wireguard.android.fragment.ConfigNamingDialogFragment
import com.wireguard.android.model.ObservableTunnel
import com.wireguard.config.Config
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
object TunnelImporter {
suspend fun importTunnel(contentResolver: ContentResolver, uri: Uri, messageCallback: (CharSequence) -> Unit) = withContext(Dispatchers.IO) {
val context = Application.get().applicationContext
val futureTunnels = ArrayList<Deferred<ObservableTunnel>>()
val throwables = ArrayList<Throwable>()
try {
val columns = arrayOf(OpenableColumns.DISPLAY_NAME)
var name = ""
contentResolver.query(uri, columns, null, null, null)?.use { cursor ->
if (cursor.moveToFirst() && !cursor.isNull(0)) {
name = cursor.getString(0)
}
}
if (name.isEmpty()) {
name = Uri.decode(uri.lastPathSegment)
}
var idx = name.lastIndexOf('/')
if (idx >= 0) {
require(idx < name.length - 1) { context.getString(R.string.illegal_filename_error, name) }
name = name.substring(idx + 1)
}
val isZip = name.lowercase().endsWith(".zip")
if (name.lowercase().endsWith(".conf")) {
name = name.substring(0, name.length - ".conf".length)
} else {
require(isZip) { context.getString(R.string.bad_extension_error) }
}
if (isZip) {
ZipInputStream(contentResolver.openInputStream(uri)).use { zip ->
val reader = BufferedReader(InputStreamReader(zip, StandardCharsets.UTF_8))
var entry: ZipEntry?
while (true) {
entry = zip.nextEntry ?: break
name = entry.name
idx = name.lastIndexOf('/')
if (idx >= 0) {
if (idx >= name.length - 1) {
continue
}
name = name.substring(name.lastIndexOf('/') + 1)
}
if (name.lowercase().endsWith(".conf")) {
name = name.substring(0, name.length - ".conf".length)
} else {
continue
}
try {
Config.parse(reader)
} catch (e: Throwable) {
throwables.add(e)
null
}?.let {
val nameCopy = name
futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(nameCopy, it) })
}
}
}
} else {
futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(name, Config.parse(contentResolver.openInputStream(uri)!!)) })
}
if (futureTunnels.isEmpty()) {
if (throwables.size == 1) {
throw throwables[0]
} else {
require(throwables.isNotEmpty()) { context.getString(R.string.no_configs_error) }
}
}
val tunnels = futureTunnels.mapNotNull {
try {
it.await()
} catch (e: Throwable) {
throwables.add(e)
null
}
}
withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(tunnels, throwables, messageCallback) }
} catch (e: Throwable) {
withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(emptyList(), listOf(e), messageCallback) }
}
}
fun importTunnel(parentFragmentManager: FragmentManager, configText: String, messageCallback: (CharSequence) -> Unit) {
try {
// Ensure the config text is parseable before proceeding…
Config.parse(ByteArrayInputStream(configText.toByteArray(StandardCharsets.UTF_8)))
// Config text is valid, now create the tunnel…
ConfigNamingDialogFragment.newInstance(configText).show(parentFragmentManager, null)
} catch (e: Throwable) {
onTunnelImportFinished(emptyList(), listOf<Throwable>(e), messageCallback)
}
}
private fun onTunnelImportFinished(tunnels: List<ObservableTunnel>, throwables: Collection<Throwable>, messageCallback: (CharSequence) -> Unit) {
val context = Application.get().applicationContext
var message = ""
for (throwable in throwables) {
val error = ErrorMessages[throwable]
message = context.getString(R.string.import_error, error)
Log.e(TAG, message, throwable)
}
if (tunnels.size == 1 && throwables.isEmpty())
message = context.getString(R.string.import_success, tunnels[0].name)
else if (tunnels.isEmpty() && throwables.size == 1)
else if (throwables.isEmpty())
message = context.resources.getQuantityString(
R.plurals.import_total_success,
tunnels.size, tunnels.size
)
else if (!throwables.isEmpty())
message = context.resources.getQuantityString(
R.plurals.import_partial_success,
tunnels.size + throwables.size,
tunnels.size, tunnels.size + throwables.size
)
messageCallback(message)
}
private const val TAG = "WireGuard/TunnelImporter"
}

View file

@ -0,0 +1,121 @@
/*
* Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.wireguard.android.Application
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
object UserKnobs {
private val ENABLE_KERNEL_MODULE = booleanPreferencesKey("enable_kernel_module")
val enableKernelModule: Flow<Boolean>
get() = Application.getPreferencesDataStore().data.map {
it[ENABLE_KERNEL_MODULE] ?: false
}
suspend fun setEnableKernelModule(enable: Boolean?) {
Application.getPreferencesDataStore().edit {
if (enable == null)
it.remove(ENABLE_KERNEL_MODULE)
else
it[ENABLE_KERNEL_MODULE] = enable
}
}
private val MULTIPLE_TUNNELS = booleanPreferencesKey("multiple_tunnels")
val multipleTunnels: Flow<Boolean>
get() = Application.getPreferencesDataStore().data.map {
it[MULTIPLE_TUNNELS] ?: false
}
private val DARK_THEME = booleanPreferencesKey("dark_theme")
val darkTheme: Flow<Boolean>
get() = Application.getPreferencesDataStore().data.map {
it[DARK_THEME] ?: false
}
suspend fun setDarkTheme(on: Boolean) {
Application.getPreferencesDataStore().edit {
it[DARK_THEME] = on
}
}
private val ALLOW_REMOTE_CONTROL_INTENTS = booleanPreferencesKey("allow_remote_control_intents")
val allowRemoteControlIntents: Flow<Boolean>
get() = Application.getPreferencesDataStore().data.map {
it[ALLOW_REMOTE_CONTROL_INTENTS] ?: false
}
private val RESTORE_ON_BOOT = booleanPreferencesKey("restore_on_boot")
val restoreOnBoot: Flow<Boolean>
get() = Application.getPreferencesDataStore().data.map {
it[RESTORE_ON_BOOT] ?: false
}
private val LAST_USED_TUNNEL = stringPreferencesKey("last_used_tunnel")
val lastUsedTunnel: Flow<String?>
get() = Application.getPreferencesDataStore().data.map {
it[LAST_USED_TUNNEL]
}
suspend fun setLastUsedTunnel(lastUsedTunnel: String?) {
Application.getPreferencesDataStore().edit {
if (lastUsedTunnel == null)
it.remove(LAST_USED_TUNNEL)
else
it[LAST_USED_TUNNEL] = lastUsedTunnel
}
}
private val RUNNING_TUNNELS = stringSetPreferencesKey("enabled_configs")
val runningTunnels: Flow<Set<String>>
get() = Application.getPreferencesDataStore().data.map {
it[RUNNING_TUNNELS] ?: emptySet()
}
suspend fun setRunningTunnels(runningTunnels: Set<String>) {
Application.getPreferencesDataStore().edit {
if (runningTunnels.isEmpty())
it.remove(RUNNING_TUNNELS)
else
it[RUNNING_TUNNELS] = runningTunnels
}
}
private val UPDATER_NEWER_VERSION_SEEN = stringPreferencesKey("updater_newer_version_seen")
val updaterNewerVersionSeen: Flow<String?>
get() = Application.getPreferencesDataStore().data.map {
it[UPDATER_NEWER_VERSION_SEEN]
}
suspend fun setUpdaterNewerVersionSeen(newerVersionSeen: String?) {
Application.getPreferencesDataStore().edit {
if (newerVersionSeen == null)
it.remove(UPDATER_NEWER_VERSION_SEEN)
else
it[UPDATER_NEWER_VERSION_SEEN] = newerVersionSeen
}
}
private val UPDATER_NEWER_VERSION_CONSENTED = stringPreferencesKey("updater_newer_version_consented")
val updaterNewerVersionConsented: Flow<String?>
get() = Application.getPreferencesDataStore().data.map {
it[UPDATER_NEWER_VERSION_CONSENTED]
}
suspend fun setUpdaterNewerVersionConsented(newerVersionConsented: String?) {
Application.getPreferencesDataStore().edit {
if (newerVersionConsented == null)
it.remove(UPDATER_NEWER_VERSION_CONSENTED)
else
it[UPDATER_NEWER_VERSION_CONSENTED] = newerVersionConsented
}
}
}

View file

@ -0,0 +1,86 @@
/*
* Copyright © 2017-2025 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()
}
}

View file

@ -0,0 +1,142 @@
/*
* Copyright © 2017-2025 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()
}
}

View file

@ -0,0 +1,294 @@
/*
* Copyright © 2017-2025 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")
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright © 2017-2025 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()
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright © 2017-2025 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)
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright © 2017-2025 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()
}
}

View file

@ -0,0 +1,175 @@
/*
* Copyright © 2018 The Android Open Source Project
* Copyright © 2017-2025 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
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
@Deprecated("Deprecated in API level 29")
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
}
}
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright © 2013 The Android Open Source Project
* Copyright © 2017-2025 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)
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright © 2017-2025 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)
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<set xmlns:android="http://schemas.android.com/apk/res/android">
<scale
android:duration="300"
android:fromXScale="1.0"
android:fromYScale="1.0"
android:interpolator="@android:anim/linear_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="0"
android:toYScale="0" />
</set>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<set xmlns:android="http://schemas.android.com/apk/res/android">
<scale
android:duration="300"
android:fromXScale="0"
android:fromYScale="0"
android:interpolator="@android:anim/linear_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="1.0"
android:toYScale="1.0" />
</set>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item app:state_isUp="true" app:state_isDeleting="false" android:color="?attr/colorPrimaryInverse" />
<item android:state_focused="true" app:state_isDeleting="true" android:color="?attr/colorErrorContainer" />
<item android:color="?attr/colorOnSurfaceInverse" />
</selector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,4L12,1L8,5l4,4L12,6c3.31,0 6,2.69 6,6 0,1.01 -0.25,1.97 -0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12c0,-4.42 -3.58,-8 -8,-8zM12,18c-3.31,0 -6,-2.69 -6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4 -4,-4v3z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6L6,2zM13,9L13,3.5L18.5,9L13,9z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4,4H10V10H4V4M20,4V10H14V4H20M14,15H16V13H14V11H16V13H18V11H20V13H18V15H20V18H18V20H16V18H13V20H11V16H14V15M16,15V18H18V15H16M4,20V14H10V20H4M6,6V8H8V6H6M16,6V8H18V6H16M6,16V18H8V16H6M4,11H6V13H4V11M9,11H13V15H11V13H9V11M11,6H13V10H11V6M2,2V6H0V2A2,2 0 0,1 2,0H6V2H2M22,0A2,2 0 0,1 24,2V6H22V2H18V0H22M2,18V22H6V24H2A2,2 0 0,1 0,22V18H2M22,22V18H24V22A2,2 0 0,1 22,24H18V22H22Z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M3 5L5 5 5 3C3.9 3 3 3.9 3 5Zm0 8l2 0 0 -2 -2 0 0 2zm4 8l2 0 0 -2 -2 0 0 2zM3 9L5 9 5 7 3 7 3 9Zm10 -6l-2 0 0 2 2 0 0 -2zm6 0l0 2 2 0C21 3.9 20.1 3 19 3ZM5 21L5 19 3 19c0 1.1 0.9 2 2 2zm-2 -4l2 0 0 -2 -2 0 0 2zM9 3L7 3 7 5 9 5 9 3Zm2 18l2 0 0 -2 -2 0 0 2zm8 -8l2 0 0 -2 -2 0 0 2zm0 8c1.1 0 2 -0.9 2 -2l-2 0 0 2zm0 -12l2 0 0 -2 -2 0 0 2zm0 8l2 0 0 -2 -2 0 0 2zm-4 4l2 0 0 -2 -2 0 0 2zm0 -16l2 0 0 -2 -2 0 0 2zM7 17L17 17 17 7 7 7 7 17Zm2 -8l6 0 0 6 -6 0 0 -6z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,11H7.83l4.88,-4.88c0.39,-0.39 0.39,-1.03 0,-1.42 -0.39,-0.39 -1.02,-0.39 -1.41,0l-6.59,6.59c-0.39,0.39 -0.39,1.02 0,1.41l6.59,6.59c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L7.83,13H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1z" />
</vector>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="2160"
android:viewportHeight="2160">
<group
android:scaleX="1"
android:scaleY="-1"
android:translateX="630"
android:translateY="1750">
<group>
<clip-path android:pathData="M0 1347.452l773.449 0L773.449 0 0 0Z" />
<group
android:translateX="349.0264"
android:translateY="572.616">
<path
android:fillColor="#ffffff"
android:pathData="M0 0c-12.169 -6.44 -21.541 -11.184 -30.71 -16.292 -37.523 -20.902 -69.603 -48.262 -95.162 -82.767 -8.264 -11.156 -13.945 -12.055 -26.528 -4.36 -163.692 100.101 -174.212 351.318 4.549 460.681 139.045 85.064 316.68 33.074 383.242 -94.85 12.614 -24.244 14.218 -61.567 6.228 -87 -27.582 -87.807 -92.71 -137.049 -182.1 -157.968 26.353 22.561 47.329 48.145 54.006 83.494 6.725 35.606 -0.388 67.807 -21.041 97.072 -31.371 44.451 -92.029 62.74 -142.721 43.492 -55.035 -20.896 -85.181 -71.123 -79.747 -132.863C-124.935 51.288 -81.419 14.12 0 0" />
</group>
<group android:translateY="285.9856">
<path
android:fillColor="#ffffff"
android:pathData="M0 0C13.148 88.712 117.033 170.407 204.881 161.087 177.673 124.291 165.104 82.664 162.071 41.145 132.88 35.769 105.368 32.152 78.66 25.373 52.364 18.698 26.882 8.816 0 0" />
</group>
<group
android:translateX="580.2814"
android:translateY="1243.915">
<path
android:fillColor="#ffffff"
android:pathData="M0 0C4.917 3.762 9.98 6.922 16.085 1.891 19.557 -0.97 22.93 -3.933 27.136 -7.523 21.915 -10.28 17.676 -12.599 13.355 -14.779 7.307 -17.83 2.785 -15.792 -0.877 -10.972 -3.847 -7.062 -4.384 -3.354 0 0m71.552 -730.934c-7.403 6.401 -12.094 6.399 -20.775 0.845 -29.454 -18.844 -59.602 -36.696 -90.239 -53.556 -17.562 -9.664 -36.584 -16.675 -58.61 -26.516 7.564 -1.952 11.203 -2.865 14.829 -3.83 82.337 -21.913 126.326 -94.196 106.841 -175.157 -17.329 -71.999 -90.422 -118.033 -161.255 -105.889 -59.053 10.125 -110.601 59.161 -119.21 117.917 -9.382 64.032 22.508 125.618 79.246 151.417 31.472 14.31 63.79 26.766 95.186 41.229 35.697 16.445 74.29 29.435 105.458 52.322 77.355 56.801 125.124 135.006 143.747 229.401 11.155 56.543 10.401 112.837 -15.467 166.524 -19.851 41.202 -52.429 71.133 -87.429 98.447 -36.018 28.108 -74.148 53.518 -110.002 81.821 -9.702 7.659 -16.252 20.865 -20.742 32.84 -1.903 5.075 4.287 18.838 8.426 19.581 21.985 3.946 44.45 5.978 66.818 6.823 25.82 0.974 51.713 0.148 77.571 -0.192 5.606 -0.073 13.217 0.653 16.439 -2.514 13.394 -13.167 23.897 -4.697 33.194 3.965 7.823 7.29 13.399 16.992 19.62 25.168 -3.775 0.555 -11.519 2.505 -19.304 2.689 -26.003 0.616 -52.035 0.221 -78.021 1.176 -4.63 0.17 -9.09 4.935 -13.629 7.579 4.776 1.898 9.537 5.399 14.33 5.444 44.849 0.421 89.703 0.25 134.594 0.25 0.052 23.336 -31.136 55.291 -58.846 63.95 -0.207 -3.158 -0.4 -6.097 -0.606 -9.233C106.184 0.913 79.16 1.426 54.61 14.481 48.141 17.921 43.912 25.57 38.647 31.279 32.019 38.467 26.58 47.709 18.5 52.399 1.934 62.014 -16.148 68.982 -33.431 77.4c-61.418 29.914 -126.278 28.862 -195.946 22.484 41.644 -9.693 79.255 -18.447 116.865 -27.202 -0.428 -2.286 -0.856 -4.573 -1.284 -6.86 -50.308 -6.74 -97.898 11.71 -147.101 18.545 17.831 -10.443 35.897 -20.152 54.561 -28.527 18.965 -8.51 38.52 -15.705 58.076 -23.58 -24.845 -21.229 -49.776 -25.887 -81.008 -18.751 -17.072 3.901 -35.132 5.972 -52.564 5.121 -18.006 -0.88 -36.141 -5.311 -52.491 -16.238 17.507 -8.875 33.643 -16.24 48.864 -25.165 6.277 -3.681 13.473 -9.93 15.223 -16.377 4.191 -15.44 5.402 -31.689 7.817 -47.623 -28.667 -3.247 -79.074 -32.4 -89.261 -51.373 44.059 -8.478 92.031 1.777 134.06 -26.617 -13.844 -10.477 -46.085 -23.507 -57.911 -32.457 14.621 -3.831 48.498 -1.955 61.751 -1.057 11.157 0.756 16.306 1.029 20.881 -2.735l129.701 -101.541c13.638 -10.994 68.719 -63.131 83.098 -95.903 12.241 -27.902 13.739 -51.638 13.736 -57.431 -0.01 -15.541 -1.917 -39.876 -12.605 -67.022 -4.488 -11.399 -17.658 -36.65 -44.826 -66.083 -42.107 -45.617 -96.27 -70.274 -155.501 -82.487 -137.722 -28.395 -252.153 -175.469 -219.85 -337.61 37.714 -189.296 246.646 -291.784 417.386 -201.739 110.359 58.201 168.871 171.751 153.193 295.356 -9.471 74.672 -43.252 135.578 -99.881 184.538" />
</group>
</group>
</group>
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="400.0"
android:viewportHeight="400.0">
<path
android:fillAlpha="1"
android:fillColor="#ffffff"
android:pathData="m197.7,0c-6.2,0.1 -12.5,0.5 -19,1.1 12.4,2.9 23.5,5.5 34.7,8.1 -0.1,0.7 -0.3,1.4 -0.4,2 -14.9,2 -29.1,-3.5 -43.7,-5.5 5.3,3.1 10.7,6 16.2,8.5 5.6,2.5 11.4,4.7 17.2,7 -7.4,6.3 -14.8,7.7 -24,5.6 -5.1,-1.2 -10.4,-1.8 -15.6,-1.5 -5.3,0.3 -10.7,1.6 -15.6,4.8 5.2,2.6 10,4.8 14.5,7.5 1.9,1.1 4,2.9 4.5,4.9 1.2,4.6 1.6,9.4 2.3,14.1 -8.5,1 -23.5,9.6 -26.5,15.3 13.1,2.5 27.3,-0.5 39.8,7.9 -4.1,3.1 -13.7,7 -17.2,9.6 4.3,1.1 14.4,0.6 18.3,0.3 3.3,-0.2 4.8,-0.3 6.2,0.8l38.5,30.1c4,3.3 20.4,18.7 24.7,28.5 3.6,8.3 4.1,15.3 4.1,17.1 -0,4.6 -0.6,11.8 -3.7,19.9 -1.3,3.4 -5.2,10.9 -13.3,19.6 -12.5,13.5 -28.6,20.9 -46.2,24.5 -40.9,8.4 -74.9,52.1 -65.3,100.2 11.2,56.2 73.2,86.6 123.9,59.9 32.8,-17.3 50.1,-51 45.5,-87.7 -2.8,-22.2 -12.8,-40.2 -29.7,-54.8 -2.2,-1.9 -3.6,-1.9 -6.2,-0.3 -8.7,5.6 -17.7,10.9 -26.8,15.9 -5.2,2.9 -10.9,5 -17.4,7.9 2.2,0.6 3.3,0.9 4.4,1.1 24.4,6.5 37.5,28 31.7,52 -5.1,21.4 -26.8,35 -47.9,31.4 -17.5,-3 -32.8,-17.6 -35.4,-35 -2.8,-19 6.7,-37.3 23.5,-44.9 9.3,-4.2 18.9,-7.9 28.3,-12.2 10.6,-4.9 22.1,-8.7 31.3,-15.5 23,-16.9 37.1,-40.1 42.7,-68.1 3.3,-16.8 3.1,-33.5 -4.6,-49.4 -5.9,-12.2 -15.6,-21.1 -26,-29.2 -10.7,-8.3 -22,-15.9 -32.7,-24.3 -2.9,-2.3 -4.8,-6.2 -6.2,-9.7 -0.6,-1.5 1.3,-5.6 2.5,-5.8 6.5,-1.2 13.2,-1.8 19.8,-2 7.7,-0.3 15.4,-0 23,0.1 1.7,0 3.9,-0.2 4.9,0.7 4,3.9 7.1,1.4 9.9,-1.2 2.3,-2.2 4,-5 5.8,-7.5 -1.1,-0.2 -3.4,-0.7 -5.7,-0.8 -7.7,-0.2 -15.4,-0.1 -23.2,-0.3 -1.4,-0.1 -2.7,-1.5 -4,-2.2 1.4,-0.6 2.8,-1.6 4.3,-1.6 13.3,-0.1 26.6,-0.1 40,-0.1 0,-6.9 -9.2,-16.4 -17.5,-19 -0.1,0.9 -0.1,1.8 -0.2,2.7 -8.2,0.2 -16.2,0 -23.5,-3.8 -1.9,-1 -3.2,-3.3 -4.7,-5 -2,-2.1 -3.6,-4.9 -6,-6.3 -4.9,-2.9 -10.3,-4.9 -15.4,-7.4C224.3,1.7 211.3,-0.1 197.7,0ZM249.6,29.4c0.6,-0 1.2,0.2 1.9,0.8 1,0.8 2,1.7 3.3,2.8 -1.5,0.8 -2.8,1.5 -4.1,2.2 -1.8,0.9 -3.1,0.3 -4.2,-1.1 -0.9,-1.2 -1,-2.3 0.3,-3.3 0.9,-0.7 1.8,-1.3 2.9,-1.3z"
android:strokeWidth="1.33333325"
android:strokeColor="#00000000" />
<path
android:fillAlpha="1"
android:fillColor="#ffffff"
android:pathData="m97.9,307.6c-7.8,2 -15.4,4.9 -23.4,7.5 3.9,-26.3 34.7,-50.6 60.8,-47.8 -8.1,10.9 -11.8,23.3 -12.7,35.6 -8.7,1.6 -16.8,2.7 -24.8,4.7"
android:strokeWidth="1.33333325"
android:strokeColor="#00000000" />
<path
android:fillAlpha="1"
android:fillColor="#ffffff"
android:pathData="m134.3,124c41.3,-25.3 94,-9.8 113.8,28.2 3.7,7.2 4.2,18.3 1.8,25.8 -8.2,26.1 -27.5,40.7 -54.1,46.9 7.8,-6.7 14.1,-14.3 16,-24.8 2,-10.6 -0.1,-20.1 -6.2,-28.8 -9.3,-13.2 -27.3,-18.6 -42.4,-12.9 -16.3,6.2 -25.3,21.1 -23.7,39.4 1.5,17 14.4,28.1 38.6,32.2 -3.6,1.9 -6.4,3.3 -9.1,4.8 -11.1,6.2 -20.7,14.3 -28.2,24.6 -2.5,3.3 -4.1,3.6 -7.9,1.3 -48.6,-29.7 -51.7,-104.3 1.4,-136.8"
android:strokeWidth="1.33333325"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item>
<selector>
<item
android:state_activated="true"
app:state_multiselected="true">
<color android:color="?attr/colorSurfaceVariant" />
</item>
<item android:state_activated="true">
<color android:color="?attr/colorControlHighlight" />
</item>
</selector>
</item>
<item android:drawable="?attr/selectableItemBackground" />
</layer-list>

View file

@ -0,0 +1,210 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="1934.5dp"
android:height="393.14dp"
android:viewportWidth="1934.5"
android:viewportHeight="393.14">
<path
android:fillColor="#88171a"
android:fillType="nonZero"
android:pathData="M0,0L1934.5,0L1934.5,393.14L0,393.14Z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="M432.69,266.47 L367.47,66.48l24.46,0l49.64,154.34 50.91,-154.34l22.28,0l50.72,153.98 49.81,-153.98L639.03,66.48L573.99,266.47L555.87,266.47L503.16,105.43 450.99,266.47Z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m634.56,111.4l24.64,0l0,155.07l-24.64,0z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m716.31,198.9l0,67.57L692.04,266.47L692.04,112.13l106.52,0c15.82,0 28.2,3.8 37.14,11.41 8.94,7.61 13.41,18.18 13.41,31.7 0.18,10.5 -3.6,20.69 -10.6,28.53 -7.06,8.03 -16.27,12.95 -27.63,14.77l42.93,67.93l-26.27,0l-43.84,-67.57zM716.31,176.25l82.24,0c8.33,0 14.7,-1.81 19.11,-5.43 4.41,-3.63 6.61,-8.82 6.61,-15.58 0,-6.77 -2.2,-11.93 -6.61,-15.49 -4.41,-3.56 -10.78,-5.34 -19.11,-5.34l-82.24,0z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="M871.98,266.47L871.98,111.77l145.1,0l0,22.64l-120.83,0l0,38.59l79.34,0l0,22.28l-79.34,0l0,48.91l127.53,0l0,22.28L871.97,266.47Z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m1194.46,212.31l0,-29.35l-54.53,0l0,-23.01l79.34,0l0,60.14c-9.66,16.67 -22.25,29.44 -37.77,38.31 -15.52,8.88 -33.13,13.32 -52.81,13.32 -29.95,0 -54.68,-9.87 -74.18,-29.62 -19.51,-19.75 -29.26,-44.78 -29.26,-75.09 0,-30.43 9.78,-55.52 29.34,-75.27 19.57,-19.75 44.27,-29.62 74.09,-29.62 18.48,0 35.32,4.05 50.54,12.13 15.2,8.08 28.01,20.01 37.13,34.6L1195.55,123.72c-6.13,-11.8 -15.58,-21.56 -27.17,-28.08 -12.09,-6.83 -25.79,-10.33 -39.67,-10.15 -22.46,0 -41.06,7.7 -55.8,23.1 -14.73,15.4 -22.1,34.87 -22.1,58.42 0,23.55 7.37,42.99 22.1,58.33 14.73,15.34 33.33,23.01 55.8,23.01 14.01,0 26.48,-3.02 37.41,-9.06 10.93,-6.04 20.38,-15.03 28.35,-26.99z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m1246.36,112.14l24.28,0l0,99.09c0,14.85 3.5,24.87 10.5,30.07 7.01,5.2 20.89,7.79 41.67,7.79 20.89,0 34.84,-2.59 41.84,-7.79 7.01,-5.19 10.51,-15.21 10.51,-30.07L1375.16,112.14l24.09,0l0,105.43c0,18.96 -6.01,32.67 -18.02,41.13 -12.02,8.45 -31.61,12.68 -58.79,12.68 -27.05,0 -46.49,-4.17 -58.33,-12.5 -11.84,-8.33 -17.75,-22.1 -17.75,-41.3z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m1395.46,266.47 l78.62,-154.7l15.22,0l79.34,154.7l-25.9,0l-20.11,-39.31l-81.7,0l-19.93,39.31zM1451.44,206.69l60.51,0l-30.07,-59.23z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m1595.56,198.9l0,67.57l-24.28,0L1571.29,112.13l106.52,0c15.82,0 28.2,3.8 37.14,11.41 8.94,7.61 13.41,18.18 13.41,31.7 0.18,10.5 -3.6,20.69 -10.6,28.53 -7.06,8.03 -16.27,12.95 -27.62,14.77l42.93,67.93l-26.27,0l-43.84,-67.57zM1595.56,176.25l82.24,0c8.33,0 14.7,-1.81 19.11,-5.43 4.41,-3.63 6.61,-8.82 6.61,-15.58 0,-6.77 -2.2,-11.93 -6.61,-15.49 -4.41,-3.56 -10.78,-5.34 -19.11,-5.34l-82.24,0z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m1820.26,111.77c25.24,0 45.59,7.21 61.05,21.65 15.46,14.43 23.19,33.18 23.19,56.25 0,23.31 -7.58,41.94 -22.73,55.89 -15.16,13.95 -35.66,20.92 -61.5,20.92l-68.66,0L1751.6,111.78l68.66,0zM1820.63,134.05l-44.75,0l0,110.14l44.75,0c18.35,0 32.79,-4.92 43.29,-14.77 10.51,-9.84 15.76,-23.21 15.76,-40.12 0,-16.3 -5.43,-29.59 -16.3,-39.85 -10.87,-10.26 -25.12,-15.4 -42.75,-15.4l0,0z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m429.67,318.63l0,18.61l-6.35,0l0,-40.45l37.37,0l0,5.92l-31.02,0l0,10.08l20.04,0l0,5.83z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m455.24,337.24 l20.56,-40.45l3.98,0l20.75,40.45l-6.78,0l-5.26,-10.28l-21.36,0l-5.21,10.28zM469.87,321.61l15.82,0l-7.86,-15.49z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m505.02,332.08 l3.08,-5.3c2.81,1.87 5.87,3.34 9.09,4.35 3.04,1.01 6.22,1.53 9.43,1.57 3.06,0.14 6.09,-0.62 8.71,-2.2 2.03,-1.16 3.3,-3.32 3.32,-5.66 0.08,-2.16 -1.1,-4.17 -3.03,-5.14 -2.03,-1.12 -5.21,-1.75 -9.57,-1.87 -7.36,-0.09 -12.41,-0.95 -15.16,-2.56 -2.75,-1.61 -4.13,-4.28 -4.13,-8.01 -0.04,-3.39 1.67,-6.56 4.53,-8.38 3.01,-2.12 7.06,-3.18 12.15,-3.18 3.43,-0.01 6.83,0.49 10.11,1.47 3.33,1.01 6.52,2.48 9.45,4.36l-3.17,5.26c-2.52,-1.7 -5.25,-3.03 -8.15,-3.95 -2.69,-0.9 -5.5,-1.37 -8.34,-1.4 -2.56,-0.11 -5.1,0.44 -7.39,1.61 -1.66,0.76 -2.75,2.39 -2.8,4.21 -0.03,1.77 1.13,3.33 2.82,3.84 1.88,0.76 5.14,1.14 9.78,1.14 6.44,0 11.31,1.09 14.59,3.27 3.29,2.18 4.93,5.42 4.93,9.71 0.01,3.8 -1.86,7.37 -5,9.52 -3.33,2.53 -7.65,3.79 -12.95,3.79 -3.95,-0.01 -7.88,-0.57 -11.67,-1.68 -3.75,-1.07 -7.34,-2.67 -10.63,-4.76z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m568.06,337.24l0,-34.81l-17.05,0l0,-5.63l40.45,0l0,5.63L574.36,302.43l0,34.81z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m604.52,345.48c0.91,-1.32 1.73,-2.7 2.46,-4.12 0.68,-1.32 1.25,-2.7 1.7,-4.12l-0.38,0c-0.96,0.01 -1.86,-0.44 -2.44,-1.21 -0.67,-0.87 -1.01,-1.95 -0.97,-3.06 -0.05,-1.22 0.36,-2.41 1.16,-3.34 0.73,-0.84 1.8,-1.32 2.92,-1.3 1.3,-0.02 2.52,0.64 3.22,1.73 0.89,1.39 1.32,3.02 1.23,4.67 -0.09,2.03 -0.71,3.99 -1.8,5.71 -1.5,2.45 -3.3,4.7 -5.35,6.7z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m668.13,337.24l0,-40.45l5.25,0l16.58,23.68 16.58,-23.68l5.26,0l0,40.45l-6.3,0l0,-29.08l-15.54,22.49 -15.49,-22.49l0,29.08z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m748.83,338.52c-7.2,0 -13.12,-2.04 -17.76,-6.11 -4.42,-3.87 -6.96,-9.46 -6.96,-15.33 -0.01,-5.87 2.52,-11.47 6.94,-15.34 4.63,-4.02 10.55,-6.04 17.78,-6.04 7.3,0 13.25,2.01 17.88,6.02 4.42,3.89 6.95,9.48 6.94,15.36 -0.01,5.88 -2.54,11.47 -6.96,15.35 -4.64,4.06 -10.59,6.09 -17.85,6.09zM748.83,332.6c5.4,0 9.81,-1.46 13.22,-4.38 3.25,-2.79 5.12,-6.86 5.11,-11.15 -0.01,-4.29 -1.89,-8.35 -5.14,-11.14 -3.43,-2.94 -7.82,-4.4 -13.19,-4.4 -5.36,0 -9.75,1.46 -13.14,4.38 -3.23,2.8 -5.09,6.87 -5.09,11.15 0,4.28 1.86,8.35 5.09,11.16 3.39,2.92 7.78,4.38 13.14,4.38z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m803.92,296.79c6.6,0 11.92,1.89 15.96,5.66 4.04,3.78 6.06,8.68 6.06,14.71 0,6.09 -1.98,10.96 -5.94,14.61 -3.96,3.65 -9.32,5.47 -16.08,5.47l-17.95,0l0,-40.45zM804.02,302.61l-11.7,0l0,28.8l11.7,0c4.8,0 8.57,-1.29 11.32,-3.86 2.75,-2.57 4.13,-6.07 4.13,-10.49 0.13,-3.93 -1.42,-7.72 -4.27,-10.42 -2.84,-2.68 -6.57,-4.03 -11.18,-4.03z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m837.87,337.24l0,-40.45l37.94,0l0,5.92l-31.59,0l0,10.09l20.75,0l0,5.82l-20.75,0l0,12.79l33.34,0l0,5.82z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m894.94,319.57l0,17.67l-6.35,0L888.59,296.88l27.85,0c4.14,0 7.37,0.99 9.71,2.98 2.38,2.08 3.67,5.14 3.51,8.29 0.05,2.75 -0.94,5.41 -2.77,7.46 -1.86,2.11 -4.43,3.48 -7.22,3.86l11.22,17.76l-6.87,0l-11.46,-17.67zM894.94,313.65l21.5,0c1.78,0.12 3.55,-0.39 5,-1.42 1.18,-1.01 1.82,-2.52 1.72,-4.07 0.1,-1.55 -0.54,-3.05 -1.72,-4.05 -1.46,-1.02 -3.22,-1.52 -5,-1.4l-21.5,0z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m943.11,337.24l0,-40.45l3.32,0l25.72,28.42l0,-28.42l6.2,0l0,40.45l-3.32,0l-25.67,-28.32l0,28.32z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m999,345.48c0.91,-1.32 1.73,-2.7 2.46,-4.12 0.68,-1.32 1.25,-2.7 1.7,-4.12l-0.37,0c-0.96,0.01 -1.87,-0.44 -2.44,-1.21 -0.67,-0.87 -1.01,-1.95 -0.97,-3.06 -0.05,-1.22 0.37,-2.41 1.16,-3.34 0.72,-0.84 1.8,-1.32 2.91,-1.3 1.3,-0.02 2.52,0.64 3.22,1.73 0.89,1.39 1.32,3.02 1.23,4.67 -0.09,2.03 -0.71,3.99 -1.8,5.71 -1.5,2.45 -3.3,4.7 -5.35,6.7z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1059.16,332.08 l3.08,-5.3c2.81,1.87 5.87,3.34 9.09,4.35 3.04,1.01 6.22,1.53 9.43,1.57 3.06,0.14 6.09,-0.62 8.71,-2.2 2.03,-1.16 3.3,-3.32 3.31,-5.66 0.09,-2.16 -1.1,-4.17 -3.03,-5.14 -2.02,-1.12 -5.21,-1.75 -9.57,-1.87 -7.35,-0.09 -12.41,-0.95 -15.15,-2.56 -2.75,-1.61 -4.12,-4.28 -4.12,-8.01 -0.04,-3.39 1.67,-6.56 4.53,-8.38 3.02,-2.12 7.06,-3.18 12.15,-3.18 3.43,-0.01 6.83,0.49 10.11,1.47 3.33,1.01 6.52,2.48 9.45,4.36l-3.17,5.26c-2.51,-1.7 -5.25,-3.03 -8.15,-3.95 -2.69,-0.9 -5.51,-1.37 -8.34,-1.4 -2.56,-0.11 -5.1,0.44 -7.39,1.61 -1.66,0.76 -2.74,2.39 -2.8,4.21 -0.03,1.77 1.13,3.33 2.82,3.84 1.88,0.76 5.14,1.14 9.78,1.14 6.44,0 11.3,1.09 14.59,3.27 3.28,2.18 4.93,5.41 4.93,9.71 0.01,3.8 -1.86,7.37 -5,9.52 -3.33,2.53 -7.65,3.79 -12.95,3.79 -3.95,-0.01 -7.88,-0.57 -11.68,-1.68 -3.75,-1.07 -7.33,-2.67 -10.63,-4.76z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1112.56,337.24l0,-40.45l37.94,0l0,5.92L1118.91,302.71l0,10.09l20.74,0l0,5.82L1118.91,318.63l0,12.79l33.34,0l0,5.82z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1206.06,327.34c-2.21,3.55 -5.35,6.42 -9.07,8.31 -3.96,1.96 -8.34,2.94 -12.77,2.87 -7.1,0 -12.95,-2.01 -17.55,-6.04 -4.38,-3.89 -6.89,-9.48 -6.89,-15.34 0,-5.87 2.51,-11.45 6.89,-15.35 4.59,-4.06 10.44,-6.08 17.55,-6.08 4.15,-0.06 8.25,0.79 12.03,2.49 3.47,1.55 6.47,3.97 8.71,7.04l-5.02,3.51c-1.7,-2.33 -3.97,-4.16 -6.6,-5.33 -2.87,-1.28 -5.98,-1.92 -9.12,-1.87 -4.69,-0.17 -9.27,1.41 -12.86,4.43 -3.23,2.78 -5.09,6.84 -5.09,11.11 0.01,4.27 1.87,8.32 5.11,11.1 3.58,3.02 8.16,4.59 12.83,4.43 3.3,0.07 6.57,-0.66 9.52,-2.13 2.93,-1.58 5.41,-3.86 7.24,-6.64z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1218.66,296.89l6.34,0l0,25.91c0,3.89 0.92,6.51 2.75,7.86 1.83,1.36 5.46,2.04 10.89,2.04 5.46,0 9.11,-0.68 10.94,-2.04 1.83,-1.36 2.75,-3.98 2.75,-7.86l0,-25.91l6.3,0l0,27.56c0,4.96 -1.57,8.54 -4.71,10.75 -3.14,2.21 -8.27,3.31 -15.37,3.31 -7.07,0 -12.16,-1.09 -15.25,-3.27 -3.09,-2.18 -4.64,-5.78 -4.64,-10.8z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1280.86,319.57l0,17.67l-6.34,0L1274.52,296.88l27.85,0c4.14,0 7.37,0.99 9.71,2.98 2.38,2.08 3.67,5.14 3.51,8.29 0.05,2.75 -0.94,5.41 -2.77,7.46 -1.86,2.11 -4.43,3.48 -7.22,3.86l11.22,17.76l-6.86,0l-11.46,-17.67zM1280.86,313.65l21.5,0c1.78,0.12 3.55,-0.39 5,-1.42 1.18,-1.01 1.82,-2.52 1.73,-4.07 0.1,-1.55 -0.55,-3.05 -1.73,-4.05 -1.46,-1.02 -3.22,-1.52 -5,-1.4l-21.5,0z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1328.96,337.24l0,-40.45l37.94,0l0,5.92l-31.59,0l0,10.09l20.75,0l0,5.82l-20.75,0l0,12.79l33.34,0l0,5.82z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1431.46,337.24l-3.27,0l-20.32,-40.45l6.72,0l15.2,30.27 14.97,-30.27l6.96,0z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1468.06,319.57l0,17.67l-6.35,0L1461.71,296.79l29.41,0c4.17,0 7.42,0.99 9.73,2.96 2.37,2.09 3.66,5.15 3.48,8.31 0.1,3.13 -1.16,6.16 -3.46,8.29 -2.49,2.22 -5.76,3.37 -9.09,3.22zM1468.06,313.65l23.06,0c1.77,0.11 3.51,-0.39 4.95,-1.42 2.28,-2.21 2.31,-5.85 0.07,-8.1 -1.35,-1.03 -3.04,-1.53 -4.73,-1.42l-23.35,0z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1515.26,337.24l0,-40.45l3.32,0l25.71,28.42l0,-28.42l6.21,0l0,40.45l-3.32,0l-25.67,-28.32l0,28.32z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1610.06,337.24l0,-34.81l-17.05,0l0,-5.63l40.45,0l0,5.63l-17.1,0l0,34.81z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1641.76,296.89l6.35,0l0,25.91c0,3.89 0.91,6.51 2.75,7.86 1.83,1.36 5.46,2.04 10.89,2.04 5.46,0 9.11,-0.68 10.94,-2.04 1.83,-1.36 2.75,-3.98 2.75,-7.86l0,-25.91l6.3,0l0,27.56c0,4.96 -1.57,8.54 -4.71,10.75 -3.14,2.21 -8.27,3.31 -15.37,3.31 -7.07,0 -12.16,-1.09 -15.25,-3.27 -3.09,-2.18 -4.64,-5.78 -4.64,-10.8z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1697.66,337.24l0,-40.45l3.31,0l25.72,28.42l0,-28.42l6.21,0l0,40.45l-3.32,0l-25.67,-28.32l0,28.32z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1748.76,337.24l0,-40.45l3.32,0l25.71,28.42L1777.79,296.79L1784,296.79l0,40.45l-3.32,0l-25.67,-28.32l0,28.32z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1799.76,337.24l0,-40.45l37.94,0l0,5.92l-31.59,0l0,10.09l20.75,0l0,5.82l-20.75,0l0,12.79l33.34,0l0,5.82z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1850.46,337.24l0,-40.45l6.35,0l0,34.62l30.03,0l0,5.82z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="M329.74,185.56C329.74,185.56 336.68,40 176.7,40 35.22,40 30.8,179.63 30.8,179.63 30.8,179.63 9.99,340 179.96,340 342.98,340 329.74,185.56 329.74,185.56ZM131.94,134.7c30.02,-18.36 68.36,-7.14 82.73,20.47 2.72,5.23 3.07,13.29 1.34,18.78 -5.95,18.96 -20.02,29.59 -39.31,34.1 5.69,-4.87 10.22,-10.4 11.66,-18.03 1.52,-7.31 -0.13,-14.93 -4.55,-20.95 -7.03,-9.65 -19.59,-13.49 -30.81,-9.39 -11.89,4.51 -18.39,15.35 -17.22,28.68 1.09,12.38 10.48,20.41 28.06,23.45 -2.63,1.39 -4.65,2.42 -6.63,3.52 -8.05,4.41 -15.06,10.51 -20.54,17.87 -1.79,2.41 -3.01,2.6 -5.73,0.94 -35.34,-21.61 -37.61,-75.84 0.98,-99.45zM105.49,268.23c-5.68,1.44 -11.18,3.57 -16.98,5.47 2.84,-19.15 25.27,-36.79 44.23,-34.78 -5.49,7.57 -8.7,16.56 -9.24,25.9 -6.3,1.16 -12.25,1.94 -18.01,3.4zM226.28,81.25c5.61,0.21 11.23,0.12 16.84,0.25 1.4,0.09 2.8,0.29 4.17,0.58 -1.26,1.93 -2.67,3.75 -4.23,5.43 -2.01,1.87 -4.28,3.7 -7.17,0.85 -0.69,-0.68 -2.34,-0.53 -3.55,-0.54 -5.58,-0.07 -11.17,-0.25 -16.75,-0.04 -4.84,0.16 -9.66,0.65 -14.43,1.47 -0.9,0.16 -2.23,3.13 -1.82,4.22 0.97,2.59 2.38,5.44 4.47,7.09 7.75,6.11 15.97,11.59 23.75,17.66 7.56,5.9 14.59,12.36 18.87,21.25 5.58,11.59 5.74,23.74 3.34,35.95 -4.02,20.38 -14.33,37.26 -31.03,49.53 -6.73,4.94 -15.06,7.74 -22.77,11.29 -6.78,3.13 -13.75,5.81 -20.55,8.9 -12.25,5.57 -19.13,18.87 -17.1,32.69 1.85,12.69 12.98,23.27 25.73,25.46 15.29,2.62 31.07,-7.32 34.81,-22.86 4.2,-17.48 -5.29,-33.08 -23.07,-37.81 -0.78,-0.21 -1.57,-0.41 -3.2,-0.83 4.75,-2.12 8.86,-3.64 12.66,-5.72 6.61,-3.64 13.11,-7.49 19.48,-11.56 1.88,-1.2 2.89,-1.2 4.49,0.18 12.23,10.57 19.52,23.72 21.56,39.84 3.39,26.68 -9.25,51.2 -33.07,63.76 -36.87,19.44 -81.97,-2.68 -90.11,-43.55 -6.97,-35 17.73,-66.75 47.46,-72.88 12.79,-2.63 24.48,-7.96 33.57,-17.81 5.87,-6.35 8.71,-11.81 9.68,-14.27 1.81,-4.61 2.73,-9.52 2.72,-14.47 -0.2,-4.29 -1.2,-8.49 -2.96,-12.4 -3.1,-7.07 -15,-18.33 -17.94,-20.7l-28,-21.92c-0.98,-0.81 -2.1,-0.75 -4.51,-0.59 -2.86,0.19 -10.18,0.6 -13.33,-0.23 2.55,-1.93 9.52,-4.74 12.51,-7.01 -9.08,-6.13 -19.43,-3.92 -28.94,-5.75 2.2,-4.09 13.08,-10.39 19.27,-11.09 -0.37,-3.46 -0.93,-6.89 -1.69,-10.28 -0.38,-1.39 -1.93,-2.74 -3.29,-3.54 -3.29,-1.93 -6.77,-3.52 -10.55,-5.43 3.39,-2.19 7.31,-3.4 11.33,-3.51 3.82,-0.15 7.63,0.23 11.35,1.1 6.74,1.54 12.12,0.54 17.49,-4.05 -4.22,-1.7 -8.45,-3.25 -12.54,-5.09 -4.03,-1.84 -7.96,-3.9 -11.78,-6.16 10.62,1.48 20.89,5.46 31.75,4 0.09,-0.49 0.19,-0.99 0.28,-1.48 -8.12,-1.89 -16.24,-3.78 -25.23,-5.87 15.04,-1.37 29.04,-1.6 42.3,4.85 3.73,1.82 7.63,3.32 11.21,5.4 1.74,1.02 2.92,3.01 4.35,4.56 1.13,1.23 2.05,2.88 3.44,3.63 5.3,2.82 11.13,2.93 17.08,2.79 0.05,-0.68 0.09,-1.31 0.13,-1.99 5.98,1.87 12.72,8.77 12.71,13.8 -9.69,0 -19.37,-0.04 -29.06,0.06 -1.04,0.01 -2.06,0.77 -3.09,1.17 0.98,0.57 1.94,1.6 2.94,1.64z"
android:strokeColor="#00000000" />
<path
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m213.78,66.91c-0.84,0.53 -0.94,1.71 -0.19,2.37 0.61,1.08 1.99,1.45 3.07,0.82 0.94,-0.47 1.85,-0.97 2.98,-1.57 -0.91,-0.78 -1.64,-1.42 -2.39,-2.03 -1.32,-1.09 -2.41,-0.41 -3.47,0.41z"
android:strokeColor="#00000000" />
<path
android:fillColor="#d1d1d1"
android:fillType="nonZero"
android:pathData="m1890,97.71l-1.24,0c-7.56,0.08 -11.84,6.64 -11.84,13 0,6.16 3.92,12.32 12.2,12.32l0.56,0c8.6,-0.24 12.36,-6.16 12.36,-12.24l0,-0.72c-0.28,-6.36 -4.84,-12.36 -12.04,-12.36zM1884.48,102.87l0,14.44l1.8,0l0,-12.96l3.4,0c1.88,0 2.8,1.16 2.8,2.32 0,1.4 -1.36,3 -3.8,3 -0.37,0 -0.84,-0.08 -1.28,-0.16l5.44,7.8l2.36,0l-4.92,-6.64c2.4,-0.44 4,-2.36 4,-4.24 0,-1.76 -1.52,-3.56 -4.88,-3.56zM1881.04,103.16c2.52,-2.72 5.16,-3.72 8.24,-3.72l1.28,0c5.48,0.24 9.44,5.8 9.44,11.16 0,0.76 -0.08,1.64 -0.24,2.36 -0.84,4.92 -5.12,8.36 -10.08,8.36 -0.12,0 -0.37,0.04 -0.49,0.04 -6.28,0 -10.28,-5.68 -10.28,-11.36 0,-0.48 0.08,-1.12 0.12,-1.64 0,0 0.28,-3.12 2,-5.2z"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_activity_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".activity.MainActivity">
<LinearLayout
android:id="@+id/master_detail_wrapper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:baselineAligned="false"
android:divider="?attr/dividerHorizontal"
android:orientation="horizontal"
android:showDividers="middle">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/list_fragment"
android:name="com.wireguard.android.fragment.TunnelListFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2"
android:tag="LIST" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/detail_container"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="@dimen/bottom_sheet_top_padding">
<com.google.android.material.button.MaterialButton
android:id="@+id/create_from_file"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_sheet_item_height"
android:layout_marginStart="@dimen/normal_margin"
android:layout_marginLeft="@dimen/normal_margin"
android:layout_marginEnd="@dimen/normal_margin"
android:layout_marginRight="@dimen/normal_margin"
android:nextFocusDown="@id/create_from_qrcode"
android:nextFocusForward="@id/create_from_qrcode"
android:text="@string/create_from_file"
android:textAlignment="viewStart"
android:textColor="?attr/colorOnSurface"
app:icon="@drawable/ic_action_open"
app:iconPadding="@dimen/bottom_sheet_icon_padding"
app:iconTint="?attr/colorSecondary"
app:layout_constraintBottom_toTopOf="@+id/create_from_qrcode"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="parent"
app:rippleColor="?attr/colorSecondary" />
<com.google.android.material.button.MaterialButton
android:id="@+id/create_from_qrcode"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_sheet_item_height"
android:layout_marginStart="@dimen/normal_margin"
android:layout_marginLeft="@dimen/normal_margin"
android:layout_marginEnd="@dimen/normal_margin"
android:layout_marginRight="@dimen/normal_margin"
android:nextFocusUp="@id/create_from_file"
android:nextFocusDown="@id/create_empty"
android:nextFocusForward="@id/create_empty"
android:text="@string/create_from_qr_code"
android:textAlignment="viewStart"
android:textColor="?attr/colorOnSurface"
app:icon="@drawable/ic_action_scan_qr_code"
app:iconPadding="@dimen/bottom_sheet_icon_padding"
app:iconTint="?attr/colorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/create_empty"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/create_from_file"
app:rippleColor="?attr/colorSecondary" />
<com.google.android.material.button.MaterialButton
android:id="@+id/create_empty"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_sheet_item_height"
android:layout_marginStart="@dimen/normal_margin"
android:layout_marginLeft="@dimen/normal_margin"
android:layout_marginEnd="@dimen/normal_margin"
android:layout_marginRight="@dimen/normal_margin"
android:nextFocusUp="@id/create_from_qrcode"
android:text="@string/create_empty"
android:textAlignment="viewStart"
android:textColor="?attr/colorOnSurface"
app:icon="@drawable/ic_action_edit"
app:iconPadding="@dimen/bottom_sheet_icon_padding"
app:iconTint="?attr/colorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/create_from_qrcode"
app:rippleColor="?attr/colorSecondary" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<import type="com.wireguard.android.model.ApplicationData" />
<variable
name="fragment"
type="com.wireguard.android.fragment.AppListDialogFragment" />
<variable
name="appData"
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, ApplicationData&gt;" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
style="@style/Widget.Material3.TabLayout.OnSurface"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/exclude_from_tunnel" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/include_in_tunnel" />
</com.google.android.material.tabs.TabLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minHeight="200dp">
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="@{appData.isEmpty() ? View.VISIBLE : View.GONE}"
tools:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/app_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:items="@{appData}"
app:layout="@{@layout/app_list_item}"
tools:itemCount="10"
tools:listitem="@layout/app_list_item" />
</FrameLayout>
</LinearLayout>
</layout>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="com.wireguard.android.model.ApplicationData" />
<variable
name="collection"
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, com.wireguard.android.model.ApplicationData&gt;" />
<variable
name="key"
type="String" />
<variable
name="item"
type="com.wireguard.android.model.ApplicationData" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/list_item_background"
android:gravity="center_vertical"
android:onClick="@{(view) -> item.setSelected(!item.selected)}"
android:orientation="horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:id="@+id/app_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="16dp"
android:src="@{item.icon}"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/app_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@{key}"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:text="@tools:sample/full_names" />
<CheckBox
android:id="@+id/selected_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="@={item.selected}"
tools:checked="true" />
</LinearLayout>
</layout>

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="com.wireguard.android.widget.NameInputFilter" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tunnel_name_text_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/tunnel_name_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/tunnel_name"
android:imeOptions="actionDone"
android:inputType="textNoSuggestions|textVisiblePassword"
app:filter="@{NameInputFilter.newInstance()}">
<requestFocus />
</com.google.android.material.textfield.TextInputEditText>
</com.google.android.material.textfield.TextInputLayout>
</FrameLayout>
</layout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:itemCount="20"
tools:listitem="@layout/log_viewer_entry" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/share_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
app:srcCompat="@drawable/ic_action_share_white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="6dp">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/log_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Fri Mar 13 10:17:37 GMT+05:30 2020" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/log_msg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintTop_toBottomOf="@id/log_date"
tools:text="FATAL EXCEPTION: Thread-2" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_activity_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".activity.MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/list_detail_container"
android:name="com.wireguard.android.fragment.TunnelListFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:tag="LIST" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_activity_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".activity.TunnelCreatorActivity">
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/editor_fragment"
android:name="com.wireguard.android.fragment.TunnelEditorFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,324 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="com.wireguard.android.backend.Tunnel.State" />
<import type="com.wireguard.android.util.ClipboardUtils" />
<variable
name="fragment"
type="com.wireguard.android.fragment.TunnelDetailFragment" />
<variable
name="tunnel"
type="com.wireguard.android.model.ObservableTunnel" />
<variable
name="config"
type="com.wireguard.config.Config" />
</data>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clickable="true"
android:focusable="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.card.MaterialCardView
android:id="@+id/tunnel_detail_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/interface_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/interface_title"
android:textAppearance="?attr/textAppearanceTitleMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.wireguard.android.widget.ToggleSwitch
android:id="@+id/tunnel_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:nextFocusDown="@id/interface_name_text"
android:nextFocusForward="@id/interface_name_text"
app:checked="@{tunnel.state == State.UP}"
app:layout_constraintBaseline_toBottomOf="@+id/interface_title"
app:layout_constraintEnd_toEndOf="parent"
app:onBeforeCheckedChanged="@{fragment::setTunnelState}" />
<TextView
android:id="@+id/interface_name_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/interface_name_text"
android:text="@string/name"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/interface_title" />
<TextView
android:id="@+id/interface_name_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/name"
android:nextFocusUp="@id/tunnel_switch"
android:nextFocusDown="@id/public_key_text"
android:nextFocusForward="@id/public_key_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{tunnel.name}"
android:textAppearance="?attr/textAppearanceBodyLarge"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/interface_name_label"
tools:text="wg0" />
<TextView
android:id="@+id/public_key_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/public_key_text"
android:text="@string/public_key"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/interface_name_text" />
<TextView
android:id="@+id/public_key_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/public_key"
android:ellipsize="end"
android:maxLines="1"
android:nextFocusUp="@id/interface_name_text"
android:nextFocusDown="@id/addresses_text"
android:nextFocusForward="@id/addresses_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:singleLine="true"
android:text="@{config.interface.keyPair.publicKey.toBase64}"
android:textAppearance="?attr/textAppearanceBodyLarge"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/public_key_label"
tools:text="wOs2eguFEohqIZxlSJ1CAT9584tc6ejj9hfGFsoBVkA=" />
<TextView
android:id="@+id/addresses_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/addresses_text"
android:text="@string/addresses"
android:visibility="@{config.interface.addresses.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/public_key_text" />
<TextView
android:id="@+id/addresses_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/addresses"
android:nextFocusUp="@id/public_key_text"
android:nextFocusDown="@id/dns_servers_text"
android:nextFocusForward="@id/dns_servers_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{config.interface.addresses}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{config.interface.addresses.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/addresses_label"
tools:text="fc00:bbbb:bbbb:bb11::3:368b/128" />
<TextView
android:id="@+id/dns_servers_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/dns_servers_text"
android:text="@string/dns_servers"
android:visibility="@{config.interface.dnsServers.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/addresses_text" />
<TextView
android:id="@+id/dns_servers_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/dns_servers"
android:nextFocusUp="@id/addresses_text"
android:nextFocusDown="@id/dns_search_domains_text"
android:nextFocusForward="@id/dns_search_domains_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{config.interface.dnsServers}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{config.interface.dnsServers.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dns_servers_label"
tools:text="8.8.8.8, 8.8.4.4" />
<TextView
android:id="@+id/dns_search_domains_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/dns_search_domain_text"
android:text="@string/dns_search_domains"
android:visibility="@{config.interface.dnsSearchDomains.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/dns_servers_text" />
<TextView
android:id="@+id/dns_search_domains_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/dns_search_domains"
android:nextFocusUp="@id/dns_servers_text"
android:nextFocusDown="@id/listen_port_text"
android:nextFocusForward="@id/listen_port_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{config.interface.dnsSearchDomains}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{config.interface.dnsSearchDomains.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dns_search_domains_label"
tools:text="zx2c4.com" />
<TextView
android:id="@+id/listen_port_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/listen_port_text"
android:text="@string/listen_port"
android:visibility="@{!config.interface.listenPort.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintEnd_toStartOf="@id/mtu_label"
app:layout_constraintHorizontal_weight="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/dns_search_domains_text" />
<TextView
android:id="@+id/listen_port_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:contentDescription="@string/listen_port"
android:nextFocusRight="@id/mtu_text"
android:nextFocusUp="@id/dns_search_domains_text"
android:nextFocusDown="@id/applications_text"
android:nextFocusForward="@id/mtu_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{config.interface.listenPort}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{!config.interface.listenPort.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintEnd_toStartOf="@id/mtu_label"
app:layout_constraintHorizontal_weight="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/listen_port_label"
tools:text="51820" />
<TextView
android:id="@+id/mtu_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/mtu_text"
android:text="@string/mtu"
android:visibility="@{!config.interface.mtu.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_weight="0.5"
app:layout_constraintLeft_toRightOf="@id/listen_port_label"
app:layout_constraintStart_toEndOf="@id/listen_port_label"
app:layout_constraintTop_toBottomOf="@id/dns_search_domains_text" />
<TextView
android:id="@+id/mtu_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:contentDescription="@string/mtu"
android:nextFocusLeft="@id/listen_port_text"
android:nextFocusUp="@id/dns_servers_text"
android:nextFocusForward="@id/applications_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{config.interface.mtu}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{!config.interface.mtu.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_weight="0.5"
app:layout_constraintStart_toEndOf="@id/listen_port_label"
app:layout_constraintStart_toStartOf="@+id/mtu_label"
app:layout_constraintTop_toBottomOf="@+id/mtu_label"
tools:text="1500" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/listen_port_mtu_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="listen_port_text,mtu_text" />
<TextView
android:id="@+id/applications_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/applications_text"
android:text="@string/applications"
android:visibility="@{config.interface.includedApplications.isEmpty() &amp;&amp; config.interface.excludedApplications.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/listen_port_mtu_barrier" />
<TextView
android:id="@+id/applications_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/applications"
android:nextFocusUp="@id/mtu_text"
android:nextFocusDown="@id/peers_layout"
android:nextFocusForward="@id/peers_layout"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{config.interface.includedApplications.isEmpty() ? @plurals/n_excluded_applications(config.interface.excludedApplications.size(), config.interface.excludedApplications.size()) : @plurals/n_included_applications(config.interface.includedApplications.size(), config.interface.includedApplications.size())}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{config.interface.includedApplications.isEmpty() &amp;&amp; config.interface.excludedApplications.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/applications_label"
tools:text="8 excluded" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:id="@+id/peers_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:divider="@null"
android:orientation="vertical"
app:items="@{config.peers}"
app:layout="@{@layout/tunnel_detail_peer}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tunnel_detail_card"
tools:ignore="UselessLeaf" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</layout>

View file

@ -0,0 +1,230 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="com.wireguard.android.util.ClipboardUtils" />
<variable
name="item"
type="com.wireguard.config.Peer" />
</data>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/peer_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/peer"
android:textAppearance="?attr/textAppearanceTitleMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/public_key_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/public_key_text"
android:text="@string/public_key"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/peer_title" />
<TextView
android:id="@+id/public_key_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/public_key"
android:ellipsize="end"
android:maxLines="1"
android:nextFocusDown="@id/pre_shared_key_text"
android:nextFocusForward="@id/pre_shared_key_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:singleLine="true"
android:text="@{item.publicKey.toBase64}"
android:textAppearance="?attr/textAppearanceBodyLarge"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/public_key_label"
tools:text="wOs2eguFEohqIZxlSJ1CAT9584tc6ejj9hfGFsoBVkA=" />
<TextView
android:id="@+id/pre_shared_key_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/pre_shared_key_text"
android:text="@string/pre_shared_key"
android:visibility="@{!item.preSharedKey.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/public_key_text" />
<TextView
android:id="@+id/pre_shared_key_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/pre_shared_key"
android:ellipsize="end"
android:maxLines="1"
android:nextFocusUp="@id/public_key_text"
android:nextFocusDown="@id/allowed_ips_text"
android:nextFocusForward="@id/allowed_ips_text"
android:singleLine="true"
android:text="@string/pre_shared_key_enabled"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{!item.preSharedKey.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/pre_shared_key_label"
tools:text="8VyS8W8XeMcBWfKp1GuG3/fZlnUQFkqMNbrdmZtVQIM=" />
<TextView
android:id="@+id/allowed_ips_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/allowed_ips_text"
android:text="@string/allowed_ips"
android:visibility="@{item.allowedIps.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/pre_shared_key_text" />
<TextView
android:id="@+id/allowed_ips_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/allowed_ips"
android:nextFocusUp="@id/pre_shared_key_text"
android:nextFocusDown="@id/endpoint_text"
android:nextFocusForward="@id/endpoint_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{item.allowedIps}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{item.allowedIps.isEmpty() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/allowed_ips_label"
tools:text="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" />
<TextView
android:id="@+id/endpoint_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/endpoint_text"
android:text="@string/endpoint"
android:visibility="@{!item.endpoint.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/allowed_ips_text" />
<TextView
android:id="@+id/endpoint_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/endpoint"
android:nextFocusUp="@id/allowed_ips_text"
android:nextFocusDown="@id/persistent_keepalive_text"
android:nextFocusForward="@id/persistent_keepalive_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{item.endpoint}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{!item.endpoint.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/endpoint_label"
tools:text="192.168.0.1:51820" />
<TextView
android:id="@+id/persistent_keepalive_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:labelFor="@+id/persistent_keepalive_text"
android:text="@string/persistent_keepalive"
android:visibility="@{!item.persistentKeepalive.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/endpoint_text" />
<TextView
android:id="@+id/persistent_keepalive_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/persistent_keepalive"
android:nextFocusUp="@id/endpoint_text"
android:nextFocusDown="@id/transfer_text"
android:nextFocusForward="@id/transfer_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{@plurals/persistent_keepalive_seconds_unit(item.persistentKeepalive.orElse(0), item.persistentKeepalive.orElse(0))}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{!item.persistentKeepalive.isPresent() ? android.view.View.GONE : android.view.View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/persistent_keepalive_label"
tools:text="every 3 seconds" />
<TextView
android:id="@+id/transfer_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/persistent_keepalive_text"
android:layout_marginTop="8dp"
android:labelFor="@+id/transfer_text"
android:text="@string/transfer"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/persistent_keepalive_text"
tools:visibility="visible" />
<TextView
android:id="@+id/transfer_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/transfer_label"
android:contentDescription="@string/transfer"
android:nextFocusUp="@id/persistent_keepalive_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/transfer_label"
tools:text="1024 MB"
tools:visibility="visible" />
<TextView
android:id="@+id/latest_handshake_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/transfer_text"
android:layout_marginTop="8dp"
android:labelFor="@+id/latest_handshake_text"
android:text="@string/latest_handshake"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/transfer_text"
tools:visibility="visible" />
<TextView
android:id="@+id/latest_handshake_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/latest_handshake_label"
android:contentDescription="@string/latest_handshake"
android:nextFocusUp="@id/transfer_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/latest_handshake_label"
tools:text="4 minutes, 27 seconds ago"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</layout>

View file

@ -0,0 +1,295 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="com.wireguard.android.util.ClipboardUtils" />
<import type="com.wireguard.android.widget.KeyInputFilter" />
<import type="com.wireguard.android.widget.NameInputFilter" />
<variable
name="fragment"
type="com.wireguard.android.fragment.TunnelEditorFragment" />
<variable
name="config"
type="com.wireguard.android.viewmodel.ConfigProxy" />
<variable
name="name"
type="String" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/main_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/interface_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_marginTop="32dp"
android:text="@string/interface_title"
android:textAppearance="?attr/textAppearanceTitleMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/interface_name_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/interface_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/interface_name_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="textNoSuggestions|textVisiblePassword"
android:nextFocusDown="@id/private_key_text"
android:nextFocusForward="@id/private_key_text"
android:text="@={name}"
app:filter="@{NameInputFilter.newInstance()}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/private_key_text_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/private_key"
app:endIconContentDescription="@string/generate_new_private_key"
app:endIconDrawable="@drawable/ic_action_generate"
app:endIconMode="custom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/interface_name_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/private_key_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="textNoSuggestions|textPassword"
android:nextFocusUp="@id/interface_name_text"
android:nextFocusDown="@id/public_key_text"
android:nextFocusForward="@id/public_key_text"
android:onClick="@{fragment::onKeyClick}"
android:text="@={config.interface.privateKey}"
app:filter="@{KeyInputFilter.newInstance()}"
app:onFocusChange="@{fragment::onKeyFocusChange}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/public_key_label_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/public_key"
app:expandedHintEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/private_key_text_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/public_key_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:editable="false"
android:ellipsize="end"
android:focusable="false"
android:hint="@string/hint_generated"
android:imeOptions="actionNext"
android:nextFocusUp="@id/private_key_text"
android:nextFocusDown="@id/addresses_label_text"
android:nextFocusForward="@id/addresses_label_text"
android:onClick="@{ClipboardUtils::copyTextView}"
android:singleLine="true"
android:text="@{config.interface.publicKey}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/addresses_label_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/addresses"
app:layout_constraintEnd_toStartOf="@id/listen_port_label_layout"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintHorizontal_weight="0.7"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/public_key_label_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/addresses_label_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="textNoSuggestions|textVisiblePassword"
android:nextFocusUp="@id/public_key_text"
android:nextFocusDown="@id/dns_servers_text"
android:nextFocusForward="@id/listen_port_text"
android:text="@={config.interface.addresses}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/listen_port_label_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/listen_port"
app:expandedHintEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_weight="0.3"
app:layout_constraintStart_toEndOf="@id/addresses_label_layout"
app:layout_constraintTop_toBottomOf="@id/public_key_label_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/listen_port_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_random"
android:imeOptions="actionNext"
android:inputType="number"
android:nextFocusUp="@id/public_key_text"
android:nextFocusDown="@id/mtu_text"
android:nextFocusForward="@id/dns_servers_text"
android:text="@={config.interface.listenPort}"
android:textAlignment="center" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/dns_servers_label_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/dns_servers"
app:layout_constraintEnd_toStartOf="@id/mtu_label_layout"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintHorizontal_weight="0.7"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/addresses_label_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/dns_servers_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="textNoSuggestions|textVisiblePassword"
android:nextFocusUp="@id/addresses_label_text"
android:nextFocusDown="@id/set_excluded_applications"
android:nextFocusForward="@id/mtu_text"
android:text="@={config.interface.dnsServers}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/mtu_label_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/mtu"
app:expandedHintEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_weight="0.3"
app:layout_constraintStart_toEndOf="@id/dns_servers_label_layout"
app:layout_constraintTop_toBottomOf="@id/addresses_label_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/mtu_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_automatic"
android:imeOptions="actionDone"
android:inputType="number"
android:nextFocusUp="@id/listen_port_text"
android:nextFocusDown="@id/set_excluded_applications"
android:nextFocusForward="@id/set_excluded_applications"
android:text="@={config.interface.mtu}"
android:textAlignment="center" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/set_excluded_applications"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:nextFocusUp="@id/dns_servers_text"
android:nextFocusDown="@id/peers_layout"
android:nextFocusForward="@id/peers_layout"
android:onClick="@{fragment::onRequestSetExcludedIncludedApplications}"
android:text="@{config.interface.includedApplications.size > 0 ? @plurals/set_included_applications(config.interface.includedApplications.size, config.interface.includedApplications.size) : config.interface.excludedApplications.size > 0 ? @plurals/set_excluded_applications(config.interface.excludedApplications.size, config.interface.excludedApplications.size) : @string/all_applications}"
android:textColor="?attr/colorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/mtu_label_layout"
app:rippleColor="?attr/colorSecondary"
tools:text="4 excluded applications" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:id="@+id/peers_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:divider="@null"
android:orientation="vertical"
app:fragment="@{fragment}"
app:items="@{config.peers}"
app:layout="@{@layout/tunnel_editor_peer}"
tools:ignore="UselessLeaf" />
<com.google.android.material.button.MaterialButton
android:id="@+id/add_peer_button"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="?attr/colorPrimaryDark"
android:gravity="center"
android:onClick="@{() -> config.addPeer()}"
android:text="@string/add_peer"
android:textColor="?attr/colorSecondary"
app:layout_anchorGravity="bottom"
app:rippleColor="?attr/colorSecondary" />
</LinearLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -0,0 +1,203 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<import type="com.wireguard.android.widget.KeyInputFilter" />
<import type="com.wireguard.android.databinding.BindingAdapters" />
<variable
name="collection"
type="androidx.databinding.ObservableList&lt;com.wireguard.android.viewmodel.PeerProxy&gt;" />
<variable
name="item"
type="com.wireguard.android.viewmodel.PeerProxy" />
<variable
name="fragment"
type="com.wireguard.android.fragment.TunnelEditorFragment" />
</data>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/peer_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/peer"
android:textAppearance="?attr/textAppearanceTitleMedium"
app:layout_constraintBottom_toTopOf="@+id/public_key_label_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/delete"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@null"
android:nextFocusDown="@id/public_key_text"
android:nextFocusForward="@id/public_key_text"
android:onClick="@{() -> item.unbind()}"
android:padding="8dp"
android:src="@drawable/ic_action_delete"
app:layout_constraintBaseline_toBaselineOf="@id/peer_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/peer_title" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/public_key_label_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/public_key"
app:layout_constraintBottom_toTopOf="@+id/pre_shared_key_label_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/peer_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/public_key_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="textNoSuggestions|textVisiblePassword"
android:nextFocusUp="@id/delete"
android:nextFocusDown="@id/pre_shared_key_text"
android:nextFocusForward="@id/pre_shared_key_text"
android:text="@={item.publicKey}"
app:filter="@{KeyInputFilter.newInstance()}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/pre_shared_key_label_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/pre_shared_key"
app:expandedHintEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/public_key_label_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/pre_shared_key_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_optional"
android:imeOptions="actionNext"
android:inputType="textNoSuggestions|textPassword"
android:nextFocusUp="@id/public_key_text"
android:nextFocusDown="@id/persistent_keepalive_text"
android:nextFocusForward="@id/persistent_keepalive_text"
android:onClick="@{fragment::onKeyClick}"
android:text="@={item.preSharedKey}"
app:filter="@{KeyInputFilter.newInstance()}"
app:onFocusChange="@{fragment::onKeyFocusChange}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/persistent_keepalive_label_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/persistent_keepalive"
app:expandedHintEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/pre_shared_key_label_layout"
app:suffixText="@{@plurals/persistent_keepalive_seconds_suffix(BindingAdapters.tryParseInt(item.persistentKeepalive))}">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/persistent_keepalive_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_optional_discouraged"
android:imeOptions="actionNext"
android:inputType="number"
android:nextFocusUp="@id/persistent_keepalive_text"
android:nextFocusDown="@id/endpoint_text"
android:nextFocusForward="@id/endpoint_text"
android:text="@={item.persistentKeepalive}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/endpoint_label_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/endpoint"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/persistent_keepalive_label_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/endpoint_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:imeOptions="actionNext"
android:inputType="textNoSuggestions|textVisiblePassword"
android:nextFocusUp="@id/persistent_keepalive_text"
android:nextFocusDown="@id/allowed_ips_text"
android:nextFocusForward="@id/allowed_ips_text"
android:text="@={item.endpoint}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/allowed_ips_label_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:hint="@string/allowed_ips"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/endpoint_label_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/allowed_ips_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:imeOptions="actionDone"
android:inputType="textNoSuggestions|textVisiblePassword"
android:nextFocusUp="@id/endpoint_text"
android:nextFocusDown="@id/selected_checkbox"
android:nextFocusForward="@id/selected_checkbox"
android:text="@={item.allowedIps}" />
</com.google.android.material.textfield.TextInputLayout>
<CheckBox
android:id="@+id/selected_checkbox"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginStart="4dp"
android:layout_marginTop="0dp"
android:checked="@={item.excludingPrivateIps}"
android:nextFocusDown="@id/add_peer_button"
android:nextFocusForward="@id/add_peer_button"
android:text="@string/exclude_private_ips"
android:visibility="@{item.ableToExcludePrivateIps ? View.VISIBLE : View.GONE}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/allowed_ips_label_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</layout>

View file

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="com.wireguard.android.model.ObservableTunnel" />
<variable
name="fragment"
type="com.wireguard.android.fragment.TunnelListFragment" />
<variable
name="rowConfigurationHandler"
type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
<variable
name="tunnels"
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, ObservableTunnel&gt;" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/main_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipChildren="false">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tunnel_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:nextFocusDown="@id/create_fab"
android:nextFocusForward="@id/create_fab"
android:paddingBottom="@{@dimen/design_fab_size_normal * 1.1f}"
android:visibility="@{tunnels.size() > 0 ? android.view.View.VISIBLE : android.view.View.GONE}"
app:configurationHandler="@{rowConfigurationHandler}"
app:items="@{tunnels}"
app:layout="@{@layout/tunnel_list_item}"
tools:itemCount="12"
tools:listitem="@layout/tunnel_list_item" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:visibility="@{tunnels.size() == 0 ? android.view.View.VISIBLE : android.view.View.GONE}"
tools:visibility="gone">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/logo_placeholder"
android:layout_width="140dp"
android:layout_height="140dp"
android:layout_gravity="center"
android:layout_marginBottom="20dp"
android:alpha="0.3333333"
android:src="@mipmap/ic_launcher" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="@dimen/tunnel_list_placeholder_margin"
android:layout_marginEnd="@dimen/tunnel_list_placeholder_margin"
android:text="@string/tunnel_list_placeholder"
android:textSize="20sp" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/create_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:nextFocusUp="@id/tunnel_list"
app:srcCompat="@drawable/ic_action_add_white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="com.wireguard.android.model.ObservableTunnel" />
<import type="com.wireguard.android.backend.Tunnel.State" />
<variable
name="collection"
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, ObservableTunnel&gt;" />
<variable
name="key"
type="String" />
<variable
name="item"
type="com.wireguard.android.model.ObservableTunnel" />
<variable
name="fragment"
type="com.wireguard.android.fragment.TunnelListFragment" />
</data>
<com.wireguard.android.widget.MultiselectableRelativeLayout
android:id="@+id/tunnel_list_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/list_item_background"
android:descendantFocusability="beforeDescendants"
android:focusable="true"
android:nextFocusRight="@+id/tunnel_switch"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp">
<TextView
android:id="@+id/tunnel_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:ellipsize="end"
android:maxLines="1"
android:text="@{key}"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:text="@sample/interface_names.json/names/names/name" />
<com.wireguard.android.widget.ToggleSwitch
android:id="@+id/tunnel_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:nextFocusLeft="@+id/tunnel_list_item"
app:checked="@{item.state == State.UP}"
app:onBeforeCheckedChanged="@{fragment::setTunnelState}"
tools:checked="@sample/interface_names.json/names/checked/checked" />
</com.wireguard.android.widget.MultiselectableRelativeLayout>
</layout>

View file

@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<import type="com.wireguard.android.model.ObservableTunnel" />
<import type="com.wireguard.android.activity.TvMainActivity.KeyedFile" />
<variable
name="isDeleting"
type="androidx.databinding.ObservableBoolean" />
<variable
name="files"
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, KeyedFile&gt;" />
<variable
name="filesRoot"
type="androidx.databinding.ObservableField&lt;String&gt;" />
<variable
name="tunnels"
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, ObservableTunnel&gt;" />
<variable
name="tunnelRowConfigurationHandler"
type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
<variable
name="filesRowConfigurationHandler"
type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.card.MaterialCardView
android:id="@+id/banner_logo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginStart="5dp"
android:layout_marginTop="5dp"
app:cardElevation="2dp"
app:contentPadding="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="320dp"
android:layout_height="67dp"
android:contentDescription="@string/app_name"
android:scaleType="fitXY"
app:srcCompat="@drawable/tv_logo_banner" />
</com.google.android.material.card.MaterialCardView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tunnel_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:orientation="horizontal"
android:visibility="@{(tunnels.isEmpty || !filesRoot.isEmpty) ? View.GONE : View.VISIBLE}"
app:configurationHandler="@{tunnelRowConfigurationHandler}"
app:items="@{tunnels}"
app:layout="@{@layout/tv_tunnel_list_item}"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toTopOf="@id/delete_button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/banner_logo"
app:spanCount="3"
tools:itemCount="10"
tools:listitem="@layout/tv_tunnel_list_item" />
<TextView
android:id="@+id/files_root_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginStart="8dp"
android:text="@{filesRoot}"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:visibility="@{filesRoot.isEmpty ? View.GONE : View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/banner_logo"
tools:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/files_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:orientation="horizontal"
android:visibility="@{filesRoot.isEmpty ? View.GONE : View.VISIBLE}"
app:configurationHandler="@{filesRowConfigurationHandler}"
app:items="@{files}"
app:layout="@{@layout/tv_file_list_item}"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toTopOf="@id/import_button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/files_root_label"
app:spanCount="5"
tools:itemCount="10"
tools:listitem="@layout/tv_file_list_item"
tools:visibility="gone" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="@string/tv_add_tunnel_get_started"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:visibility="@{(filesRoot.isEmpty &amp;&amp; tunnels.isEmpty) ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toTopOf="@id/delete_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/banner_logo"
tools:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/import_button"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:minWidth="0dp"
android:visibility="@{isDeleting ? View.GONE : View.VISIBLE}"
app:icon="@{filesRoot.isEmpty ? @drawable/ic_action_add_white : @drawable/ic_arrow_back}"
app:iconPadding="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/delete_button"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:minWidth="0dp"
android:visibility="@{((tunnels.isEmpty &amp;&amp; !isDeleting) || !filesRoot.isEmpty) ? View.GONE : View.VISIBLE}"
app:icon="@{isDeleting ? @drawable/ic_arrow_back : @drawable/ic_action_delete}"
app:iconPadding="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="com.wireguard.android.activity.TvMainActivity.KeyedFile" />
<variable
name="key"
type="String" />
<variable
name="item"
type="KeyedFile" />
</data>
<com.google.android.material.card.MaterialCardView
android:layout_width="320dp"
android:layout_height="50dp"
android:layout_margin="8dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="0dp"
android:checkable="true"
android:focusable="true"
app:contentPadding="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textview.MaterialTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{key}"
android:textAppearance="?attr/textAppearanceTitleLarge"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</layout>

View file

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<import type="com.wireguard.android.model.ObservableTunnel" />
<import type="com.wireguard.android.backend.Tunnel.State" />
<variable
name="isDeleting"
type="androidx.databinding.ObservableBoolean" />
<variable
name="isFocused"
type="androidx.databinding.ObservableBoolean" />
<variable
name="key"
type="String" />
<variable
name="item"
type="com.wireguard.android.model.ObservableTunnel" />
</data>
<com.wireguard.android.widget.TvCardView
android:layout_width="225dp"
android:layout_height="110dp"
android:layout_margin="8dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="0dp"
android:backgroundTint="@color/tv_list_item_tint"
android:checkable="true"
android:focusable="true"
app:contentPadding="8dp"
app:isDeleting="@{isDeleting}"
app:isUp="@{item.state == State.UP}">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tunnel_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.name}"
android:textAppearance="?attr/textAppearanceTitleLarge"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@sample/interface_names.json/names/names/name" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tunnel_transfer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:visibility="@{isDeleting ? View.GONE : View.VISIBLE}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="rx: 200 MB, tx: 100 MB" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tunnel_delete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tv_delete"
android:visibility="@{(isDeleting &amp;&amp; isFocused) ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.wireguard.android.widget.TvCardView>
</layout>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_action_save"
android:alphabeticShortcut="s"
android:icon="@drawable/ic_action_save"
android:title="@string/save"
app:showAsAction="always" />
</menu>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/save_log"
android:icon="@drawable/ic_action_save"
android:title="@string/log_export_title"
app:showAsAction="ifRoom" />
</menu>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
~ SPDX-License-Identifier: Apache-2.0
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_settings"
android:alphabeticShortcut="s"
android:icon="@drawable/ic_settings"
android:orderInCategory="1000"
android:title="@string/settings"
app:showAsAction="always" />
</menu>

Some files were not shown because too many files have changed in this diff Show more