Repo cloned
This commit is contained in:
parent
b280361250
commit
db901828a8
235 changed files with 27925 additions and 2 deletions
4
ui/src/debug/res/values/strings.xml
Normal file
4
ui/src/debug/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">WireGuard β</string>
|
||||
</resources>
|
||||
11
ui/src/googleplay/AndroidManifest.xml
Normal file
11
ui/src/googleplay/AndroidManifest.xml
Normal 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>
|
||||
169
ui/src/main/AndroidManifest.xml
Normal file
169
ui/src/main/AndroidManifest.xml
Normal 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>
|
||||
157
ui/src/main/java/com/wireguard/android/Application.kt
Normal file
157
ui/src/main/java/com/wireguard/android/Application.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
203
ui/src/main/java/com/wireguard/android/QuickTileService.kt
Normal file
203
ui/src/main/java/com/wireguard/android/QuickTileService.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
129
ui/src/main/java/com/wireguard/android/activity/MainActivity.kt
Normal file
129
ui/src/main/java/com/wireguard/android/activity/MainActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
12
ui/src/main/java/com/wireguard/android/databinding/Keyed.kt
Normal file
12
ui/src/main/java/com/wireguard/android/databinding/Keyed.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
114
ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt
Normal file
114
ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
146
ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt
Normal file
146
ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
254
ui/src/main/java/com/wireguard/android/model/TunnelManager.kt
Normal file
254
ui/src/main/java/com/wireguard/android/model/TunnelManager.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
2507
ui/src/main/java/com/wireguard/android/updater/Ed25519.java
Normal file
2507
ui/src/main/java/com/wireguard/android/updater/Ed25519.java
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
460
ui/src/main/java/com/wireguard/android/updater/Updater.kt
Normal file
460
ui/src/main/java/com/wireguard/android/updater/Updater.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt
Normal file
17
ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
158
ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt
Normal file
158
ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt
Normal 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
|
||||
}
|
||||
}
|
||||
31
ui/src/main/java/com/wireguard/android/util/Extensions.kt
Normal file
31
ui/src/main/java/com/wireguard/android/util/Extensions.kt
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
152
ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt
Normal file
152
ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt
Normal 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"
|
||||
}
|
||||
121
ui/src/main/java/com/wireguard/android/util/UserKnobs.kt
Normal file
121
ui/src/main/java/com/wireguard/android/util/UserKnobs.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
294
ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt
Normal file
294
ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
175
ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt
Normal file
175
ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
44
ui/src/main/java/com/wireguard/android/widget/TvCardView.kt
Normal file
44
ui/src/main/java/com/wireguard/android/widget/TvCardView.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
15
ui/src/main/res/anim/scale_down.xml
Normal file
15
ui/src/main/res/anim/scale_down.xml
Normal 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>
|
||||
15
ui/src/main/res/anim/scale_up.xml
Normal file
15
ui/src/main/res/anim/scale_up.xml
Normal 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>
|
||||
10
ui/src/main/res/color/tv_list_item_tint.xml
Normal file
10
ui/src/main/res/color/tv_list_item_tint.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_action_add_white.xml
Normal file
14
ui/src/main/res/drawable/ic_action_add_white.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_action_delete.xml
Normal file
14
ui/src/main/res/drawable/ic_action_delete.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_action_edit.xml
Normal file
14
ui/src/main/res/drawable/ic_action_edit.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_action_generate.xml
Normal file
14
ui/src/main/res/drawable/ic_action_generate.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_action_open.xml
Normal file
14
ui/src/main/res/drawable/ic_action_open.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_action_save.xml
Normal file
14
ui/src/main/res/drawable/ic_action_save.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_action_scan_qr_code.xml
Normal file
14
ui/src/main/res/drawable/ic_action_scan_qr_code.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_action_select_all.xml
Normal file
14
ui/src/main/res/drawable/ic_action_select_all.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_action_share_white.xml
Normal file
14
ui/src/main/res/drawable/ic_action_share_white.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_arrow_back.xml
Normal file
14
ui/src/main/res/drawable/ic_arrow_back.xml
Normal 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>
|
||||
38
ui/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
38
ui/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
14
ui/src/main/res/drawable/ic_settings.xml
Normal file
14
ui/src/main/res/drawable/ic_settings.xml
Normal 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>
|
||||
28
ui/src/main/res/drawable/ic_tile.xml
Normal file
28
ui/src/main/res/drawable/ic_tile.xml
Normal 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>
|
||||
20
ui/src/main/res/drawable/list_item_background.xml
Normal file
20
ui/src/main/res/drawable/list_item_background.xml
Normal 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>
|
||||
210
ui/src/main/res/drawable/tv_logo_banner.xml
Normal file
210
ui/src/main/res/drawable/tv_logo_banner.xml
Normal 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>
|
||||
37
ui/src/main/res/layout-sw600dp/main_activity.xml
Normal file
37
ui/src/main/res/layout-sw600dp/main_activity.xml
Normal 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>
|
||||
81
ui/src/main/res/layout/add_tunnels_bottom_sheet.xml
Normal file
81
ui/src/main/res/layout/add_tunnels_bottom_sheet.xml
Normal 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>
|
||||
70
ui/src/main/res/layout/app_list_dialog_fragment.xml
Normal file
70
ui/src/main/res/layout/app_list_dialog_fragment.xml
Normal 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<String, ApplicationData>" />
|
||||
</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>
|
||||
64
ui/src/main/res/layout/app_list_item.xml
Normal file
64
ui/src/main/res/layout/app_list_item.xml
Normal 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<String, com.wireguard.android.model.ApplicationData>" />
|
||||
|
||||
<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>
|
||||
39
ui/src/main/res/layout/config_naming_dialog_fragment.xml
Normal file
39
ui/src/main/res/layout/config_naming_dialog_fragment.xml
Normal 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>
|
||||
28
ui/src/main/res/layout/log_viewer_activity.xml
Normal file
28
ui/src/main/res/layout/log_viewer_activity.xml
Normal 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>
|
||||
32
ui/src/main/res/layout/log_viewer_entry.xml
Normal file
32
ui/src/main/res/layout/log_viewer_entry.xml
Normal 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>
|
||||
19
ui/src/main/res/layout/main_activity.xml
Normal file
19
ui/src/main/res/layout/main_activity.xml
Normal 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>
|
||||
18
ui/src/main/res/layout/tunnel_creator_activity.xml
Normal file
18
ui/src/main/res/layout/tunnel_creator_activity.xml
Normal 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>
|
||||
324
ui/src/main/res/layout/tunnel_detail_fragment.xml
Normal file
324
ui/src/main/res/layout/tunnel_detail_fragment.xml
Normal 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() && 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() && 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>
|
||||
230
ui/src/main/res/layout/tunnel_detail_peer.xml
Normal file
230
ui/src/main/res/layout/tunnel_detail_peer.xml
Normal 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>
|
||||
295
ui/src/main/res/layout/tunnel_editor_fragment.xml
Normal file
295
ui/src/main/res/layout/tunnel_editor_fragment.xml
Normal 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>
|
||||
203
ui/src/main/res/layout/tunnel_editor_peer.xml
Normal file
203
ui/src/main/res/layout/tunnel_editor_peer.xml
Normal 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<com.wireguard.android.viewmodel.PeerProxy>" />
|
||||
|
||||
<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>
|
||||
85
ui/src/main/res/layout/tunnel_list_fragment.xml
Normal file
85
ui/src/main/res/layout/tunnel_list_fragment.xml
Normal 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<String, ObservableTunnel>" />
|
||||
</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>
|
||||
66
ui/src/main/res/layout/tunnel_list_item.xml
Normal file
66
ui/src/main/res/layout/tunnel_list_item.xml
Normal 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<String, ObservableTunnel>" />
|
||||
|
||||
<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>
|
||||
157
ui/src/main/res/layout/tv_activity.xml
Normal file
157
ui/src/main/res/layout/tv_activity.xml
Normal 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<String, KeyedFile>" />
|
||||
|
||||
<variable
|
||||
name="filesRoot"
|
||||
type="androidx.databinding.ObservableField<String>" />
|
||||
|
||||
<variable
|
||||
name="tunnels"
|
||||
type="com.wireguard.android.databinding.ObservableKeyedArrayList<String, ObservableTunnel>" />
|
||||
|
||||
<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 && 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 && !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>
|
||||
47
ui/src/main/res/layout/tv_file_list_item.xml
Normal file
47
ui/src/main/res/layout/tv_file_list_item.xml
Normal 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>
|
||||
85
ui/src/main/res/layout/tv_tunnel_list_item.xml
Normal file
85
ui/src/main/res/layout/tv_tunnel_list_item.xml
Normal 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 && 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>
|
||||
13
ui/src/main/res/menu/config_editor.xml
Normal file
13
ui/src/main/res/menu/config_editor.xml
Normal 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>
|
||||
12
ui/src/main/res/menu/log_viewer.xml
Normal file
12
ui/src/main/res/menu/log_viewer.xml
Normal 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>
|
||||
14
ui/src/main/res/menu/main_activity.xml
Normal file
14
ui/src/main/res/menu/main_activity.xml
Normal 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
Loading…
Add table
Add a link
Reference in a new issue