summaryrefslogtreecommitdiffhomepage
path: root/ui
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2020-03-09 19:06:11 +0530
committerHarsh Shandilya <me@msfjarvis.dev>2020-03-09 19:24:27 +0530
commit7d48bef70a56d4370856eedab619b1f83ac3d0d0 (patch)
tree76fd859578e499cd3a8fd2f402652530ea36a72d /ui
parent6bc3e257f80a273d35d07099bd4ed99eb45163bf (diff)
Rename app module to ui
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'ui')
-rw-r--r--ui/build.gradle90
-rw-r--r--ui/proguard-rules.pro3
-rw-r--r--ui/sampledata/interface_names.json34
-rw-r--r--ui/src/debug/res/values/strings.xml4
-rw-r--r--ui/src/main/AndroidManifest.xml90
-rw-r--r--ui/src/main/java/com/wireguard/android/Application.java171
-rw-r--r--ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java38
-rw-r--r--ui/src/main/java/com/wireguard/android/QuickTileService.java174
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/BaseActivity.java99
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/MainActivity.java144
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java129
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java47
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java34
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java51
-rw-r--r--ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java67
-rw-r--r--ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java103
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java148
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java140
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java159
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt106
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java140
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java126
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java118
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java162
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java264
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java449
-rw-r--r--ui/src/main/java/com/wireguard/android/model/ApplicationData.java54
-rw-r--r--ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java142
-rw-r--r--ui/src/main/java/com/wireguard/android/model/TunnelManager.java301
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java112
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java92
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java102
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/VersionPreference.java71
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java119
-rw-r--r--ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt67
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java37
-rw-r--r--ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java98
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ErrorMessages.java160
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java36
-rw-r--r--ui/src/main/java/com/wireguard/android/util/Extensions.kt16
-rw-r--r--ui/src/main/java/com/wireguard/android/util/FragmentUtils.java27
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ModuleLoader.java186
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java109
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java19
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java198
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java17
-rw-r--r--ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java93
-rw-r--r--ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java190
-rw-r--r--ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java380
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java54
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java59
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java53
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java217
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java59
-rw-r--r--ui/src/main/java/com/wireguard/util/Keyed.java14
-rw-r--r--ui/src/main/java/com/wireguard/util/KeyedList.java32
-rw-r--r--ui/src/main/java/com/wireguard/util/SortedKeyedList.java31
-rw-r--r--ui/src/main/res/drawable/ic_action_add_white.xml9
-rw-r--r--ui/src/main/res/drawable/ic_action_delete.xml9
-rw-r--r--ui/src/main/res/drawable/ic_action_edit.xml9
-rw-r--r--ui/src/main/res/drawable/ic_action_edit_white.xml9
-rw-r--r--ui/src/main/res/drawable/ic_action_open_white.xml9
-rw-r--r--ui/src/main/res/drawable/ic_action_save.xml10
-rw-r--r--ui/src/main/res/drawable/ic_action_scan_qr_code_white.xml9
-rw-r--r--ui/src/main/res/drawable/ic_action_select_all.xml10
-rw-r--r--ui/src/main/res/drawable/ic_launcher_foreground.xml35
-rw-r--r--ui/src/main/res/drawable/ic_settings.xml9
-rw-r--r--ui/src/main/res/drawable/ic_tile.xml24
-rw-r--r--ui/src/main/res/drawable/list_item_background.xml12
-rw-r--r--ui/src/main/res/layout-sw600dp/main_activity.xml31
-rw-r--r--ui/src/main/res/layout/add_tunnels_bottom_sheet.xml73
-rw-r--r--ui/src/main/res/layout/app_list_dialog_fragment.xml47
-rw-r--r--ui/src/main/res/layout/app_list_item.xml61
-rw-r--r--ui/src/main/res/layout/config_naming_dialog_fragment.xml33
-rw-r--r--ui/src/main/res/layout/main_activity.xml16
-rw-r--r--ui/src/main/res/layout/tunnel_detail_fragment.xml139
-rw-r--r--ui/src/main/res/layout/tunnel_detail_peer.xml112
-rw-r--r--ui/src/main/res/layout/tunnel_editor_fragment.xml254
-rw-r--r--ui/src/main/res/layout/tunnel_editor_peer.xml161
-rw-r--r--ui/src/main/res/layout/tunnel_list_fragment.xml78
-rw-r--r--ui/src/main/res/layout/tunnel_list_item.xml62
-rw-r--r--ui/src/main/res/menu/config_editor.xml10
-rw-r--r--ui/src/main/res/menu/main_activity.xml11
-rw-r--r--ui/src/main/res/menu/tunnel_detail.xml10
-rw-r--r--ui/src/main/res/menu/tunnel_list_action_mode.xml16
-rw-r--r--ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml5
-rw-r--r--ui/src/main/res/mipmap-hdpi/ic_launcher.pngbin0 -> 6688 bytes
-rw-r--r--ui/src/main/res/mipmap-hdpi/ic_launcher_round.pngbin0 -> 7525 bytes
-rw-r--r--ui/src/main/res/mipmap-mdpi/ic_launcher.pngbin0 -> 3594 bytes
-rw-r--r--ui/src/main/res/mipmap-mdpi/ic_launcher_round.pngbin0 -> 4050 bytes
-rw-r--r--ui/src/main/res/mipmap-xhdpi/ic_launcher.pngbin0 -> 8904 bytes
-rw-r--r--ui/src/main/res/mipmap-xhdpi/ic_launcher_round.pngbin0 -> 10484 bytes
-rw-r--r--ui/src/main/res/mipmap-xxhdpi/ic_launcher.pngbin0 -> 16217 bytes
-rw-r--r--ui/src/main/res/mipmap-xxhdpi/ic_launcher_round.pngbin0 -> 19531 bytes
-rw-r--r--ui/src/main/res/mipmap-xxxhdpi/ic_launcher.pngbin0 -> 22885 bytes
-rw-r--r--ui/src/main/res/mipmap-xxxhdpi/ic_launcher_round.pngbin0 -> 27146 bytes
-rw-r--r--ui/src/main/res/values-hi/strings.xml179
-rw-r--r--ui/src/main/res/values-it/strings.xml179
-rw-r--r--ui/src/main/res/values-ja/strings.xml173
-rw-r--r--ui/src/main/res/values-night/bools.xml5
-rw-r--r--ui/src/main/res/values-night/colors.xml17
-rw-r--r--ui/src/main/res/values-ru/strings.xml179
-rw-r--r--ui/src/main/res/values-v27/styles.xml28
-rw-r--r--ui/src/main/res/values-zh-rCN/strings.xml173
-rw-r--r--ui/src/main/res/values/attrs.xml11
-rw-r--r--ui/src/main/res/values/bools.xml5
-rw-r--r--ui/src/main/res/values/colors.xml21
-rw-r--r--ui/src/main/res/values/dimens.xml9
-rw-r--r--ui/src/main/res/values/ic_launcher_background.xml4
-rw-r--r--ui/src/main/res/values/ids.xml4
-rw-r--r--ui/src/main/res/values/strings.xml179
-rw-r--r--ui/src/main/res/values/styles.xml56
-rw-r--r--ui/src/main/res/xml/preferences.xml19
114 files changed, 8754 insertions, 0 deletions
diff --git a/ui/build.gradle b/ui/build.gradle
new file mode 100644
index 00000000..e4caeb6b
--- /dev/null
+++ b/ui/build.gradle
@@ -0,0 +1,90 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply from: rootProject.file('nonnull.gradle')
+
+// Create a variable called keystorePropertiesFile, and initialize it to your
+// keystore.properties file, in the rootProject folder.
+final def keystorePropertiesFile = rootProject.file("keystore.properties")
+
+android {
+ buildToolsVersion '29.0.3'
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+ }
+ compileSdkVersion 29
+ dataBinding.enabled true
+ defaultConfig {
+ applicationId 'com.wireguard.android'
+ minSdkVersion 21
+ targetSdkVersion 29
+ versionCode 464
+ versionName '0.0.20200206'
+ buildConfigField 'int', 'MIN_SDK_VERSION', "$minSdkVersion.apiLevel"
+ }
+ // If the keystore file exists
+ if (keystorePropertiesFile.exists()) {
+ // Initialize a new Properties() object called keystoreProperties.
+ final def keystoreProperties = new Properties()
+
+ // Load your keystore.properties file into the keystoreProperties object.
+ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
+
+ signingConfigs {
+ release {
+ keyAlias keystoreProperties['keyAlias']
+ keyPassword keystoreProperties['keyPassword']
+ storeFile file(keystoreProperties['storeFile'])
+ storePassword keystoreProperties['storePassword']
+ }
+ }
+ }
+ buildTypes {
+ release {
+ if (keystorePropertiesFile.exists()) signingConfig signingConfigs.release
+ externalNativeBuild {
+ cmake {
+ arguments "-DANDROID_PACKAGE_NAME=${android.defaultConfig.applicationId}"
+ }
+ }
+ minifyEnabled true
+ proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+ }
+ debug {
+ applicationIdSuffix ".debug"
+ versionNameSuffix "-debug"
+ externalNativeBuild {
+ cmake {
+ arguments "-DANDROID_PACKAGE_NAME=${android.defaultConfig.applicationId}${applicationIdSuffix}"
+ }
+ }
+ }
+ }
+}
+
+dependencies {
+ implementation project(":tunnel")
+ implementation "androidx.annotation:annotation:$annotationsVersion"
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
+ implementation "androidx.cardview:cardview:$cardviewVersion"
+ implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion"
+ implementation "androidx.coordinatorlayout:coordinatorlayout:$coordinatorLayoutVersion"
+ implementation "androidx.core:core-ktx:$coreKtxVersion"
+ implementation "androidx.databinding:databinding-runtime:$agpVersion"
+ implementation "androidx.fragment:fragment:$fragmentVersion"
+ implementation "androidx.preference:preference:$preferenceVersion"
+ implementation "com.google.android.material:material:$materialComponentsVersion"
+ implementation "com.journeyapps:zxing-android-embedded:$zxingEmbeddedVersion"
+ implementation "net.i2p.crypto:eddsa:$eddsaVersion"
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion"
+}
+
+tasks.withType(JavaCompile) {
+ options.compilerArgs << '-Xlint:unchecked'
+ options.deprecation = true
+}
diff --git a/ui/proguard-rules.pro b/ui/proguard-rules.pro
new file mode 100644
index 00000000..4e7b3d96
--- /dev/null
+++ b/ui/proguard-rules.pro
@@ -0,0 +1,3 @@
+# Squelch all warnings, they're harmless but ProGuard
+# escalates them as errors.
+-dontwarn sun.misc.Unsafe
diff --git a/ui/sampledata/interface_names.json b/ui/sampledata/interface_names.json
new file mode 100644
index 00000000..1c41cb22
--- /dev/null
+++ b/ui/sampledata/interface_names.json
@@ -0,0 +1,34 @@
+{
+ "comment": "Interface names",
+ "names": [
+ {
+ "names": [
+ { "name": "wg0" },
+ { "name": "wg1" },
+ { "name": "wg2" },
+ { "name": "wg3" },
+ { "name": "wg4" },
+ { "name": "wg5" },
+ { "name": "wg6" },
+ { "name": "wg7" },
+ { "name": "wg8" },
+ { "name": "wg9" },
+ { "name": "wg10" },
+ { "name": "wg11" }
+ ],
+ "checked": [
+ { "checked": true },
+ { "checked": false },
+ { "checked": true },
+ { "checked": false },
+ { "checked": true },
+ { "checked": false },
+ { "checked": true },
+ { "checked": false },
+ { "checked": true },
+ { "checked": false },
+ { "checked": true }
+ ]
+ }
+ ]
+}
diff --git a/ui/src/debug/res/values/strings.xml b/ui/src/debug/res/values/strings.xml
new file mode 100644
index 00000000..60e016ea
--- /dev/null
+++ b/ui/src/debug/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">WireGuard β</string>
+</resources>
diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..5e993ae2
--- /dev/null
+++ b/ui/src/main/AndroidManifest.xml
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="com.wireguard.android"
+ 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.WRITE_EXTERNAL_STORAGE" />
+
+ <uses-feature android:name="android.hardware.touchscreen" 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:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:roundIcon="@mipmap/ic_launcher_round"
+ android:supportsRtl="true"
+ android:theme="@style/AppTheme"
+ tools:ignore="UnusedAttribute">
+
+ <activity android:name=".activity.TunnelToggleActivity" android:theme="@style/NoBackgroundTheme"/>
+ <activity android:name=".activity.MainActivity">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
+ </intent-filter>
+
+ <intent-filter>
+ <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
+ </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" />
+
+ <receiver android:name=".BootShutdownReceiver">
+ <intent-filter>
+ <action android:name="android.intent.action.ACTION_SHUTDOWN" />
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
+ </intent-filter>
+ </receiver>
+
+ <receiver
+ android:name=".model.TunnelManager$IntentReceiver"
+ 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: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" />
+ </service>
+ </application>
+</manifest>
diff --git a/ui/src/main/java/com/wireguard/android/Application.java b/ui/src/main/java/com/wireguard/android/Application.java
new file mode 100644
index 00000000..2ebeb69d
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/Application.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright © 2017-2019 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.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.StrictMode;
+import android.util.Log;
+
+import androidx.preference.PreferenceManager;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatDelegate;
+
+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.util.AsyncWorker;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.android.util.ModuleLoader;
+import com.wireguard.android.util.RootShell;
+import com.wireguard.android.util.ToolsInstaller;
+
+import java.lang.ref.WeakReference;
+import java.util.Locale;
+
+import java9.util.concurrent.CompletableFuture;
+
+public class Application extends android.app.Application {
+ private static final String TAG = "WireGuard/" + Application.class.getSimpleName();
+ public static final String USER_AGENT;
+
+ static {
+ String preferredAbi = "unknown ABI";
+ if (Build.SUPPORTED_ABIS.length > 0)
+ preferredAbi = Build.SUPPORTED_ABIS[0];
+ USER_AGENT = String.format(Locale.ENGLISH, "WireGuard/%s (Android %d; %s; %s; %s %s; %s)", BuildConfig.VERSION_NAME, Build.VERSION.SDK_INT, preferredAbi, Build.BOARD, Build.MANUFACTURER, Build.MODEL, Build.FINGERPRINT);
+ }
+
+ @SuppressWarnings("NullableProblems") private static WeakReference<Application> weakSelf;
+ private final CompletableFuture<Backend> futureBackend = new CompletableFuture<>();
+ @SuppressWarnings("NullableProblems") private AsyncWorker asyncWorker;
+ @Nullable private Backend backend;
+ @SuppressWarnings("NullableProblems") private RootShell rootShell;
+ @SuppressWarnings("NullableProblems") private SharedPreferences sharedPreferences;
+ @SuppressWarnings("NullableProblems") private ToolsInstaller toolsInstaller;
+ @SuppressWarnings("NullableProblems") private ModuleLoader moduleLoader;
+ @SuppressWarnings("NullableProblems") private TunnelManager tunnelManager;
+
+ public Application() {
+ weakSelf = new WeakReference<>(this);
+ }
+
+ public static Application get() {
+ return weakSelf.get();
+ }
+
+ public static AsyncWorker getAsyncWorker() {
+ return get().asyncWorker;
+ }
+
+ public static Backend getBackend() {
+ final Application app = get();
+ synchronized (app.futureBackend) {
+ if (app.backend == null) {
+ Backend backend = null;
+ boolean didStartRootShell = false;
+ if (!ModuleLoader.isModuleLoaded() && app.moduleLoader.moduleMightExist()) {
+ try {
+ app.rootShell.start();
+ didStartRootShell = true;
+ app.moduleLoader.loadModule();
+ } catch (final Exception ignored) {
+ }
+ }
+ if (ModuleLoader.isModuleLoaded()) {
+ try {
+ if (!didStartRootShell)
+ app.rootShell.start();
+ backend = new WgQuickBackend(app.getApplicationContext(), app.rootShell, app.toolsInstaller);
+ } catch (final Exception ignored) {
+ }
+ }
+ if (backend == null) {
+ backend = new GoBackend(app.getApplicationContext());
+ GoBackend.setAlwaysOnCallback(() -> {
+ get().tunnelManager.restoreState(true).whenComplete(ExceptionLoggers.D);
+ });
+ }
+ app.backend = backend;
+ }
+ return app.backend;
+ }
+ }
+
+ public static CompletableFuture<Backend> getBackendAsync() {
+ return get().futureBackend;
+ }
+
+ public static RootShell getRootShell() {
+ return get().rootShell;
+ }
+
+ public static SharedPreferences getSharedPreferences() {
+ return get().sharedPreferences;
+ }
+
+ public static ToolsInstaller getToolsInstaller() {
+ return get().toolsInstaller;
+ }
+
+ public static ModuleLoader getModuleLoader() {
+ return get().moduleLoader;
+ }
+
+ public static TunnelManager getTunnelManager() {
+ return get().tunnelManager;
+ }
+
+ @Override
+ protected void attachBaseContext(final Context context) {
+ super.attachBaseContext(context);
+
+ if (BuildConfig.MIN_SDK_VERSION > Build.VERSION.SDK_INT) {
+ final Intent intent = new 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);
+ }
+
+ if (BuildConfig.DEBUG) {
+ StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectAll().penaltyLog().build());
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ Log.i(TAG, USER_AGENT);
+ super.onCreate();
+
+ asyncWorker = new AsyncWorker(AsyncTask.SERIAL_EXECUTOR, new Handler(Looper.getMainLooper()));
+ rootShell = new RootShell(getApplicationContext());
+ toolsInstaller = new ToolsInstaller(getApplicationContext(), rootShell);
+ moduleLoader = new ModuleLoader(getApplicationContext(), rootShell, USER_AGENT);
+
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ AppCompatDelegate.setDefaultNightMode(
+ sharedPreferences.getBoolean("dark_theme", false) ?
+ AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO);
+ } else {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
+ }
+
+ tunnelManager = new TunnelManager(new FileConfigStore(getApplicationContext()));
+ tunnelManager.onCreate();
+
+ asyncWorker.supplyAsync(Application::getBackend).thenAccept(futureBackend::complete);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java
new file mode 100644
index 00000000..e3ffce7a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright © 2017-2019 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.model.TunnelManager;
+import com.wireguard.android.util.ExceptionLoggers;
+
+public class BootShutdownReceiver extends BroadcastReceiver {
+ private static final String TAG = "WireGuard/" + BootShutdownReceiver.class.getSimpleName();
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ Application.getBackendAsync().thenAccept(backend -> {
+ if (!(backend instanceof WgQuickBackend))
+ return;
+ final String action = intent.getAction();
+ if (action == null)
+ return;
+ final TunnelManager tunnelManager = Application.getTunnelManager();
+ if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
+ Log.i(TAG, "Broadcast receiver restoring state (boot)");
+ tunnelManager.restoreState(false).whenComplete(ExceptionLoggers.D);
+ } else if (Intent.ACTION_SHUTDOWN.equals(action)) {
+ Log.i(TAG, "Broadcast receiver saving state (shutdown)");
+ tunnelManager.saveState();
+ }
+ });
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/QuickTileService.java b/ui/src/main/java/com/wireguard/android/QuickTileService.java
new file mode 100644
index 00000000..66aecec3
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/QuickTileService.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android;
+
+import android.content.Intent;
+import androidx.databinding.Observable;
+import androidx.databinding.Observable.OnPropertyChangedCallback;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.Icon;
+import android.os.Build;
+import android.os.IBinder;
+import android.service.quicksettings.Tile;
+import android.service.quicksettings.TileService;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import android.util.Log;
+
+import com.wireguard.android.activity.MainActivity;
+import com.wireguard.android.activity.TunnelToggleActivity;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.widget.SlashDrawable;
+
+import java.util.Objects;
+
+/**
+ * Service that maintains the application's custom Quick Settings tile. This service is bound by the
+ * system framework as necessary to update the appearance of the tile in the system UI, and to
+ * forward click events to the application.
+ */
+
+@RequiresApi(Build.VERSION_CODES.N)
+public class QuickTileService extends TileService {
+ private static final String TAG = "WireGuard/" + QuickTileService.class.getSimpleName();
+
+ private final OnStateChangedCallback onStateChangedCallback = new OnStateChangedCallback();
+ private final OnTunnelChangedCallback onTunnelChangedCallback = new OnTunnelChangedCallback();
+ @Nullable private Icon iconOff;
+ @Nullable private Icon iconOn;
+ @Nullable private ObservableTunnel tunnel;
+
+ /* This works around an annoying unsolved frameworks bug some people are hitting. */
+ @Override
+ @Nullable
+ public IBinder onBind(final Intent intent) {
+ IBinder ret = null;
+ try {
+ ret = super.onBind(intent);
+ } catch (final Exception e) {
+ Log.d(TAG, "Failed to bind to TileService", e);
+ }
+ return ret;
+ }
+
+ @Override
+ public void onClick() {
+ if (tunnel != null) {
+ unlockAndRun(() -> {
+ final Tile tile = getQsTile();
+ if (tile != null) {
+ tile.setIcon(tile.getIcon() == iconOn ? iconOff : iconOn);
+ tile.updateTile();
+ }
+ tunnel.setState(State.TOGGLE).whenComplete((v, t) -> {
+ if (t == null) {
+ updateTile();
+ } else {
+ final Intent toggleIntent = new Intent(this, TunnelToggleActivity.class);
+ toggleIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(toggleIntent);
+ }
+ });
+ });
+ } else {
+ final Intent intent = new Intent(this, MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivityAndCollapse(intent);
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ iconOff = iconOn = Icon.createWithResource(this, R.drawable.ic_tile);
+ return;
+ }
+ final SlashDrawable icon = new SlashDrawable(getResources().getDrawable(R.drawable.ic_tile, Application.get().getTheme()));
+ icon.setAnimationEnabled(false); /* Unfortunately we can't have animations, since Icons are marshaled. */
+ icon.setSlashed(false);
+ Bitmap b = Bitmap.createBitmap(icon.getIntrinsicWidth(), icon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ Canvas c = new Canvas(b);
+ icon.setBounds(0, 0, c.getWidth(), c.getHeight());
+ icon.draw(c);
+ iconOn = Icon.createWithBitmap(b);
+ icon.setSlashed(true);
+ b = Bitmap.createBitmap(icon.getIntrinsicWidth(), icon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ c = new Canvas(b);
+ icon.setBounds(0, 0, c.getWidth(), c.getHeight());
+ icon.draw(c);
+ iconOff = Icon.createWithBitmap(b);
+ }
+
+ @Override
+ public void onStartListening() {
+ Application.getTunnelManager().addOnPropertyChangedCallback(onTunnelChangedCallback);
+ if (tunnel != null)
+ tunnel.addOnPropertyChangedCallback(onStateChangedCallback);
+ updateTile();
+ }
+
+ @Override
+ public void onStopListening() {
+ if (tunnel != null)
+ tunnel.removeOnPropertyChangedCallback(onStateChangedCallback);
+ Application.getTunnelManager().removeOnPropertyChangedCallback(onTunnelChangedCallback);
+ }
+
+ private void updateTile() {
+ // Update the tunnel.
+ final ObservableTunnel newTunnel = Application.getTunnelManager().getLastUsedTunnel();
+ if (newTunnel != tunnel) {
+ if (tunnel != null)
+ tunnel.removeOnPropertyChangedCallback(onStateChangedCallback);
+ tunnel = newTunnel;
+ if (tunnel != null)
+ tunnel.addOnPropertyChangedCallback(onStateChangedCallback);
+ }
+ // Update the tile contents.
+ final String label;
+ final int state;
+ final Tile tile = getQsTile();
+ if (tunnel != null) {
+ label = tunnel.getName();
+ state = tunnel.getState() == State.UP ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE;
+ } else {
+ label = getString(R.string.app_name);
+ state = Tile.STATE_INACTIVE;
+ }
+ if (tile == null)
+ return;
+ tile.setLabel(label);
+ if (tile.getState() != state) {
+ tile.setIcon(state == Tile.STATE_ACTIVE ? iconOn : iconOff);
+ tile.setState(state);
+ }
+ tile.updateTile();
+ }
+
+ private final class OnStateChangedCallback extends OnPropertyChangedCallback {
+ @Override
+ public void onPropertyChanged(final Observable sender, final int propertyId) {
+ if (!Objects.equals(sender, tunnel)) {
+ sender.removeOnPropertyChangedCallback(this);
+ return;
+ }
+ if (propertyId != 0 && propertyId != BR.state)
+ return;
+ updateTile();
+ }
+ }
+
+ private final class OnTunnelChangedCallback extends OnPropertyChangedCallback {
+ @Override
+ public void onPropertyChanged(final Observable sender, final int propertyId) {
+ if (propertyId != 0 && propertyId != BR.lastUsedTunnel)
+ return;
+ updateTile();
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/BaseActivity.java b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.java
new file mode 100644
index 00000000..8ec58ee8
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import androidx.databinding.CallbackRegistry;
+import androidx.databinding.CallbackRegistry.NotifierCallback;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.model.ObservableTunnel;
+
+import java.util.Objects;
+
+/**
+ * Base class for activities that need to remember the currently-selected tunnel.
+ */
+
+public abstract class BaseActivity extends ThemeChangeAwareActivity {
+ private static final String KEY_SELECTED_TUNNEL = "selected_tunnel";
+
+ private final SelectionChangeRegistry selectionChangeRegistry = new SelectionChangeRegistry();
+ @Nullable private ObservableTunnel selectedTunnel;
+
+ public void addOnSelectedTunnelChangedListener(final OnSelectedTunnelChangedListener listener) {
+ selectionChangeRegistry.add(listener);
+ }
+
+ @Nullable
+ public ObservableTunnel getSelectedTunnel() {
+ return selectedTunnel;
+ }
+
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ // Restore the saved tunnel if there is one; otherwise grab it from the arguments.
+ final String savedTunnelName;
+ if (savedInstanceState != null)
+ savedTunnelName = savedInstanceState.getString(KEY_SELECTED_TUNNEL);
+ else if (getIntent() != null)
+ savedTunnelName = getIntent().getStringExtra(KEY_SELECTED_TUNNEL);
+ else
+ savedTunnelName = null;
+
+ if (savedTunnelName != null)
+ Application.getTunnelManager().getTunnels()
+ .thenAccept(tunnels -> setSelectedTunnel(tunnels.get(savedTunnelName)));
+
+ // The selected tunnel must be set before the superclass method recreates fragments.
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ protected void onSaveInstanceState(final Bundle outState) {
+ if (selectedTunnel != null)
+ outState.putString(KEY_SELECTED_TUNNEL, selectedTunnel.getName());
+ super.onSaveInstanceState(outState);
+ }
+
+ protected abstract void onSelectedTunnelChanged(@Nullable ObservableTunnel oldTunnel, @Nullable ObservableTunnel newTunnel);
+
+ public void removeOnSelectedTunnelChangedListener(
+ final OnSelectedTunnelChangedListener listener) {
+ selectionChangeRegistry.remove(listener);
+ }
+
+ public void setSelectedTunnel(@Nullable final ObservableTunnel tunnel) {
+ final ObservableTunnel oldTunnel = selectedTunnel;
+ if (Objects.equals(oldTunnel, tunnel))
+ return;
+ selectedTunnel = tunnel;
+ onSelectedTunnelChanged(oldTunnel, tunnel);
+ selectionChangeRegistry.notifyCallbacks(oldTunnel, 0, tunnel);
+ }
+
+ public interface OnSelectedTunnelChangedListener {
+ void onSelectedTunnelChanged(@Nullable ObservableTunnel oldTunnel, @Nullable ObservableTunnel newTunnel);
+ }
+
+ private static final class SelectionChangeNotifier
+ extends NotifierCallback<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel> {
+ @Override
+ public void onNotifyCallback(final OnSelectedTunnelChangedListener listener,
+ final ObservableTunnel oldTunnel, final int ignored,
+ final ObservableTunnel newTunnel) {
+ listener.onSelectedTunnelChanged(oldTunnel, newTunnel);
+ }
+ }
+
+ private static final class SelectionChangeRegistry
+ extends CallbackRegistry<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel> {
+ private SelectionChangeRegistry() {
+ super(new SelectionChangeNotifier());
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/MainActivity.java b/ui/src/main/java/com/wireguard/android/activity/MainActivity.java
new file mode 100644
index 00000000..4c33f000
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/MainActivity.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import android.annotation.SuppressLint;
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+import androidx.appcompat.app.ActionBar;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View.OnApplyWindowInsetsListener;
+import android.widget.LinearLayout;
+
+import com.wireguard.android.R;
+import com.wireguard.android.fragment.TunnelDetailFragment;
+import com.wireguard.android.fragment.TunnelEditorFragment;
+import com.wireguard.android.model.ObservableTunnel;
+
+import java.util.List;
+
+/**
+ * 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.
+ */
+
+public class MainActivity extends BaseActivity
+ implements FragmentManager.OnBackStackChangedListener {
+ @Nullable private ActionBar actionBar;
+ private boolean isTwoPaneLayout;
+
+ @Override
+ public void onBackPressed() {
+ final int backStackEntries = getSupportFragmentManager().getBackStackEntryCount();
+ // If the two-pane layout does not have an editor open, going back should exit the app.
+ if (isTwoPaneLayout && backStackEntries <= 1) {
+ finish();
+ return;
+ }
+ // Deselect the current tunnel on navigating back from the detail pane to the one-pane list.
+ if (!isTwoPaneLayout && backStackEntries == 1) {
+ getSupportFragmentManager().popBackStack();
+ setSelectedTunnel(null);
+ return;
+ }
+ super.onBackPressed();
+ }
+
+ @Override public void onBackStackChanged() {
+ if (actionBar == null)
+ return;
+ // Do not show the home menu when the two-pane layout is at the detail view (see above).
+ final int backStackEntries = getSupportFragmentManager().getBackStackEntryCount();
+ final int minBackStackEntries = isTwoPaneLayout ? 2 : 1;
+ actionBar.setDisplayHomeAsUpEnabled(backStackEntries >= minBackStackEntries);
+ }
+
+ // We use onTouchListener here to avoid the UI click sound, hence
+ // calling View#performClick defeats the purpose of it.
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.main_activity);
+ actionBar = getSupportActionBar();
+ isTwoPaneLayout = findViewById(R.id.master_detail_wrapper) instanceof LinearLayout;
+ getSupportFragmentManager().addOnBackStackChangedListener(this);
+ onBackStackChanged();
+ // Dispatch insets on back stack change
+ // This is required to ensure replaced fragments are also able to consume insets
+ findViewById(R.id.master_detail_wrapper).setOnApplyWindowInsetsListener((OnApplyWindowInsetsListener) (v, insets) -> {
+ final FragmentManager fragmentManager = getSupportFragmentManager();
+ fragmentManager.addOnBackStackChangedListener(() -> {
+ final List<Fragment> fragments = fragmentManager.getFragments();
+ for (int i = 0; i < fragments.size(); i++) {
+ fragments.get(i).requireView().dispatchApplyWindowInsets(insets);
+ }
+ });
+ return insets;
+ });
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ getMenuInflater().inflate(R.menu.main_activity, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ // The back arrow in the action bar should act the same as the back button.
+ onBackPressed();
+ return true;
+ case R.id.menu_action_edit:
+ getSupportFragmentManager().beginTransaction()
+ .replace(R.id.detail_container, new TunnelEditorFragment())
+ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ .addToBackStack(null)
+ .commit();
+ return true;
+ case R.id.menu_action_save:
+ // This menu item is handled by the editor fragment.
+ return false;
+ case R.id.menu_settings:
+ startActivity(new Intent(this, SettingsActivity.class));
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ protected void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel,
+ @Nullable final ObservableTunnel newTunnel) {
+ final FragmentManager fragmentManager = getSupportFragmentManager();
+ final int backStackEntries = fragmentManager.getBackStackEntryCount();
+ if (newTunnel == null) {
+ // Clear everything off the back stack (all editors and detail fragments).
+ fragmentManager.popBackStackImmediate(0, FragmentManager.POP_BACK_STACK_INCLUSIVE);
+ return;
+ }
+ 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.beginTransaction()
+ .add(R.id.detail_container, new TunnelDetailFragment())
+ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ .addToBackStack(null)
+ .commit();
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java
new file mode 100644
index 00000000..f545c371
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+import android.util.SparseArray;
+import android.view.MenuItem;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.backend.WgQuickBackend;
+import com.wireguard.android.util.ModuleLoader;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Interface for changing application-global persistent settings.
+ */
+
+public class SettingsActivity extends ThemeChangeAwareActivity {
+ private final SparseArray<PermissionRequestCallback> permissionRequestCallbacks = new SparseArray<>();
+ private int permissionRequestCounter;
+
+ public void ensurePermissions(final String[] permissions, final PermissionRequestCallback cb) {
+ final List<String> needPermissions = new ArrayList<>(permissions.length);
+ for (final String permission : permissions) {
+ if (ContextCompat.checkSelfPermission(this, permission)
+ != PackageManager.PERMISSION_GRANTED)
+ needPermissions.add(permission);
+ }
+ if (needPermissions.isEmpty()) {
+ final int[] granted = new int[permissions.length];
+ Arrays.fill(granted, PackageManager.PERMISSION_GRANTED);
+ cb.done(permissions, granted);
+ return;
+ }
+ final int idx = permissionRequestCounter++;
+ permissionRequestCallbacks.put(idx, cb);
+ ActivityCompat.requestPermissions(this,
+ needPermissions.toArray(new String[needPermissions.size()]), idx);
+ }
+
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getSupportFragmentManager().findFragmentById(android.R.id.content) == null) {
+ getSupportFragmentManager().beginTransaction()
+ .add(android.R.id.content, new SettingsFragment())
+ .commit();
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ finish();
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(final int requestCode,
+ final String[] permissions,
+ final int[] grantResults) {
+ final PermissionRequestCallback f = permissionRequestCallbacks.get(requestCode);
+ if (f != null) {
+ permissionRequestCallbacks.remove(requestCode);
+ f.done(permissions, grantResults);
+ }
+ }
+
+ public interface PermissionRequestCallback {
+ void done(String[] permissions, int[] grantResults);
+ }
+
+ public static class SettingsFragment extends PreferenceFragmentCompat {
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String key) {
+ addPreferencesFromResource(R.xml.preferences);
+ final PreferenceScreen screen = getPreferenceScreen();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ screen.removePreference(getPreferenceManager().findPreference("dark_theme"));
+
+ final Preference wgQuickOnlyPrefs[] = {
+ getPreferenceManager().findPreference("tools_installer"),
+ getPreferenceManager().findPreference("restore_on_boot")
+ };
+ for (final Preference pref : wgQuickOnlyPrefs)
+ pref.setVisible(false);
+ Application.getBackendAsync().thenAccept(backend -> {
+ for (final Preference pref : wgQuickOnlyPrefs) {
+ if (backend instanceof WgQuickBackend)
+ pref.setVisible(true);
+ else
+ screen.removePreference(pref);
+ }
+ });
+
+ final Preference moduleInstaller = getPreferenceManager().findPreference("module_downloader");
+ moduleInstaller.setVisible(false);
+ if (ModuleLoader.isModuleLoaded()) {
+ screen.removePreference(moduleInstaller);
+ } else {
+ Application.getAsyncWorker().runAsync(Application.getRootShell()::start).whenComplete((v, e) -> {
+ if (e == null)
+ moduleInstaller.setVisible(true);
+ else
+ screen.removePreference(moduleInstaller);
+ });
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java b/ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java
new file mode 100644
index 00000000..602ad37c
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
+import android.util.Log;
+
+import com.wireguard.android.Application;
+
+import java.lang.reflect.Field;
+
+public abstract class ThemeChangeAwareActivity extends AppCompatActivity implements SharedPreferences.OnSharedPreferenceChangeListener {
+ private static final String TAG = "WireGuard/" + ThemeChangeAwareActivity.class.getSimpleName();
+
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
+ Application.getSharedPreferences().registerOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
+ Application.getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this);
+ super.onDestroy();
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) {
+ if ("dark_theme".equals(key)) {
+ AppCompatDelegate.setDefaultNightMode(
+ sharedPreferences.getBoolean(key, false) ?
+ AppCompatDelegate.MODE_NIGHT_YES :
+ AppCompatDelegate.MODE_NIGHT_NO);
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java
new file mode 100644
index 00000000..c87ec537
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.fragment.TunnelEditorFragment;
+import com.wireguard.android.model.ObservableTunnel;
+
+/**
+ * Standalone activity for creating tunnels.
+ */
+
+public class TunnelCreatorActivity extends BaseActivity {
+ @Override
+ @SuppressWarnings("UnnecessaryFullyQualifiedName")
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getSupportFragmentManager().findFragmentById(android.R.id.content) == null) {
+ getSupportFragmentManager().beginTransaction()
+ .add(android.R.id.content, new TunnelEditorFragment())
+ .commit();
+ }
+ }
+
+ @Override
+ protected void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) {
+ finish();
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java
new file mode 100644
index 00000000..09a34bf7
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.appcompat.app.AppCompatActivity;
+
+import android.content.ComponentName;
+import android.os.Bundle;
+import android.os.Build;
+import android.service.quicksettings.TileService;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.QuickTileService;
+import com.wireguard.android.R;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.util.ErrorMessages;
+
+@RequiresApi(Build.VERSION_CODES.N)
+public class TunnelToggleActivity extends AppCompatActivity {
+ private static final String TAG = "WireGuard/" + TunnelToggleActivity.class.getSimpleName();
+
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final ObservableTunnel tunnel = Application.getTunnelManager().getLastUsedTunnel();
+ if (tunnel == null)
+ return;
+ tunnel.setState(State.TOGGLE).whenComplete((v, t) -> {
+ TileService.requestListeningState(this, new ComponentName(this, QuickTileService.class));
+ onToggleFinished(t);
+ finishAffinity();
+ });
+ }
+
+ private void onToggleFinished(@Nullable final Throwable throwable) {
+ if (throwable == null)
+ return;
+ final String error = ErrorMessages.get(throwable);
+ final String message = getString(R.string.toggle_error, error);
+ Log.e(TAG, message, throwable);
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show();
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java
new file mode 100644
index 00000000..d4761464
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.configStore;
+
+import com.wireguard.config.Config;
+
+import java.util.Set;
+
+/**
+ * Interface for persistent storage providers for WireGuard configurations.
+ */
+
+public 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.
+ */
+ Config create(final String name, final Config config) throws Exception;
+
+ /**
+ * Delete a persistent tunnel.
+ *
+ * @param name The name of the tunnel to delete.
+ */
+ void delete(final String name) throws Exception;
+
+ /**
+ * Enumerate the names of tunnels present in persistent storage.
+ *
+ * @return The set of present tunnel names.
+ */
+ Set<String> enumerate();
+
+ /**
+ * Load the configuration for the tunnel given by {@code 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.
+ */
+ Config load(final String name) throws Exception;
+
+ /**
+ * Rename the configuration for the tunnel given by {@code name}.
+ *
+ * @param name The identifier for the existing configuration in persistent storage.
+ * @param replacement The new identifier for the configuration in persistent storage.
+ */
+ void rename(String name, String replacement) throws Exception;
+
+ /**
+ * Save the configuration for an existing tunnel given by {@code 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.
+ */
+ Config save(final String name, final Config config) throws Exception;
+}
diff --git a/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java
new file mode 100644
index 00000000..45f2f759
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright © 2017-2019 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;
+import java.util.Set;
+
+import java9.util.stream.Collectors;
+import java9.util.stream.Stream;
+
+/**
+ * Configuration store that uses a {@code wg-quick}-style file for each configured tunnel.
+ */
+
+public final class FileConfigStore implements ConfigStore {
+ private static final String TAG = "WireGuard/" + FileConfigStore.class.getSimpleName();
+
+ private final Context context;
+
+ public FileConfigStore(final Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public Config create(final String name, final Config config) throws IOException {
+ Log.d(TAG, "Creating configuration for tunnel " + name);
+ final File file = fileFor(name);
+ if (!file.createNewFile())
+ throw new IOException(context.getString(R.string.config_file_exists_error, file.getName()));
+ try (final FileOutputStream stream = new FileOutputStream(file, false)) {
+ stream.write(config.toWgQuickString().getBytes(StandardCharsets.UTF_8));
+ }
+ return config;
+ }
+
+ @Override
+ public void delete(final String name) throws IOException {
+ Log.d(TAG, "Deleting configuration for tunnel " + name);
+ final File file = fileFor(name);
+ if (!file.delete())
+ throw new IOException(context.getString(R.string.config_delete_error, file.getName()));
+ }
+
+ @Override
+ public Set<String> enumerate() {
+ return Stream.of(context.fileList())
+ .filter(name -> name.endsWith(".conf"))
+ .map(name -> name.substring(0, name.length() - ".conf".length()))
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ private File fileFor(final String name) {
+ return new File(context.getFilesDir(), name + ".conf");
+ }
+
+ @Override
+ public Config load(final String name) throws BadConfigException, IOException {
+ try (final FileInputStream stream = new FileInputStream(fileFor(name))) {
+ return Config.parse(stream);
+ }
+ }
+
+ @Override
+ public void rename(final String name, final String replacement) throws IOException {
+ Log.d(TAG, "Renaming configuration for tunnel " + name + " to " + replacement);
+ final File file = fileFor(name);
+ final File replacementFile = fileFor(replacement);
+ if (!replacementFile.createNewFile())
+ throw new 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 new IOException(context.getString(R.string.config_rename_error, file.getName()));
+ }
+ }
+
+ @Override
+ public Config save(final String name, final Config config) throws IOException {
+ Log.d(TAG, "Saving configuration for tunnel " + name);
+ final File file = fileFor(name);
+ if (!file.isFile())
+ throw new FileNotFoundException(context.getString(R.string.config_not_found_error, file.getName()));
+ try (final FileOutputStream stream = new FileOutputStream(file, false)) {
+ stream.write(config.toWgQuickString().getBytes(StandardCharsets.UTF_8));
+ }
+ return config;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java
new file mode 100644
index 00000000..ee216d4c
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.databinding;
+
+import androidx.databinding.BindingAdapter;
+import androidx.databinding.DataBindingUtil;
+import androidx.databinding.ObservableList;
+import androidx.databinding.ViewDataBinding;
+import androidx.databinding.adapters.ListenerUtil;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import android.text.InputFilter;
+import android.view.LayoutInflater;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.wireguard.android.BR;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler;
+import com.wireguard.android.util.ObservableKeyedList;
+import com.wireguard.android.widget.ToggleSwitch;
+import com.wireguard.android.widget.ToggleSwitch.OnBeforeCheckedChangeListener;
+import com.wireguard.config.Attribute;
+import com.wireguard.config.InetNetwork;
+import com.wireguard.util.Keyed;
+
+import java9.util.Optional;
+
+/**
+ * Static methods for use by generated code in the Android data binding library.
+ */
+
+@SuppressWarnings("unused")
+public final class BindingAdapters {
+ private BindingAdapters() {
+ // Prevent instantiation.
+ }
+
+ @BindingAdapter("checked")
+ public static void setChecked(final ToggleSwitch view, final boolean checked) {
+ view.setCheckedInternal(checked);
+ }
+
+ @BindingAdapter("filter")
+ public static void setFilter(final TextView view, final InputFilter filter) {
+ view.setFilters(new InputFilter[]{filter});
+ }
+
+ @BindingAdapter({"items", "layout"})
+ public static <E>
+ void setItems(final LinearLayout view,
+ @Nullable final ObservableList<E> oldList, final int oldLayoutId,
+ @Nullable final ObservableList<E> newList, final int newLayoutId) {
+ if (oldList == newList && oldLayoutId == newLayoutId)
+ return;
+ ItemChangeListener<E> listener = 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(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 = new ItemChangeListener<>(view, newLayoutId);
+ 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);
+ }
+
+ @BindingAdapter({"items", "layout"})
+ public static <E>
+ void setItems(final LinearLayout view,
+ @Nullable final Iterable<E> oldList, final int oldLayoutId,
+ @Nullable final Iterable<E> newList, final int newLayoutId) {
+ if (oldList == newList && oldLayoutId == newLayoutId)
+ return;
+ view.removeAllViews();
+ if (newList == null)
+ return;
+ final LayoutInflater layoutInflater = LayoutInflater.from(view.getContext());
+ for (final E item : newList) {
+ final ViewDataBinding binding =
+ DataBindingUtil.inflate(layoutInflater, newLayoutId, view, false);
+ binding.setVariable(BR.collection, newList);
+ binding.setVariable(BR.item, item);
+ binding.executePendingBindings();
+ view.addView(binding.getRoot());
+ }
+ }
+
+ @BindingAdapter(requireAll = false, value = {"items", "layout", "configurationHandler"})
+ public static <K, E extends Keyed<? extends K>>
+ void setItems(final RecyclerView view,
+ @Nullable final ObservableKeyedList<K, E> oldList, final int oldLayoutId,
+ final RowConfigurationHandler oldRowConfigurationHandler,
+ @Nullable final ObservableKeyedList<K, E> newList, final int newLayoutId,
+ final RowConfigurationHandler newRowConfigurationHandler) {
+ if (view.getLayoutManager() == null)
+ view.setLayoutManager(new LinearLayoutManager(view.getContext(), RecyclerView.VERTICAL, false));
+
+ if (oldList == newList && oldLayoutId == newLayoutId)
+ return;
+ // The ListAdapter interface is not generic, so this cannot be checked.
+ @SuppressWarnings("unchecked") ObservableKeyedRecyclerViewAdapter<K, E> adapter =
+ (ObservableKeyedRecyclerViewAdapter<K, E>) view.getAdapter();
+ // 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 = new ObservableKeyedRecyclerViewAdapter<>(view.getContext(), newLayoutId, newList);
+ view.setAdapter(adapter);
+ }
+
+ adapter.setRowConfigurationHandler(newRowConfigurationHandler);
+ // Either the list changed, or this is an entirely new listener because the layout changed.
+ adapter.setList(newList);
+ }
+
+ @BindingAdapter("onBeforeCheckedChanged")
+ public static void setOnBeforeCheckedChanged(final ToggleSwitch view,
+ final OnBeforeCheckedChangeListener listener) {
+ view.setOnBeforeCheckedChangeListener(listener);
+ }
+
+ @BindingAdapter("android:text")
+ public static void setText(final TextView view, final Optional<?> text) {
+ view.setText(text.map(Object::toString).orElse(""));
+ }
+
+ @BindingAdapter("android:text")
+ public static void setText(final TextView view, @Nullable final Iterable<InetNetwork> networks) {
+ view.setText(networks != null ? Attribute.join(networks) : "");
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java
new file mode 100644
index 00000000..e7303eae
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.databinding;
+
+import androidx.databinding.DataBindingUtil;
+import androidx.databinding.ObservableList;
+import androidx.databinding.ViewDataBinding;
+import androidx.annotation.Nullable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.wireguard.android.BR;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+/**
+ * Helper class for binding an ObservableList to the children of a ViewGroup.
+ */
+
+class ItemChangeListener<T> {
+ private final OnListChangedCallback<T> callback = new OnListChangedCallback<>(this);
+ private final ViewGroup container;
+ private final int layoutId;
+ private final LayoutInflater layoutInflater;
+ @Nullable private ObservableList<T> list;
+
+ ItemChangeListener(final ViewGroup container, final int layoutId) {
+ this.container = container;
+ this.layoutId = layoutId;
+ layoutInflater = LayoutInflater.from(container.getContext());
+ }
+
+ private View getView(final int position, @Nullable final View convertView) {
+ ViewDataBinding binding = convertView != null ? DataBindingUtil.getBinding(convertView) : null;
+ if (binding == null) {
+ binding = DataBindingUtil.inflate(layoutInflater, layoutId, container, false);
+ }
+
+ Objects.requireNonNull(list, "Trying to get a view while list is still null");
+
+ binding.setVariable(BR.collection, list);
+ binding.setVariable(BR.item, list.get(position));
+ binding.executePendingBindings();
+ return binding.getRoot();
+ }
+
+ void setList(@Nullable final ObservableList<T> newList) {
+ if (list != null)
+ list.removeOnListChangedCallback(callback);
+ list = newList;
+ if (list != null) {
+ list.addOnListChangedCallback(callback);
+ callback.onChanged(list);
+ } else {
+ container.removeAllViews();
+ }
+ }
+
+ private static final class OnListChangedCallback<T>
+ extends ObservableList.OnListChangedCallback<ObservableList<T>> {
+
+ private final WeakReference<ItemChangeListener<T>> weakListener;
+
+ private OnListChangedCallback(final ItemChangeListener<T> listener) {
+ weakListener = new WeakReference<>(listener);
+ }
+
+ @Override
+ public void onChanged(final ObservableList<T> sender) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ // TODO: recycle views
+ listener.container.removeAllViews();
+ for (int i = 0; i < sender.size(); ++i)
+ listener.container.addView(listener.getView(i, null));
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+
+ @Override
+ public void onItemRangeChanged(final ObservableList<T> sender, final int positionStart,
+ final int itemCount) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ for (int i = positionStart; i < positionStart + itemCount; ++i) {
+ final View child = listener.container.getChildAt(i);
+ listener.container.removeViewAt(i);
+ listener.container.addView(listener.getView(i, child));
+ }
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+
+ @Override
+ public void onItemRangeInserted(final ObservableList<T> sender, final int positionStart,
+ final int itemCount) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ for (int i = positionStart; i < positionStart + itemCount; ++i)
+ listener.container.addView(listener.getView(i, null));
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+
+ @Override
+ public void onItemRangeMoved(final ObservableList<T> sender, final int fromPosition,
+ final int toPosition, final int itemCount) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ final View[] views = new View[itemCount];
+ for (int i = 0; i < itemCount; ++i)
+ views[i] = listener.container.getChildAt(fromPosition + i);
+ listener.container.removeViews(fromPosition, itemCount);
+ for (int i = 0; i < itemCount; ++i)
+ listener.container.addView(views[i], toPosition + i);
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+
+ @Override
+ public void onItemRangeRemoved(final ObservableList<T> sender, final int positionStart,
+ final int itemCount) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ listener.container.removeViews(positionStart, itemCount);
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java
new file mode 100644
index 00000000..8b40dd91
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.databinding;
+
+import android.content.Context;
+import androidx.databinding.DataBindingUtil;
+import androidx.databinding.ObservableList;
+import androidx.databinding.ViewDataBinding;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.RecyclerView.Adapter;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import com.wireguard.android.BR;
+import com.wireguard.android.util.ObservableKeyedList;
+import com.wireguard.util.Keyed;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * A generic {@code RecyclerView.Adapter} backed by a {@code ObservableKeyedList}.
+ */
+
+public class ObservableKeyedRecyclerViewAdapter<K, E extends Keyed<? extends K>> extends Adapter<ObservableKeyedRecyclerViewAdapter.ViewHolder> {
+
+ private final OnListChangedCallback<E> callback = new OnListChangedCallback<>(this);
+ private final int layoutId;
+ private final LayoutInflater layoutInflater;
+ @Nullable private ObservableKeyedList<K, E> list;
+ @Nullable private RowConfigurationHandler rowConfigurationHandler;
+
+ ObservableKeyedRecyclerViewAdapter(final Context context, final int layoutId,
+ final ObservableKeyedList<K, E> list) {
+ this.layoutId = layoutId;
+ layoutInflater = LayoutInflater.from(context);
+ setList(list);
+ }
+
+ @Nullable
+ private E getItem(final int position) {
+ if (list == null || position < 0 || position >= list.size())
+ return null;
+ return list.get(position);
+ }
+
+ @Override
+ public int getItemCount() {
+ return list != null ? list.size() : 0;
+ }
+
+ @Override
+ public long getItemId(final int position) {
+ final K key = getKey(position);
+ return key != null ? key.hashCode() : -1;
+ }
+
+ @Nullable
+ private K getKey(final int position) {
+ final E item = getItem(position);
+ return item != null ? item.getKey() : null;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public void onBindViewHolder(final ViewHolder holder, final int position) {
+ 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) {
+ final E item = getItem(position);
+ if (item != null) {
+ rowConfigurationHandler.onConfigureRow(holder.binding, item, position);
+ }
+ }
+ }
+
+ @Override
+ public ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
+ return new ViewHolder(DataBindingUtil.inflate(layoutInflater, layoutId, parent, false));
+ }
+
+ void setList(@Nullable final ObservableKeyedList<K, E> newList) {
+ if (list != null)
+ list.removeOnListChangedCallback(callback);
+ list = newList;
+ if (list != null) {
+ list.addOnListChangedCallback(callback);
+ }
+ notifyDataSetChanged();
+ }
+
+ void setRowConfigurationHandler(final RowConfigurationHandler rowConfigurationHandler) {
+ this.rowConfigurationHandler = rowConfigurationHandler;
+ }
+
+ public interface RowConfigurationHandler<B extends ViewDataBinding, T> {
+ void onConfigureRow(B binding, T item, int position);
+ }
+
+ private static final class OnListChangedCallback<E extends Keyed<?>>
+ extends ObservableList.OnListChangedCallback<ObservableList<E>> {
+
+ private final WeakReference<ObservableKeyedRecyclerViewAdapter<?, E>> weakAdapter;
+
+ private OnListChangedCallback(final ObservableKeyedRecyclerViewAdapter<?, E> adapter) {
+ weakAdapter = new WeakReference<>(adapter);
+ }
+
+ @Override
+ public void onChanged(final ObservableList<E> sender) {
+ final ObservableKeyedRecyclerViewAdapter adapter = weakAdapter.get();
+ if (adapter != null)
+ adapter.notifyDataSetChanged();
+ else
+ sender.removeOnListChangedCallback(this);
+ }
+
+ @Override
+ public void onItemRangeChanged(final ObservableList<E> sender, final int positionStart,
+ final int itemCount) {
+ onChanged(sender);
+ }
+
+ @Override
+ public void onItemRangeInserted(final ObservableList<E> sender, final int positionStart,
+ final int itemCount) {
+ onChanged(sender);
+ }
+
+ @Override
+ public void onItemRangeMoved(final ObservableList<E> sender, final int fromPosition,
+ final int toPosition, final int itemCount) {
+ onChanged(sender);
+ }
+
+ @Override
+ public void onItemRangeRemoved(final ObservableList<E> sender, final int positionStart,
+ final int itemCount) {
+ onChanged(sender);
+ }
+ }
+
+ public static class ViewHolder extends RecyclerView.ViewHolder {
+ final ViewDataBinding binding;
+
+ public ViewHolder(final ViewDataBinding binding) {
+ super(binding.getRoot());
+
+ this.binding = binding;
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
new file mode 100644
index 00000000..3df141be
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright © 2020 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.Intent
+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.fragment.app.Fragment
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.zxing.integration.android.IntentIntegrator
+import com.wireguard.android.R
+import com.wireguard.android.activity.TunnelCreatorActivity
+import com.wireguard.android.util.resolveAttribute
+
+class AddTunnelsSheet : BottomSheetDialogFragment() {
+
+ private lateinit var behavior: BottomSheetBehavior<FrameLayout>
+ 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 getTheme(): Int {
+ return R.style.BottomSheetDialogTheme
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ if (savedInstanceState != null) dismiss()
+ return inflater.inflate(R.layout.add_tunnels_bottom_sheet, container, false)
+ }
+
+ 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.state = BottomSheetBehavior.STATE_EXPANDED
+ behavior.peekHeight = 0
+ behavior.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(R.attr.colorBackground))
+ }
+ view.background = gradientDrawable
+ }
+
+ override fun dismiss() {
+ super.dismiss()
+ behavior.removeBottomSheetCallback(bottomSheetCallback)
+ }
+
+ private fun requireTargetFragment(): Fragment {
+ return requireNotNull(targetFragment) { "A target fragment should always be set" }
+ }
+
+ private fun onRequestCreateConfig() {
+ startActivity(Intent(activity, TunnelCreatorActivity::class.java))
+ }
+
+ private fun onRequestImportConfig() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "*/*"
+ }
+ requireTargetFragment().startActivityForResult(intent, TunnelListFragment.REQUEST_IMPORT)
+ }
+
+ private fun onRequestScanQRCode() {
+ val integrator = IntentIntegrator.forSupportFragment(requireTargetFragment()).apply {
+ setOrientationLocked(false)
+ setBeepEnabled(false)
+ setPrompt(getString(R.string.qr_code_hint))
+ }
+ integrator.initiateScan(listOf(IntentIntegrator.QR_CODE))
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java
new file mode 100644
index 00000000..43178665
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.appcompat.app.AlertDialog;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.AppListDialogFragmentBinding;
+import com.wireguard.android.model.ApplicationData;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.util.ObservableKeyedArrayList;
+import com.wireguard.android.util.ObservableKeyedList;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import java9.util.Comparators;
+import java9.util.stream.Collectors;
+import java9.util.stream.StreamSupport;
+
+public class AppListDialogFragment extends DialogFragment {
+
+ private static final String KEY_EXCLUDED_APPS = "excludedApps";
+ private final ObservableKeyedList<String, ApplicationData> appData = new ObservableKeyedArrayList<>();
+ private List<String> currentlyExcludedApps = Collections.emptyList();
+
+ public static <T extends Fragment & AppExclusionListener>
+ AppListDialogFragment newInstance(final ArrayList<String> excludedApps, final T target) {
+ final Bundle extras = new Bundle();
+ extras.putStringArrayList(KEY_EXCLUDED_APPS, excludedApps);
+ final AppListDialogFragment fragment = new AppListDialogFragment();
+ fragment.setTargetFragment(target, 0);
+ fragment.setArguments(extras);
+ return fragment;
+ }
+
+ private void loadData() {
+ final Activity activity = getActivity();
+ if (activity == null) {
+ return;
+ }
+
+ final PackageManager pm = activity.getPackageManager();
+ Application.getAsyncWorker().supplyAsync(() -> {
+ final Intent launcherIntent = new Intent(Intent.ACTION_MAIN, null);
+ launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+ final List<ResolveInfo> resolveInfos = pm.queryIntentActivities(launcherIntent, 0);
+
+ final List<ApplicationData> applicationData = new ArrayList<>();
+ for (ResolveInfo resolveInfo : resolveInfos) {
+ String packageName = resolveInfo.activityInfo.packageName;
+ applicationData.add(new ApplicationData(resolveInfo.loadIcon(pm), resolveInfo.loadLabel(pm).toString(), packageName, currentlyExcludedApps.contains(packageName)));
+ }
+
+ Collections.sort(applicationData, Comparators.comparing(ApplicationData::getName, String.CASE_INSENSITIVE_ORDER));
+ return applicationData;
+ }).whenComplete(((data, throwable) -> {
+ if (data != null) {
+ appData.clear();
+ appData.addAll(data);
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ final String message = activity.getString(R.string.error_fetching_apps, error);
+ Toast.makeText(activity, message, Toast.LENGTH_LONG).show();
+ dismissAllowingStateLoss();
+ }
+ }));
+ }
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final List<String> excludedApps = requireArguments().getStringArrayList(KEY_EXCLUDED_APPS);
+ currentlyExcludedApps = (excludedApps != null) ? excludedApps : Collections.emptyList();
+ }
+
+ @Override
+ public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
+ final AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(requireActivity());
+ alertDialogBuilder.setTitle(R.string.excluded_applications);
+
+ final AppListDialogFragmentBinding binding = AppListDialogFragmentBinding.inflate(requireActivity().getLayoutInflater(), null, false);
+ binding.executePendingBindings();
+ alertDialogBuilder.setView(binding.getRoot());
+
+ alertDialogBuilder.setPositiveButton(R.string.set_exclusions, (dialog, which) -> setExclusionsAndDismiss());
+ alertDialogBuilder.setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss());
+ alertDialogBuilder.setNeutralButton(R.string.toggle_all, (dialog, which) -> {
+ });
+
+ binding.setFragment(this);
+ binding.setAppData(appData);
+
+ loadData();
+
+ final AlertDialog dialog = alertDialogBuilder.create();
+ dialog.setOnShowListener(d -> dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener(view -> {
+ final List<ApplicationData> selectedItems = StreamSupport.stream(appData)
+ .filter(ApplicationData::isExcludedFromTunnel)
+ .collect(Collectors.toList());
+ final boolean excludeAll = selectedItems.isEmpty();
+ for (final ApplicationData app : appData)
+ app.setExcludedFromTunnel(excludeAll);
+ }));
+ return dialog;
+ }
+
+ private void setExclusionsAndDismiss() {
+ final List<String> excludedApps = new ArrayList<>();
+ for (final ApplicationData data : appData) {
+ if (data.isExcludedFromTunnel()) {
+ excludedApps.add(data.getPackageName());
+ }
+ }
+
+ ((AppExclusionListener) getTargetFragment()).onExcludedAppsSelected(excludedApps);
+ dismiss();
+ }
+
+ public interface AppExclusionListener {
+ void onExcludedAppsSelected(List<String> excludedApps);
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java
new file mode 100644
index 00000000..23bf44e7
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.content.Context;
+import android.content.Intent;
+import androidx.databinding.DataBindingUtil;
+import androidx.databinding.ViewDataBinding;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+import androidx.fragment.app.Fragment;
+import android.util.Log;
+import android.view.View;
+import android.widget.Toast;
+
+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.databinding.TunnelDetailFragmentBinding;
+import com.wireguard.android.databinding.TunnelListItemBinding;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.util.ErrorMessages;
+
+/**
+ * Base class for fragments that need to know the currently-selected tunnel. Only does anything when
+ * attached to a {@code BaseActivity}.
+ */
+
+public abstract class BaseFragment extends Fragment implements OnSelectedTunnelChangedListener {
+ private static final int REQUEST_CODE_VPN_PERMISSION = 23491;
+ private static final String TAG = "WireGuard/" + BaseFragment.class.getSimpleName();
+ @Nullable private BaseActivity activity;
+ @Nullable private ObservableTunnel pendingTunnel;
+ @Nullable private Boolean pendingTunnelUp;
+
+ @Nullable
+ protected ObservableTunnel getSelectedTunnel() {
+ return activity != null ? activity.getSelectedTunnel() : null;
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == REQUEST_CODE_VPN_PERMISSION) {
+ if (pendingTunnel != null && pendingTunnelUp != null)
+ setTunnelStateWithPermissionsResult(pendingTunnel, pendingTunnelUp);
+ pendingTunnel = null;
+ pendingTunnelUp = null;
+ }
+ }
+
+ @Override
+ public void onAttach(final Context context) {
+ super.onAttach(context);
+ if (context instanceof BaseActivity) {
+ activity = (BaseActivity) context;
+ activity.addOnSelectedTunnelChangedListener(this);
+ } else {
+ activity = null;
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ if (activity != null)
+ activity.removeOnSelectedTunnelChangedListener(this);
+ activity = null;
+ super.onDetach();
+ }
+
+ protected void setSelectedTunnel(@Nullable final ObservableTunnel tunnel) {
+ if (activity != null)
+ activity.setSelectedTunnel(tunnel);
+ }
+
+ public void setTunnelState(final View view, final boolean checked) {
+ final ViewDataBinding binding = DataBindingUtil.findBinding(view);
+ final ObservableTunnel tunnel;
+ if (binding instanceof TunnelDetailFragmentBinding)
+ tunnel = ((TunnelDetailFragmentBinding) binding).getTunnel();
+ else if (binding instanceof TunnelListItemBinding)
+ tunnel = ((TunnelListItemBinding) binding).getItem();
+ else
+ return;
+ if (tunnel == null)
+ return;
+
+ Application.getBackendAsync().thenAccept(backend -> {
+ if (backend instanceof GoBackend) {
+ final Intent intent = GoBackend.VpnService.prepare(view.getContext());
+ if (intent != null) {
+ pendingTunnel = tunnel;
+ pendingTunnelUp = checked;
+ startActivityForResult(intent, REQUEST_CODE_VPN_PERMISSION);
+ return;
+ }
+ }
+
+ setTunnelStateWithPermissionsResult(tunnel, checked);
+ });
+ }
+
+ private void setTunnelStateWithPermissionsResult(final ObservableTunnel tunnel, final boolean checked) {
+ tunnel.setState(State.of(checked)).whenComplete((state, throwable) -> {
+ if (throwable == null)
+ return;
+ final String error = ErrorMessages.get(throwable);
+ final int messageResId = checked ? R.string.error_up : R.string.error_down;
+ final String message = requireContext().getString(messageResId, error);
+ final View view = getView();
+ if (view != null)
+ Snackbar.make(view, message, Snackbar.LENGTH_LONG).show();
+ else
+ Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show();
+ Log.e(TAG, message, throwable);
+ });
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java
new file mode 100644
index 00000000..effa0593
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.appcompat.app.AlertDialog;
+import android.view.inputmethod.InputMethodManager;
+
+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 java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+
+public class ConfigNamingDialogFragment extends DialogFragment {
+ private static final String KEY_CONFIG_TEXT = "config_text";
+
+ @Nullable private ConfigNamingDialogFragmentBinding binding;
+ @Nullable private Config config;
+ @Nullable private InputMethodManager imm;
+
+ public static ConfigNamingDialogFragment newInstance(final String configText) {
+ final Bundle extras = new Bundle();
+ extras.putString(KEY_CONFIG_TEXT, configText);
+ final ConfigNamingDialogFragment fragment = new ConfigNamingDialogFragment();
+ fragment.setArguments(extras);
+ return fragment;
+ }
+
+ private void createTunnelAndDismiss() {
+ if (binding != null) {
+ final String name = binding.tunnelNameText.getText().toString();
+
+ Application.getTunnelManager().create(name, config).whenComplete((tunnel, throwable) -> {
+ if (tunnel != null) {
+ dismiss();
+ } else {
+ binding.tunnelNameTextLayout.setError(throwable.getMessage());
+ }
+ });
+ }
+ }
+
+ @Override
+ public void dismiss() {
+ setKeyboardVisible(false);
+ super.dismiss();
+ }
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Bundle arguments = getArguments();
+ final String configText = arguments.getString(KEY_CONFIG_TEXT);
+ final byte[] configBytes = configText.getBytes(StandardCharsets.UTF_8);
+ try {
+ config = Config.parse(new ByteArrayInputStream(configBytes));
+ } catch (final BadConfigException | IOException e) {
+ throw new IllegalArgumentException("Invalid config passed to " + getClass().getSimpleName(), e);
+ }
+ }
+
+ @Override
+ public Dialog onCreateDialog(final Bundle savedInstanceState) {
+ final Activity activity = requireActivity();
+
+ imm = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
+
+ final AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(activity);
+ alertDialogBuilder.setTitle(R.string.import_from_qr_code);
+
+ binding = ConfigNamingDialogFragmentBinding.inflate(activity.getLayoutInflater(), null, false);
+ binding.executePendingBindings();
+ alertDialogBuilder.setView(binding.getRoot());
+
+ alertDialogBuilder.setPositiveButton(R.string.create_tunnel, null);
+ alertDialogBuilder.setNegativeButton(R.string.cancel, (dialog, which) -> dismiss());
+
+ return alertDialogBuilder.create();
+ }
+
+ @Override public void onResume() {
+ super.onResume();
+
+ final AlertDialog dialog = (AlertDialog) getDialog();
+ if (dialog != null) {
+ dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(v -> createTunnelAndDismiss());
+
+ setKeyboardVisible(true);
+ }
+ }
+
+ private void setKeyboardVisible(final boolean visible) {
+ Objects.requireNonNull(imm);
+
+ if (visible) {
+ imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0);
+ } else if (binding != null) {
+ imm.hideSoftInputFromWindow(binding.tunnelNameText.getWindowToken(), 0);
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java
new file mode 100644
index 00000000..8d90fa7e
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.databinding.DataBindingUtil;
+
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.TunnelDetailFragmentBinding;
+import com.wireguard.android.databinding.TunnelDetailPeerBinding;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.ui.EdgeToEdge;
+import com.wireguard.crypto.Key;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+/**
+ * Fragment that shows details about a specific tunnel.
+ */
+
+public class TunnelDetailFragment extends BaseFragment {
+ @Nullable private TunnelDetailFragmentBinding binding;
+ @Nullable private Timer timer;
+ @Nullable private State lastState = State.TOGGLE;
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ inflater.inflate(R.menu.tunnel_detail, menu);
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ if (timer != null) {
+ timer.cancel();
+ timer = null;
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ timer = new Timer();
+ timer.scheduleAtFixedRate(new TimerTask() {
+ @Override
+ public void run() {
+ updateStats();
+ }
+ }, 0, 1000);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
+ @Nullable final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelDetailFragmentBinding.inflate(inflater, container, false);
+ binding.executePendingBindings();
+ EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot());
+ EdgeToEdge.setUpScrollingContent((ViewGroup) binding.getRoot(), null);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) {
+ if (binding == null)
+ return;
+ binding.setTunnel(newTunnel);
+ if (newTunnel == null)
+ binding.setConfig(null);
+ else
+ newTunnel.getConfigAsync().thenAccept(binding::setConfig);
+ lastState = State.TOGGLE;
+ updateStats();
+ }
+
+ @Override
+ public void onViewStateRestored(@Nullable final Bundle savedInstanceState) {
+ if (binding == null) {
+ return;
+ }
+
+ binding.setFragment(this);
+ onSelectedTunnelChanged(null, getSelectedTunnel());
+ super.onViewStateRestored(savedInstanceState);
+ }
+
+ private String formatBytes(final long bytes) {
+ if (bytes < 1024)
+ return requireContext().getString(R.string.transfer_bytes, bytes);
+ else if (bytes < 1024*1024)
+ return requireContext().getString(R.string.transfer_kibibytes, bytes/1024.0);
+ else if (bytes < 1024*1024*1024)
+ return requireContext().getString(R.string.transfer_mibibytes, bytes/(1024.0*1024.0));
+ else if (bytes < 1024*1024*1024*1024)
+ return requireContext().getString(R.string.transfer_gibibytes, bytes/(1024.0*1024.0*1024.0));
+ return requireContext().getString(R.string.transfer_tibibytes, bytes/(1024.0*1024.0*1024.0)/1024.0);
+ }
+
+ private void updateStats() {
+ if (binding == null || !isResumed())
+ return;
+ final ObservableTunnel tunnel = binding.getTunnel();
+ if (tunnel == null)
+ return;
+ final State state = tunnel.getState();
+ if (state != State.UP && lastState == state)
+ return;
+ lastState = state;
+ tunnel.getStatisticsAsync().whenComplete((statistics, throwable) -> {
+ if (throwable != null) {
+ for (int i = 0; i < binding.peersLayout.getChildCount(); ++i) {
+ final TunnelDetailPeerBinding peer = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i));
+ if (peer == null)
+ continue;
+ peer.transferLabel.setVisibility(View.GONE);
+ peer.transferText.setVisibility(View.GONE);
+ }
+ return;
+ }
+ for (int i = 0; i < binding.peersLayout.getChildCount(); ++i) {
+ final TunnelDetailPeerBinding peer = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i));
+ if (peer == null)
+ continue;
+ final Key publicKey = peer.getItem().getPublicKey();
+ final long rx = statistics.peerRx(publicKey);
+ final long tx = statistics.peerTx(publicKey);
+ if (rx == 0 && tx == 0) {
+ peer.transferLabel.setVisibility(View.GONE);
+ peer.transferText.setVisibility(View.GONE);
+ continue;
+ }
+ peer.transferText.setText(requireContext().getString(R.string.transfer_rx_tx, formatBytes(rx), formatBytes(tx)));
+ peer.transferLabel.setVisibility(View.VISIBLE);
+ peer.transferText.setVisibility(View.VISIBLE);
+ }
+ });
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java
new file mode 100644
index 00000000..92aeb52a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.content.Context;
+import androidx.databinding.ObservableList;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+
+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.inputmethod.InputMethodManager;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.TunnelEditorFragmentBinding;
+import com.wireguard.android.fragment.AppListDialogFragment.AppExclusionListener;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.model.TunnelManager;
+import com.wireguard.android.ui.EdgeToEdge;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.viewmodel.ConfigProxy;
+import com.wireguard.config.Config;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Fragment for editing a WireGuard configuration.
+ */
+
+public class TunnelEditorFragment extends BaseFragment implements AppExclusionListener {
+ private static final String KEY_LOCAL_CONFIG = "local_config";
+ private static final String KEY_ORIGINAL_NAME = "original_name";
+ private static final String TAG = "WireGuard/" + TunnelEditorFragment.class.getSimpleName();
+
+ @Nullable private TunnelEditorFragmentBinding binding;
+ @Nullable private ObservableTunnel tunnel;
+
+ private void onConfigLoaded(final Config config) {
+ if (binding != null) {
+ binding.setConfig(new ConfigProxy(config));
+ }
+ }
+
+ private void onConfigSaved(final ObservableTunnel savedTunnel,
+ @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = getString(R.string.config_save_success, savedTunnel.getName());
+ Log.d(TAG, message);
+ Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
+ onFinished();
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.config_save_error, savedTunnel.getName(), error);
+ Log.e(TAG, message, throwable);
+ if (binding != null) {
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ inflater.inflate(R.menu.config_editor, menu);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
+ @Nullable final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelEditorFragmentBinding.inflate(inflater, container, false);
+ binding.executePendingBindings();
+ EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot());
+ EdgeToEdge.setUpScrollingContent(binding.mainContainer, null);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onExcludedAppsSelected(final List<String> excludedApps) {
+ Objects.requireNonNull(binding, "Tried to set excluded apps while no view was loaded");
+ final ObservableList<String> excludedApplications =
+ binding.getConfig().getInterface().getExcludedApplications();
+ excludedApplications.clear();
+ excludedApplications.addAll(excludedApps);
+ }
+
+ private void onFinished() {
+ // Hide the keyboard; it rarely goes away on its own.
+ final Activity activity = getActivity();
+ if (activity == null) return;
+ final View focusedView = activity.getCurrentFocus();
+ if (focusedView != null) {
+ final Object service = activity.getSystemService(Context.INPUT_METHOD_SERVICE);
+ final InputMethodManager inputManager = (InputMethodManager) service;
+ if (inputManager != null)
+ inputManager.hideSoftInputFromWindow(focusedView.getWindowToken(),
+ InputMethodManager.HIDE_NOT_ALWAYS);
+ }
+ // Tell the activity to finish itself or go back to the detail view.
+ getActivity().runOnUiThread(() -> {
+ // TODO(smaeul): Remove this hack when fixing the Config ViewModel
+ // The selected tunnel has to actually change, but we have to remember this one.
+ final ObservableTunnel savedTunnel = tunnel;
+ if (savedTunnel == getSelectedTunnel())
+ setSelectedTunnel(null);
+ setSelectedTunnel(savedTunnel);
+ });
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_action_save:
+ if (binding == null)
+ return false;
+ final Config newConfig;
+ try {
+ newConfig = binding.getConfig().resolve();
+ } catch (final Exception e) {
+ final String error = ErrorMessages.get(e);
+ final String tunnelName = tunnel == null ? binding.getName() : tunnel.getName();
+ final String 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;
+ }
+ if (tunnel == null) {
+ Log.d(TAG, "Attempting to create new tunnel " + binding.getName());
+ final TunnelManager manager = Application.getTunnelManager();
+ manager.create(binding.getName(), newConfig)
+ .whenComplete(this::onTunnelCreated);
+ } else if (!tunnel.getName().equals(binding.getName())) {
+ Log.d(TAG, "Attempting to rename tunnel to " + binding.getName());
+ tunnel.setName(binding.getName())
+ .whenComplete((a, b) -> onTunnelRenamed(tunnel, newConfig, b));
+ } else {
+ Log.d(TAG, "Attempting to save config of " + tunnel.getName());
+ tunnel.setConfig(newConfig)
+ .whenComplete((a, b) -> onConfigSaved(tunnel, b));
+ }
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ public void onRequestSetExcludedApplications(@SuppressWarnings("unused") final View view) {
+ if (binding != null) {
+ final ArrayList<String> excludedApps = new ArrayList<>(binding.getConfig().getInterface().getExcludedApplications());
+ final AppListDialogFragment fragment = AppListDialogFragment.newInstance(excludedApps, this);
+ fragment.show(getParentFragmentManager(), null);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(final Bundle outState) {
+ if (binding != null)
+ outState.putParcelable(KEY_LOCAL_CONFIG, binding.getConfig());
+ outState.putString(KEY_ORIGINAL_NAME, tunnel == null ? null : tunnel.getName());
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel,
+ @Nullable final ObservableTunnel newTunnel) {
+ tunnel = newTunnel;
+ if (binding == null)
+ return;
+ binding.setConfig(new ConfigProxy());
+ if (tunnel != null) {
+ binding.setName(tunnel.getName());
+ tunnel.getConfigAsync().thenAccept(this::onConfigLoaded);
+ } else {
+ binding.setName("");
+ }
+ }
+
+ private void onTunnelCreated(final ObservableTunnel newTunnel, @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ tunnel = newTunnel;
+ message = getString(R.string.tunnel_create_success, tunnel.getName());
+ Log.d(TAG, message);
+ Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
+ onFinished();
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.tunnel_create_error, error);
+ Log.e(TAG, message, throwable);
+ if (binding != null) {
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ private void onTunnelRenamed(final ObservableTunnel renamedTunnel, final Config newConfig,
+ @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = getString(R.string.tunnel_rename_success, renamedTunnel.getName());
+ Log.d(TAG, message);
+ // Now save the rest of configuration changes.
+ Log.d(TAG, "Attempting to save config of renamed tunnel " + tunnel.getName());
+ renamedTunnel.setConfig(newConfig).whenComplete((a, b) -> onConfigSaved(renamedTunnel, b));
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.tunnel_rename_error, error);
+ Log.e(TAG, message, throwable);
+ if (binding != null) {
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ @Override
+ public void onViewStateRestored(@Nullable final Bundle savedInstanceState) {
+ if (binding == null) {
+ return;
+ }
+
+ binding.setFragment(this);
+
+ if (savedInstanceState == null) {
+ onSelectedTunnelChanged(null, getSelectedTunnel());
+ } else {
+ tunnel = getSelectedTunnel();
+ final ConfigProxy config = savedInstanceState.getParcelable(KEY_LOCAL_CONFIG);
+ final String originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME);
+ if (tunnel != null && !tunnel.getName().equals(originalName))
+ onSelectedTunnelChanged(null, tunnel);
+ else
+ binding.setConfig(config);
+ }
+
+ super.onViewStateRestored(savedInstanceState);
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java
new file mode 100644
index 00000000..21618e60
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java
@@ -0,0 +1,449 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.OpenableColumns;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.view.ActionMode;
+import androidx.recyclerview.widget.RecyclerView;
+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 com.google.zxing.integration.android.IntentIntegrator;
+import com.google.zxing.integration.android.IntentResult;
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.activity.TunnelCreatorActivity;
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter;
+import com.wireguard.android.databinding.TunnelListFragmentBinding;
+import com.wireguard.android.databinding.TunnelListItemBinding;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.ui.EdgeToEdge;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.widget.MultiselectableRelativeLayout;
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.Config;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import java9.util.concurrent.CompletableFuture;
+import java9.util.stream.StreamSupport;
+
+/**
+ * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels.
+ */
+
+public class TunnelListFragment extends BaseFragment {
+ public static final int REQUEST_IMPORT = 1;
+ private static final int REQUEST_TARGET_FRAGMENT = 2;
+ private static final String TAG = "WireGuard/" + TunnelListFragment.class.getSimpleName();
+
+ private final ActionModeListener actionModeListener = new ActionModeListener();
+ @Nullable private ActionMode actionMode;
+ @Nullable private TunnelListFragmentBinding binding;
+
+ private void importTunnel(@NonNull final String configText) {
+ try {
+ // Ensure the config text is parseable before proceeding…
+ Config.parse(new ByteArrayInputStream(configText.getBytes(StandardCharsets.UTF_8)));
+
+ // Config text is valid, now create the tunnel…
+ ConfigNamingDialogFragment.newInstance(configText).show(getParentFragmentManager(), null);
+ } catch (final BadConfigException | IOException e) {
+ onTunnelImportFinished(Collections.emptyList(), Collections.singletonList(e));
+ }
+ }
+
+ private void importTunnel(@Nullable final Uri uri) {
+ final Activity activity = getActivity();
+ if (activity == null || uri == null)
+ return;
+ final ContentResolver contentResolver = activity.getContentResolver();
+
+ final Collection<CompletableFuture<ObservableTunnel>> futureTunnels = new ArrayList<>();
+ final List<Throwable> throwables = new ArrayList<>();
+ Application.getAsyncWorker().supplyAsync(() -> {
+ final String[] columns = {OpenableColumns.DISPLAY_NAME};
+ String name = null;
+ try (Cursor cursor = contentResolver.query(uri, columns,
+ null, null, null)) {
+ if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0))
+ name = cursor.getString(0);
+ }
+ if (name == null)
+ name = Uri.decode(uri.getLastPathSegment());
+ int idx = name.lastIndexOf('/');
+ if (idx >= 0) {
+ if (idx >= name.length() - 1)
+ throw new IllegalArgumentException(getResources().getString(R.string.illegal_filename_error, name));
+ name = name.substring(idx + 1);
+ }
+ boolean isZip = name.toLowerCase(Locale.ENGLISH).endsWith(".zip");
+ if (name.toLowerCase(Locale.ENGLISH).endsWith(".conf"))
+ name = name.substring(0, name.length() - ".conf".length());
+ else if (!isZip)
+ throw new IllegalArgumentException(getResources().getString(R.string.bad_extension_error));
+
+ if (isZip) {
+ try (ZipInputStream zip = new ZipInputStream(contentResolver.openInputStream(uri));
+ BufferedReader reader = new BufferedReader(new InputStreamReader(zip))) {
+ ZipEntry entry;
+ while ((entry = zip.getNextEntry()) != null) {
+ if (entry.isDirectory())
+ continue;
+ name = entry.getName();
+ idx = name.lastIndexOf('/');
+ if (idx >= 0) {
+ if (idx >= name.length() - 1)
+ continue;
+ name = name.substring(name.lastIndexOf('/') + 1);
+ }
+ if (name.toLowerCase(Locale.ENGLISH).endsWith(".conf"))
+ name = name.substring(0, name.length() - ".conf".length());
+ else
+ continue;
+ Config config = null;
+ try {
+ config = Config.parse(reader);
+ } catch (Exception e) {
+ throwables.add(e);
+ }
+ if (config != null)
+ futureTunnels.add(Application.getTunnelManager().create(name, config).toCompletableFuture());
+ }
+ }
+ } else {
+ futureTunnels.add(Application.getTunnelManager().create(name,
+ Config.parse(contentResolver.openInputStream(uri))).toCompletableFuture());
+ }
+
+ if (futureTunnels.isEmpty()) {
+ if (throwables.size() == 1)
+ throw throwables.get(0);
+ else if (throwables.isEmpty())
+ throw new IllegalArgumentException(getResources().getString(R.string.no_configs_error));
+ }
+
+ return CompletableFuture.allOf(futureTunnels.toArray(new CompletableFuture[futureTunnels.size()]));
+ }).whenComplete((future, exception) -> {
+ if (exception != null) {
+ onTunnelImportFinished(Collections.emptyList(), Collections.singletonList(exception));
+ } else {
+ future.whenComplete((ignored1, ignored2) -> {
+ final List<ObservableTunnel> tunnels = new ArrayList<>(futureTunnels.size());
+ for (final CompletableFuture<ObservableTunnel> futureTunnel : futureTunnels) {
+ ObservableTunnel tunnel = null;
+ try {
+ tunnel = futureTunnel.getNow(null);
+ } catch (final Exception e) {
+ throwables.add(e);
+ }
+ if (tunnel != null)
+ tunnels.add(tunnel);
+ }
+ onTunnelImportFinished(tunnels, throwables);
+ });
+ }
+ });
+ }
+
+ @Override
+ public void onActivityCreated(@Nullable final Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ if (savedInstanceState != null) {
+ final Collection<Integer> checkedItems = savedInstanceState.getIntegerArrayList("CHECKED_ITEMS");
+ if (checkedItems != null) {
+ for (final Integer i : checkedItems)
+ actionModeListener.setItemChecked(i, true);
+ }
+ }
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) {
+ switch (requestCode) {
+ case REQUEST_IMPORT:
+ if (resultCode == Activity.RESULT_OK && data != null)
+ importTunnel(data.getData());
+ return;
+ case IntentIntegrator.REQUEST_CODE:
+ final IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
+ if (result != null && result.getContents() != null) {
+ importTunnel(result.getContents());
+ }
+ return;
+ default:
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container,
+ @Nullable final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelListFragmentBinding.inflate(inflater, container, false);
+ binding.createFab.setOnClickListener(v -> {
+ final AddTunnelsSheet bottomSheet = new AddTunnelsSheet();
+ bottomSheet.setTargetFragment(this, REQUEST_TARGET_FRAGMENT);
+ bottomSheet.show(getParentFragmentManager(), "BOTTOM_SHEET");
+ });
+ binding.executePendingBindings();
+ EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot());
+ EdgeToEdge.setUpFAB(binding.createFab);
+ EdgeToEdge.setUpScrollingContent(binding.tunnelList, binding.createFab);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ }
+
+ public void onRequestCreateConfig(@SuppressWarnings("unused") final View view) {
+ startActivity(new Intent(getActivity(), TunnelCreatorActivity.class));
+ }
+
+ @Override
+ public void onSaveInstanceState(final Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ outState.putIntegerArrayList("CHECKED_ITEMS", actionModeListener.getCheckedItems());
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) {
+ if (binding == null)
+ return;
+ Application.getTunnelManager().getTunnels().thenAccept(tunnels -> {
+ if (newTunnel != null)
+ viewForTunnel(newTunnel, tunnels).setSingleSelected(true);
+ if (oldTunnel != null)
+ viewForTunnel(oldTunnel, tunnels).setSingleSelected(false);
+ });
+ }
+
+ private void showSnackbar(final CharSequence message) {
+ if (binding != null) {
+ final Snackbar snackbar = Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG);
+ snackbar.setAnchorView(binding.createFab);
+ snackbar.show();
+ }
+ }
+
+ private void onTunnelDeletionFinished(final Integer count, @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = getResources().getQuantityString(R.plurals.delete_success, count, count);
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getResources().getQuantityString(R.plurals.delete_error, count, count, error);
+ Log.e(TAG, message, throwable);
+ }
+ showSnackbar(message);
+ }
+
+ private void onTunnelImportFinished(final List<ObservableTunnel> tunnels, final Collection<Throwable> throwables) {
+ String message = null;
+
+ for (final Throwable throwable : throwables) {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.import_error, error);
+ Log.e(TAG, message, throwable);
+ }
+
+ if (tunnels.size() == 1 && throwables.isEmpty())
+ message = getString(R.string.import_success, tunnels.get(0).getName());
+ else if (tunnels.isEmpty() && throwables.size() == 1)
+ /* Use the exception message from above. */ ;
+ else if (throwables.isEmpty())
+ message = getResources().getQuantityString(R.plurals.import_total_success,
+ tunnels.size(), tunnels.size());
+ else if (!throwables.isEmpty())
+ message = getResources().getQuantityString(R.plurals.import_partial_success,
+ tunnels.size() + throwables.size(),
+ tunnels.size(), tunnels.size() + throwables.size());
+
+ showSnackbar(message);
+ }
+
+ @Override
+ public void onViewStateRestored(@Nullable final Bundle savedInstanceState) {
+ super.onViewStateRestored(savedInstanceState);
+
+ if (binding == null) {
+ return;
+ }
+
+ binding.setFragment(this);
+ Application.getTunnelManager().getTunnels().thenAccept(binding::setTunnels);
+ binding.setRowConfigurationHandler((ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TunnelListItemBinding, ObservableTunnel>) (binding, tunnel, position) -> {
+ binding.setFragment(this);
+ binding.getRoot().setOnClickListener(clicked -> {
+ if (actionMode == null) {
+ setSelectedTunnel(tunnel);
+ } else {
+ actionModeListener.toggleItemChecked(position);
+ }
+ });
+ binding.getRoot().setOnLongClickListener(clicked -> {
+ actionModeListener.toggleItemChecked(position);
+ return true;
+ });
+
+ if (actionMode != null)
+ ((MultiselectableRelativeLayout) binding.getRoot()).setMultiSelected(actionModeListener.checkedItems.contains(position));
+ else
+ ((MultiselectableRelativeLayout) binding.getRoot()).setSingleSelected(getSelectedTunnel() == tunnel);
+ });
+ }
+
+ private MultiselectableRelativeLayout viewForTunnel(final ObservableTunnel tunnel, final List tunnels) {
+ return (MultiselectableRelativeLayout) binding.tunnelList.findViewHolderForAdapterPosition(tunnels.indexOf(tunnel)).itemView;
+ }
+
+ private final class ActionModeListener implements ActionMode.Callback {
+ private final Collection<Integer> checkedItems = new HashSet<>();
+
+ @Nullable private Resources resources;
+
+ public ArrayList<Integer> getCheckedItems() {
+ return new ArrayList<>(checkedItems);
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_action_delete:
+ final Iterable<Integer> copyCheckedItems = new HashSet<>(checkedItems);
+ Application.getTunnelManager().getTunnels().thenAccept(tunnels -> {
+ final Collection<ObservableTunnel> tunnelsToDelete = new ArrayList<>();
+ for (final Integer position : copyCheckedItems)
+ tunnelsToDelete.add(tunnels.get(position));
+
+ final CompletableFuture[] futures = StreamSupport.stream(tunnelsToDelete)
+ .map(ObservableTunnel::delete)
+ .toArray(CompletableFuture[]::new);
+ CompletableFuture.allOf(futures)
+ .thenApply(x -> futures.length)
+ .whenComplete(TunnelListFragment.this::onTunnelDeletionFinished);
+
+ });
+ checkedItems.clear();
+ mode.finish();
+ return true;
+ case R.id.menu_action_select_all:
+ Application.getTunnelManager().getTunnels().thenAccept(tunnels -> {
+ for (int i = 0; i < tunnels.size(); ++i) {
+ setItemChecked(i, true);
+ }
+ });
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode mode, final Menu menu) {
+ actionMode = mode;
+ if (getActivity() != null) {
+ resources = getActivity().getResources();
+ }
+ mode.getMenuInflater().inflate(R.menu.tunnel_list_action_mode, menu);
+ binding.tunnelList.getAdapter().notifyDataSetChanged();
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode mode) {
+ actionMode = null;
+ resources = null;
+ checkedItems.clear();
+ binding.tunnelList.getAdapter().notifyDataSetChanged();
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode mode, final Menu menu) {
+ updateTitle(mode);
+ return false;
+ }
+
+ void setItemChecked(final int position, final boolean checked) {
+ if (checked) {
+ checkedItems.add(position);
+ } else {
+ checkedItems.remove(position);
+ }
+
+ final RecyclerView.Adapter adapter = binding == null ? null : binding.tunnelList.getAdapter();
+
+ if (actionMode == null && !checkedItems.isEmpty() && getActivity() != null) {
+ ((AppCompatActivity) getActivity()).startSupportActionMode(this);
+ } else if (actionMode != null && checkedItems.isEmpty()) {
+ actionMode.finish();
+ }
+
+ if (adapter != null)
+ adapter.notifyItemChanged(position);
+
+ updateTitle(actionMode);
+ }
+
+ void toggleItemChecked(final int position) {
+ setItemChecked(position, !checkedItems.contains(position));
+ }
+
+ private void updateTitle(@Nullable final ActionMode mode) {
+ if (mode == null) {
+ return;
+ }
+
+ final int count = checkedItems.size();
+ if (count == 0) {
+ mode.setTitle("");
+ } else {
+ mode.setTitle(resources.getQuantityString(R.plurals.delete_title, count, count));
+ }
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/ApplicationData.java b/ui/src/main/java/com/wireguard/android/model/ApplicationData.java
new file mode 100644
index 00000000..65edff90
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/ApplicationData.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.model;
+
+import androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import android.graphics.drawable.Drawable;
+
+import com.wireguard.android.BR;
+import com.wireguard.util.Keyed;
+
+public class ApplicationData extends BaseObservable implements Keyed<String> {
+ private final Drawable icon;
+ private final String name;
+ private final String packageName;
+ private boolean excludedFromTunnel;
+
+ public ApplicationData(final Drawable icon, final String name, final String packageName, final boolean excludedFromTunnel) {
+ this.icon = icon;
+ this.name = name;
+ this.packageName = packageName;
+ this.excludedFromTunnel = excludedFromTunnel;
+ }
+
+ public Drawable getIcon() {
+ return icon;
+ }
+
+ @Override
+ public String getKey() {
+ return name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getPackageName() {
+ return packageName;
+ }
+
+ @Bindable
+ public boolean isExcludedFromTunnel() {
+ return excludedFromTunnel;
+ }
+
+ public void setExcludedFromTunnel(final boolean excludedFromTunnel) {
+ this.excludedFromTunnel = excludedFromTunnel;
+ notifyPropertyChanged(BR.excludedFromTunnel);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java
new file mode 100644
index 00000000..ce3197f2
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.model;
+
+import androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.BR;
+import com.wireguard.android.backend.Statistics;
+import com.wireguard.android.backend.Tunnel;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.config.Config;
+import com.wireguard.util.Keyed;
+
+import java9.util.concurrent.CompletableFuture;
+import java9.util.concurrent.CompletionStage;
+
+/**
+ * Encapsulates the volatile and nonvolatile state of a WireGuard tunnel.
+ */
+
+public class ObservableTunnel extends BaseObservable implements Keyed<String>, Tunnel {
+ private final TunnelManager manager;
+ @Nullable private Config config;
+ private State state;
+ private String name;
+ @Nullable private Statistics statistics;
+
+ ObservableTunnel(final TunnelManager manager, final String name,
+ @Nullable final Config config, final State state) {
+ this.name = name;
+ this.manager = manager;
+ this.config = config;
+ this.state = state;
+ }
+
+ public CompletionStage<Void> delete() {
+ return manager.delete(this);
+ }
+
+ @Bindable
+ @Nullable
+ public Config getConfig() {
+ if (config == null)
+ manager.getTunnelConfig(this).whenComplete(ExceptionLoggers.E);
+ return config;
+ }
+
+ public CompletionStage<Config> getConfigAsync() {
+ if (config == null)
+ return manager.getTunnelConfig(this);
+ return CompletableFuture.completedFuture(config);
+ }
+
+ @Override
+ public String getKey() {
+ return name;
+ }
+
+ @Override
+ @Bindable
+ public String getName() {
+ return name;
+ }
+
+ @Bindable
+ public State getState() {
+ return state;
+ }
+
+ public CompletionStage<State> getStateAsync() {
+ return TunnelManager.getTunnelState(this);
+ }
+
+ @Bindable
+ @Nullable
+ public Statistics getStatistics() {
+ if (statistics == null || statistics.isStale())
+ TunnelManager.getTunnelStatistics(this).whenComplete(ExceptionLoggers.E);
+ return statistics;
+ }
+
+ public CompletionStage<Statistics> getStatisticsAsync() {
+ if (statistics == null || statistics.isStale())
+ return TunnelManager.getTunnelStatistics(this);
+ return CompletableFuture.completedFuture(statistics);
+ }
+
+ Config onConfigChanged(final Config config) {
+ this.config = config;
+ notifyPropertyChanged(BR.config);
+ return config;
+ }
+
+ String onNameChanged(final String name) {
+ this.name = name;
+ notifyPropertyChanged(BR.name);
+ return name;
+ }
+
+ State onStateChanged(final State state) {
+ if (state != State.UP)
+ onStatisticsChanged(null);
+ this.state = state;
+ notifyPropertyChanged(BR.state);
+ return state;
+ }
+
+ @Override
+ public void onStateChange(final State newState) {
+ onStateChanged(state);
+ }
+
+ @Nullable
+ Statistics onStatisticsChanged(@Nullable final Statistics statistics) {
+ this.statistics = statistics;
+ notifyPropertyChanged(BR.statistics);
+ return statistics;
+ }
+
+ public CompletionStage<Config> setConfig(final Config config) {
+ if (!config.equals(this.config))
+ return manager.setTunnelConfig(this, config);
+ return CompletableFuture.completedFuture(this.config);
+ }
+
+ public CompletionStage<String> setName(final String name) {
+ if (!name.equals(this.name))
+ return manager.setTunnelName(this, name);
+ return CompletableFuture.completedFuture(this.name);
+ }
+
+ public CompletionStage<State> setState(final State state) {
+ if (state != this.state)
+ return manager.setTunnelState(this, state);
+ return CompletableFuture.completedFuture(this.state);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelManager.java b/ui/src/main/java/com/wireguard/android/model/TunnelManager.java
new file mode 100644
index 00000000..35d56c81
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/TunnelManager.java
@@ -0,0 +1,301 @@
+/*
+ * Copyright © 2017-2019 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 androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.BR;
+import com.wireguard.android.R;
+import com.wireguard.android.configStore.ConfigStore;
+import com.wireguard.android.backend.Tunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.backend.Statistics;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.android.util.ObservableSortedKeyedArrayList;
+import com.wireguard.android.util.ObservableSortedKeyedList;
+import com.wireguard.config.Config;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Set;
+
+import java9.util.Comparators;
+import java9.util.concurrent.CompletableFuture;
+import java9.util.concurrent.CompletionStage;
+import java9.util.stream.Collectors;
+import java9.util.stream.StreamSupport;
+
+/**
+ * Maintains and mediates changes to the set of available WireGuard tunnels,
+ */
+
+public final class TunnelManager extends BaseObservable {
+ private static final Comparator<String> COMPARATOR = Comparators.<String>thenComparing(
+ String.CASE_INSENSITIVE_ORDER, Comparators.naturalOrder());
+ private static final String KEY_LAST_USED_TUNNEL = "last_used_tunnel";
+ private static final String KEY_RESTORE_ON_BOOT = "restore_on_boot";
+ private static final String KEY_RUNNING_TUNNELS = "enabled_configs";
+
+ private final CompletableFuture<ObservableSortedKeyedList<String, ObservableTunnel>> completableTunnels = new CompletableFuture<>();
+ private final ConfigStore configStore;
+ private final Context context = Application.get();
+ private final ArrayList<CompletableFuture<Void>> delayedLoadRestoreTunnels = new ArrayList<>();
+ private final ObservableSortedKeyedList<String, ObservableTunnel> tunnels = new ObservableSortedKeyedArrayList<>(COMPARATOR);
+ private boolean haveLoaded;
+ @Nullable private ObservableTunnel lastUsedTunnel;
+
+ public TunnelManager(final ConfigStore configStore) {
+ this.configStore = configStore;
+ }
+
+ static CompletionStage<State> getTunnelState(final ObservableTunnel tunnel) {
+ return Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getState(tunnel))
+ .thenApply(tunnel::onStateChanged);
+ }
+
+ static CompletionStage<Statistics> getTunnelStatistics(final ObservableTunnel tunnel) {
+ return Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getStatistics(tunnel))
+ .thenApply(tunnel::onStatisticsChanged);
+ }
+
+ private ObservableTunnel addToList(final String name, @Nullable final Config config, final State state) {
+ final ObservableTunnel tunnel = new ObservableTunnel(this, name, config, state);
+ tunnels.add(tunnel);
+ return tunnel;
+ }
+
+ public CompletionStage<ObservableTunnel> create(final String name, @Nullable final Config config) {
+ if (Tunnel.isNameInvalid(name))
+ return CompletableFuture.failedFuture(new IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name)));
+ if (tunnels.containsKey(name)) {
+ final String message = context.getString(R.string.tunnel_error_already_exists, name);
+ return CompletableFuture.failedFuture(new IllegalArgumentException(message));
+ }
+ return Application.getAsyncWorker().supplyAsync(() -> configStore.create(name, config))
+ .thenApply(savedConfig -> addToList(name, savedConfig, State.DOWN));
+ }
+
+ CompletionStage<Void> delete(final ObservableTunnel tunnel) {
+ final State originalState = tunnel.getState();
+ final boolean wasLastUsed = tunnel == lastUsedTunnel;
+ // Make sure nothing touches the tunnel.
+ if (wasLastUsed)
+ setLastUsedTunnel(null);
+ tunnels.remove(tunnel);
+ return Application.getAsyncWorker().runAsync(() -> {
+ if (originalState == State.UP)
+ Application.getBackend().setState(tunnel, State.DOWN, null);
+ try {
+ configStore.delete(tunnel.getName());
+ } catch (final Exception e) {
+ if (originalState == State.UP)
+ Application.getBackend().setState(tunnel, State.UP, tunnel.getConfig());
+ // Re-throw the exception to fail the completion.
+ throw e;
+ }
+ }).whenComplete((x, e) -> {
+ if (e == null)
+ return;
+ // Failure, put the tunnel back.
+ tunnels.add(tunnel);
+ if (wasLastUsed)
+ setLastUsedTunnel(tunnel);
+ });
+ }
+
+ @Bindable
+ @Nullable
+ public ObservableTunnel getLastUsedTunnel() {
+ return lastUsedTunnel;
+ }
+
+ CompletionStage<Config> getTunnelConfig(final ObservableTunnel tunnel) {
+ return Application.getAsyncWorker().supplyAsync(() -> configStore.load(tunnel.getName()))
+ .thenApply(tunnel::onConfigChanged);
+ }
+
+ public CompletableFuture<ObservableSortedKeyedList<String, ObservableTunnel>> getTunnels() {
+ return completableTunnels;
+ }
+
+ public void onCreate() {
+ Application.getAsyncWorker().supplyAsync(configStore::enumerate)
+ .thenAcceptBoth(Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getRunningTunnelNames()), this::onTunnelsLoaded)
+ .whenComplete(ExceptionLoggers.E);
+ }
+
+ @SuppressWarnings("unchecked")
+ private void onTunnelsLoaded(final Iterable<String> present, final Collection<String> running) {
+ for (final String name : present)
+ addToList(name, null, running.contains(name) ? State.UP : State.DOWN);
+ final String lastUsedName = Application.getSharedPreferences().getString(KEY_LAST_USED_TUNNEL, null);
+ if (lastUsedName != null)
+ setLastUsedTunnel(tunnels.get(lastUsedName));
+ final CompletableFuture<Void>[] toComplete;
+ synchronized (delayedLoadRestoreTunnels) {
+ haveLoaded = true;
+ toComplete = delayedLoadRestoreTunnels.toArray(new CompletableFuture[delayedLoadRestoreTunnels.size()]);
+ delayedLoadRestoreTunnels.clear();
+ }
+ restoreState(true).whenComplete((v, t) -> {
+ for (final CompletableFuture<Void> f : toComplete) {
+ if (t == null)
+ f.complete(v);
+ else
+ f.completeExceptionally(t);
+ }
+ });
+
+ completableTunnels.complete(tunnels);
+ }
+
+ public void refreshTunnelStates() {
+ Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getRunningTunnelNames())
+ .thenAccept(running -> {
+ for (final ObservableTunnel tunnel : tunnels)
+ tunnel.onStateChanged(running.contains(tunnel.getName()) ? State.UP : State.DOWN);
+ })
+ .whenComplete(ExceptionLoggers.E);
+ }
+
+ public CompletionStage<Void> restoreState(final boolean force) {
+ if (!force && !Application.getSharedPreferences().getBoolean(KEY_RESTORE_ON_BOOT, false))
+ return CompletableFuture.completedFuture(null);
+ synchronized (delayedLoadRestoreTunnels) {
+ if (!haveLoaded) {
+ final CompletableFuture<Void> f = new CompletableFuture<>();
+ delayedLoadRestoreTunnels.add(f);
+ return f;
+ }
+ }
+ final Set<String> previouslyRunning = Application.getSharedPreferences().getStringSet(KEY_RUNNING_TUNNELS, null);
+ if (previouslyRunning == null)
+ return CompletableFuture.completedFuture(null);
+ return CompletableFuture.allOf(StreamSupport.stream(tunnels)
+ .filter(tunnel -> previouslyRunning.contains(tunnel.getName()))
+ .map(tunnel -> setTunnelState(tunnel, State.UP))
+ .toArray(CompletableFuture[]::new));
+ }
+
+ public void saveState() {
+ final Set<String> runningTunnels = StreamSupport.stream(tunnels)
+ .filter(tunnel -> tunnel.getState() == State.UP)
+ .map(ObservableTunnel::getName)
+ .collect(Collectors.toUnmodifiableSet());
+ Application.getSharedPreferences().edit().putStringSet(KEY_RUNNING_TUNNELS, runningTunnels).apply();
+ }
+
+ private void setLastUsedTunnel(@Nullable final ObservableTunnel tunnel) {
+ if (tunnel == lastUsedTunnel)
+ return;
+ lastUsedTunnel = tunnel;
+ notifyPropertyChanged(BR.lastUsedTunnel);
+ if (tunnel != null)
+ Application.getSharedPreferences().edit().putString(KEY_LAST_USED_TUNNEL, tunnel.getName()).apply();
+ else
+ Application.getSharedPreferences().edit().remove(KEY_LAST_USED_TUNNEL).apply();
+ }
+
+ CompletionStage<Config> setTunnelConfig(final ObservableTunnel tunnel, final Config config) {
+ return Application.getAsyncWorker().supplyAsync(() -> {
+ Application.getBackend().setState(tunnel, tunnel.getState(), config);
+ return configStore.save(tunnel.getName(), config);
+ }).thenApply(tunnel::onConfigChanged);
+ }
+
+ CompletionStage<String> setTunnelName(final ObservableTunnel tunnel, final String name) {
+ if (Tunnel.isNameInvalid(name))
+ return CompletableFuture.failedFuture(new IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name)));
+ if (tunnels.containsKey(name)) {
+ final String message = context.getString(R.string.tunnel_error_already_exists, name);
+ return CompletableFuture.failedFuture(new IllegalArgumentException(message));
+ }
+ final State originalState = tunnel.getState();
+ final boolean wasLastUsed = tunnel == lastUsedTunnel;
+ // Make sure nothing touches the tunnel.
+ if (wasLastUsed)
+ setLastUsedTunnel(null);
+ tunnels.remove(tunnel);
+ return Application.getAsyncWorker().supplyAsync(() -> {
+ if (originalState == State.UP)
+ Application.getBackend().setState(tunnel, State.DOWN, null);
+ configStore.rename(tunnel.getName(), name);
+ final String newName = tunnel.onNameChanged(name);
+ if (originalState == State.UP)
+ Application.getBackend().setState(tunnel, State.UP, tunnel.getConfig());
+ return newName;
+ }).whenComplete((newName, e) -> {
+ // On failure, we don't know what state the tunnel might be in. Fix that.
+ if (e != null)
+ getTunnelState(tunnel);
+ // Add the tunnel back to the manager, under whatever name it thinks it has.
+ tunnels.add(tunnel);
+ if (wasLastUsed)
+ setLastUsedTunnel(tunnel);
+ });
+ }
+
+ CompletionStage<State> setTunnelState(final ObservableTunnel tunnel, final State state) {
+ // Ensure the configuration is loaded before trying to use it.
+ return tunnel.getConfigAsync().thenCompose(config ->
+ Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().setState(tunnel, state, config))
+ ).whenComplete((newState, e) -> {
+ // Ensure onStateChanged is always called (failure or not), and with the correct state.
+ tunnel.onStateChanged(e == null ? newState : tunnel.getState());
+ if (e == null && newState == State.UP)
+ setLastUsedTunnel(tunnel);
+ saveState();
+ });
+ }
+
+ public static final class IntentReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(final Context context, @Nullable final Intent intent) {
+ final TunnelManager manager = Application.getTunnelManager();
+ if (intent == null)
+ return;
+ final String action = intent.getAction();
+ if (action == null)
+ return;
+
+ if ("com.wireguard.android.action.REFRESH_TUNNEL_STATES".equals(action)) {
+ manager.refreshTunnelStates();
+ return;
+ }
+
+ /* We disable the below, for now, as the security model of allowing this
+ * might take a bit more consideration.
+ */
+ if (true)
+ return;
+
+ final State state;
+ if ("com.wireguard.android.action.SET_TUNNEL_UP".equals(action))
+ state = State.UP;
+ else if ("com.wireguard.android.action.SET_TUNNEL_DOWN".equals(action))
+ state = State.DOWN;
+ else
+ return;
+
+ final String tunnelName = intent.getStringExtra("tunnel");
+ if (tunnelName == null)
+ return;
+ manager.getTunnels().thenAccept(tunnels -> {
+ final ObservableTunnel tunnel = tunnels.get(tunnelName);
+ if (tunnel == null)
+ return;
+ manager.setTunnelState(tunnel, state);
+ });
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java b/ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java
new file mode 100644
index 00000000..565854b4
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+import androidx.preference.Preference;
+
+import android.util.AttributeSet;
+import android.util.Log;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.util.DownloadsFileSaver;
+import com.wireguard.android.util.DownloadsFileSaver.DownloadsFile;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.util.FragmentUtils;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+
+/**
+ * Preference implementing a button that asynchronously exports logs.
+ */
+
+public class LogExporterPreference extends Preference {
+ private static final String TAG = "WireGuard/" + LogExporterPreference.class.getSimpleName();
+
+ @Nullable private String exportedFilePath;
+
+ public LogExporterPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ private void exportLog() {
+ Application.getAsyncWorker().supplyAsync(() -> {
+ DownloadsFile outputFile = DownloadsFileSaver.save(getContext(), "wireguard-log.txt", "text/plain", true);
+ try {
+ final Process process = Runtime.getRuntime().exec(new String[]{
+ "logcat", "-b", "all", "-d", "-v", "threadtime", "*:V"});
+ try (final BufferedReader stdout = new BufferedReader(new InputStreamReader(process.getInputStream()));
+ final BufferedReader stderr = new BufferedReader(new InputStreamReader(process.getErrorStream())))
+ {
+ String line;
+ while ((line = stdout.readLine()) != null) {
+ outputFile.getOutputStream().write(line.getBytes());
+ outputFile.getOutputStream().write('\n');
+ }
+ outputFile.getOutputStream().close();
+ stdout.close();
+ if (process.waitFor() != 0) {
+ final StringBuilder errors = new StringBuilder();
+ errors.append(R.string.logcat_error);
+ while ((line = stderr.readLine()) != null)
+ errors.append(line);
+ throw new Exception(errors.toString());
+ }
+ }
+ } catch (final Exception e) {
+ outputFile.delete();
+ throw e;
+ }
+ return outputFile.getFileName();
+ }).whenComplete(this::exportLogComplete);
+ }
+
+ private void exportLogComplete(final String filePath, @Nullable final Throwable throwable) {
+ if (throwable != null) {
+ final String error = ErrorMessages.get(throwable);
+ final String message = getContext().getString(R.string.log_export_error, error);
+ Log.e(TAG, message, throwable);
+ Snackbar.make(
+ FragmentUtils.getPrefActivity(this).findViewById(android.R.id.content),
+ message, Snackbar.LENGTH_LONG).show();
+ setEnabled(true);
+ } else {
+ exportedFilePath = filePath;
+ notifyChanged();
+ }
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return exportedFilePath == null ?
+ getContext().getString(R.string.log_export_summary) :
+ getContext().getString(R.string.log_export_success, exportedFilePath);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.log_export_title);
+ }
+
+ @Override
+ protected void onClick() {
+ FragmentUtils.getPrefActivity(this).ensurePermissions(
+ new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
+ (permissions, granted) -> {
+ if (granted.length > 0 && granted[0] == PackageManager.PERMISSION_GRANTED) {
+ setEnabled(false);
+ exportLog();
+ }
+ });
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java b/ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java
new file mode 100644
index 00000000..aac649dd
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright © 2019 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.system.OsConstants;
+import android.util.AttributeSet;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.util.ModuleLoader;
+import com.wireguard.android.util.ToolsInstaller;
+
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+
+public class ModuleDownloaderPreference extends Preference {
+ private State state = State.INITIAL;
+
+ public ModuleDownloaderPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return getContext().getString(state.messageResourceId);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.module_installer_title);
+ }
+
+ @Override
+ protected void onClick() {
+ setState(State.WORKING);
+ Application.getAsyncWorker().supplyAsync(Application.getModuleLoader()::download).whenComplete(this::onDownloadResult);
+ }
+
+ private void onDownloadResult(final Integer result, @Nullable final Throwable throwable) {
+ if (throwable != null) {
+ setState(State.FAILURE);
+ Toast.makeText(getContext(), ErrorMessages.get(throwable), Toast.LENGTH_LONG).show();
+ } else if (result == OsConstants.ENOENT)
+ setState(State.NOTFOUND);
+ else if (result == OsConstants.EXIT_SUCCESS) {
+ setState(State.SUCCESS);
+ Application.getAsyncWorker().runAsync(() -> {
+ Thread.sleep(1000 * 5);
+ Intent i = getContext().getPackageManager().getLaunchIntentForPackage(getContext().getPackageName());
+ if (i == null)
+ return;
+ i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ Application.get().startActivity(i);
+ System.exit(0);
+ });
+ } else
+ setState(State.FAILURE);
+ }
+
+ private void setState(final State state) {
+ if (this.state == state)
+ return;
+ this.state = state;
+ if (isEnabled() != state.shouldEnableView)
+ setEnabled(state.shouldEnableView);
+ notifyChanged();
+ }
+
+ private enum State {
+ INITIAL(R.string.module_installer_initial, true),
+ FAILURE(R.string.module_installer_error, true),
+ WORKING(R.string.module_installer_working, false),
+ SUCCESS(R.string.module_installer_success, false),
+ NOTFOUND(R.string.module_installer_not_found, false);
+
+ private final int messageResourceId;
+ private final boolean shouldEnableView;
+
+ State(final int messageResourceId, final boolean shouldEnableView) {
+ this.messageResourceId = messageResourceId;
+ this.shouldEnableView = shouldEnableView;
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java
new file mode 100644
index 00000000..78a7497b
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference;
+
+import android.content.Context;
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+import android.util.AttributeSet;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.util.ToolsInstaller;
+
+/**
+ * Preference implementing a button that asynchronously runs {@code ToolsInstaller} and displays the
+ * result as the preference summary.
+ */
+
+public class ToolsInstallerPreference extends Preference {
+ private State state = State.INITIAL;
+
+ public ToolsInstallerPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return getContext().getString(state.messageResourceId);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.tools_installer_title);
+ }
+
+ @Override
+ public void onAttached() {
+ super.onAttached();
+ Application.getAsyncWorker().supplyAsync(Application.getToolsInstaller()::areInstalled).whenComplete(this::onCheckResult);
+ }
+
+ private void onCheckResult(final int state, @Nullable final Throwable throwable) {
+ if (throwable != null || state == ToolsInstaller.ERROR)
+ setState(State.INITIAL);
+ else if ((state & ToolsInstaller.YES) == ToolsInstaller.YES)
+ setState(State.ALREADY);
+ else if ((state & (ToolsInstaller.MAGISK | ToolsInstaller.NO)) == (ToolsInstaller.MAGISK | ToolsInstaller.NO))
+ setState(State.INITIAL_MAGISK);
+ else if ((state & (ToolsInstaller.SYSTEM | ToolsInstaller.NO)) == (ToolsInstaller.SYSTEM | ToolsInstaller.NO))
+ setState(State.INITIAL_SYSTEM);
+ else
+ setState(State.INITIAL);
+ }
+
+ @Override
+ protected void onClick() {
+ setState(State.WORKING);
+ Application.getAsyncWorker().supplyAsync(Application.getToolsInstaller()::install).whenComplete(this::onInstallResult);
+ }
+
+ private void onInstallResult(final Integer result, @Nullable final Throwable throwable) {
+ if (throwable != null)
+ setState(State.FAILURE);
+ else if ((result & (ToolsInstaller.YES | ToolsInstaller.MAGISK)) == (ToolsInstaller.YES | ToolsInstaller.MAGISK))
+ setState(State.SUCCESS_MAGISK);
+ else if ((result & (ToolsInstaller.YES | ToolsInstaller.SYSTEM)) == (ToolsInstaller.YES | ToolsInstaller.SYSTEM))
+ setState(State.SUCCESS_SYSTEM);
+ else
+ setState(State.FAILURE);
+ }
+
+ private void setState(final State state) {
+ if (this.state == state)
+ return;
+ this.state = state;
+ if (isEnabled() != state.shouldEnableView)
+ setEnabled(state.shouldEnableView);
+ notifyChanged();
+ }
+
+ private enum State {
+ 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);
+
+ private final int messageResourceId;
+ private final boolean shouldEnableView;
+
+ State(final int messageResourceId, final boolean shouldEnableView) {
+ this.messageResourceId = messageResourceId;
+ this.shouldEnableView = shouldEnableView;
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/VersionPreference.java b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.java
new file mode 100644
index 00000000..7e95a8ae
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference;
+
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+import android.util.AttributeSet;
+
+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 java.util.Locale;
+
+public class VersionPreference extends Preference {
+ @Nullable private String versionSummary;
+
+ private String getBackendPrettyName(final Context context, final Backend backend) {
+ if (backend instanceof GoBackend)
+ return context.getString(R.string.type_name_kernel_module);
+ if (backend instanceof WgQuickBackend)
+ return context.getString(R.string.type_name_go_userspace);
+ return "";
+ }
+
+ public VersionPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ Application.getBackendAsync().thenAccept(backend -> {
+ versionSummary = getContext().getString(R.string.version_summary_checking, getBackendPrettyName(context, backend).toLowerCase(Locale.ENGLISH));
+ Application.getAsyncWorker().supplyAsync(backend::getVersion).whenComplete((version, exception) -> {
+ versionSummary = exception == null
+ ? getContext().getString(R.string.version_summary, getBackendPrettyName(context, backend), version)
+ : getContext().getString(R.string.version_summary_unknown, getBackendPrettyName(context, backend).toLowerCase(Locale.ENGLISH));
+ notifyChanged();
+ });
+ });
+ }
+
+ @Nullable
+ @Override
+ public CharSequence getSummary() {
+ return versionSummary;
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.version_title, BuildConfig.VERSION_NAME);
+ }
+
+ @Override
+ protected void onClick() {
+ final Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse("https://www.wireguard.com/"));
+ try {
+ getContext().startActivity(intent);
+ } catch (final ActivityNotFoundException ignored) {
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java
new file mode 100644
index 00000000..3af412a5
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+import androidx.preference.Preference;
+import android.util.AttributeSet;
+import android.util.Log;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.util.DownloadsFileSaver;
+import com.wireguard.android.util.DownloadsFileSaver.DownloadsFile;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.util.FragmentUtils;
+import com.wireguard.config.Config;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import java9.util.concurrent.CompletableFuture;
+
+/**
+ * Preference implementing a button that asynchronously exports config zips.
+ */
+
+public class ZipExporterPreference extends Preference {
+ private static final String TAG = "WireGuard/" + ZipExporterPreference.class.getSimpleName();
+
+ @Nullable private String exportedFilePath;
+
+ public ZipExporterPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ private void exportZip() {
+ Application.getTunnelManager().getTunnels().thenAccept(this::exportZip);
+ }
+
+ private void exportZip(final List<ObservableTunnel> tunnels) {
+ final List<CompletableFuture<Config>> futureConfigs = new ArrayList<>(tunnels.size());
+ for (final ObservableTunnel tunnel : tunnels)
+ futureConfigs.add(tunnel.getConfigAsync().toCompletableFuture());
+ if (futureConfigs.isEmpty()) {
+ exportZipComplete(null, new IllegalArgumentException(
+ getContext().getString(R.string.no_tunnels_error)));
+ return;
+ }
+ CompletableFuture.allOf(futureConfigs.toArray(new CompletableFuture[futureConfigs.size()]))
+ .whenComplete((ignored1, exception) -> Application.getAsyncWorker().supplyAsync(() -> {
+ if (exception != null)
+ throw exception;
+ DownloadsFile outputFile = DownloadsFileSaver.save(getContext(), "wireguard-export.zip", "application/zip", true);
+ try (ZipOutputStream zip = new ZipOutputStream(outputFile.getOutputStream())) {
+ for (int i = 0; i < futureConfigs.size(); ++i) {
+ zip.putNextEntry(new ZipEntry(tunnels.get(i).getName() + ".conf"));
+ zip.write(futureConfigs.get(i).getNow(null).
+ toWgQuickString().getBytes(StandardCharsets.UTF_8));
+ }
+ zip.closeEntry();
+ } catch (final Exception e) {
+ outputFile.delete();
+ throw e;
+ }
+ return outputFile.getFileName();
+ }).whenComplete(this::exportZipComplete));
+ }
+
+ private void exportZipComplete(@Nullable final String filePath, @Nullable final Throwable throwable) {
+ if (throwable != null) {
+ final String error = ErrorMessages.get(throwable);
+ final String message = getContext().getString(R.string.zip_export_error, error);
+ Log.e(TAG, message, throwable);
+ Snackbar.make(
+ FragmentUtils.getPrefActivity(this).findViewById(android.R.id.content),
+ message, Snackbar.LENGTH_LONG).show();
+ setEnabled(true);
+ } else {
+ exportedFilePath = filePath;
+ notifyChanged();
+ }
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return exportedFilePath == null ?
+ getContext().getString(R.string.zip_export_summary) :
+ getContext().getString(R.string.zip_export_success, exportedFilePath);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.zip_export_title);
+ }
+
+ @Override
+ protected void onClick() {
+ FragmentUtils.getPrefActivity(this).ensurePermissions(
+ new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
+ (permissions, granted) -> {
+ if (granted.length > 0 && granted[0] == PackageManager.PERMISSION_GRANTED) {
+ setEnabled(false);
+ exportZip();
+ }
+ });
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt b/ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt
new file mode 100644
index 00000000..52a19657
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright © 2017-2020 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.ui
+
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.marginBottom
+import androidx.core.view.marginLeft
+import androidx.core.view.marginRight
+import androidx.core.view.marginTop
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+
+/**
+ * A utility for edge-to-edge display. It provides several features needed to make the app
+ * displayed edge-to-edge on Android Q with gestural navigation.
+ */
+
+object EdgeToEdge {
+
+ @JvmStatic
+ fun setUpRoot(root: ViewGroup) {
+ root.systemUiVisibility =
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ }
+
+ @JvmStatic
+ fun setUpScrollingContent(scrollingContent: ViewGroup, fab: ExtendedFloatingActionButton?) {
+ val originalPaddingLeft = scrollingContent.paddingLeft
+ val originalPaddingRight = scrollingContent.paddingRight
+ val originalPaddingBottom = scrollingContent.paddingBottom
+
+ val fabPaddingBottom = fab?.height ?: 0
+
+ val originalMarginTop = scrollingContent.marginTop
+
+ scrollingContent.setOnApplyWindowInsetsListener { _, windowInsets ->
+ scrollingContent.updatePadding(
+ left = originalPaddingLeft + windowInsets.systemWindowInsetLeft,
+ right = originalPaddingRight + windowInsets.systemWindowInsetRight,
+ bottom = originalPaddingBottom + fabPaddingBottom + windowInsets.systemWindowInsetBottom
+ )
+ scrollingContent.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+ topMargin = originalMarginTop + windowInsets.systemWindowInsetTop
+ }
+ windowInsets
+ }
+ }
+
+ @JvmStatic
+ fun setUpFAB(fab: ExtendedFloatingActionButton) {
+ val originalMarginLeft = fab.marginLeft
+ val originalMarginRight = fab.marginRight
+ val originalMarginBottom = fab.marginBottom
+ fab.setOnApplyWindowInsetsListener { _, windowInsets ->
+ fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+ leftMargin = originalMarginLeft + windowInsets.systemWindowInsetLeft
+ rightMargin = originalMarginRight + windowInsets.systemWindowInsetRight
+ bottomMargin = originalMarginBottom + windowInsets.systemWindowInsetBottom
+ }
+ windowInsets
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java
new file mode 100644
index 00000000..0df5e96a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright © 2017-2019 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.content.Context;
+import com.google.android.material.snackbar.Snackbar;
+import android.view.View;
+import android.widget.TextView;
+
+/**
+ * Standalone utilities for interacting with the system clipboard.
+ */
+
+public final class ClipboardUtils {
+ private ClipboardUtils() {
+ // Prevent instantiation
+ }
+
+ public static void copyTextView(final View view) {
+ if (!(view instanceof TextView))
+ return;
+ final CharSequence text = ((TextView) view).getText();
+ if (text == null || text.length() == 0)
+ return;
+ final Object service = view.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ if (!(service instanceof ClipboardManager))
+ return;
+ final CharSequence description = view.getContentDescription();
+ ((ClipboardManager) service).setPrimaryClip(ClipData.newPlainText(description, text));
+ Snackbar.make(view, description + " copied to clipboard", Snackbar.LENGTH_LONG).show();
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java
new file mode 100644
index 00000000..7db46fa9
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright © 2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.provider.MediaStore;
+import android.provider.MediaStore.MediaColumns;
+
+import com.wireguard.android.R;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+public class DownloadsFileSaver {
+
+ public static class DownloadsFile {
+ private Context context;
+ private OutputStream outputStream;
+ private String fileName;
+ private Uri uri;
+
+ private DownloadsFile(final Context context, final OutputStream outputStream, final String fileName, final Uri uri) {
+ this.context = context;
+ this.outputStream = outputStream;
+ this.fileName = fileName;
+ this.uri = uri;
+ }
+
+ public OutputStream getOutputStream() { return outputStream; }
+ public String getFileName() { return fileName; }
+
+ public void delete() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ context.getContentResolver().delete(uri, null, null);
+ else
+ new File(fileName).delete();
+ }
+ }
+
+ public static DownloadsFile save(final Context context, final String name, final String mimeType, final boolean overwriteExisting) throws Exception {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ final ContentResolver contentResolver = context.getContentResolver();
+ if (overwriteExisting)
+ contentResolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, String.format("%s = ?", MediaColumns.DISPLAY_NAME), new String[]{name});
+ final ContentValues contentValues = new ContentValues();
+ contentValues.put(MediaColumns.DISPLAY_NAME, name);
+ contentValues.put(MediaColumns.MIME_TYPE, mimeType);
+ final Uri contentUri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
+ if (contentUri == null)
+ throw new IOException(context.getString(R.string.create_downloads_file_error));
+ final OutputStream contentStream = contentResolver.openOutputStream(contentUri);
+ if (contentStream == null)
+ throw new IOException(context.getString(R.string.create_downloads_file_error));
+ @SuppressWarnings("deprecation")
+ Cursor cursor = contentResolver.query(contentUri, new String[]{MediaColumns.DATA}, null, null, null);
+ String path = null;
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst())
+ path = cursor.getString(0);
+ } finally {
+ cursor.close();
+ }
+ }
+ if (path == null) {
+ path = "Download/";
+ cursor = contentResolver.query(contentUri, new String[]{MediaColumns.DISPLAY_NAME}, null, null, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst())
+ path += cursor.getString(0);
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+ return new DownloadsFile(context, contentStream, path, contentUri);
+ } else {
+ @SuppressWarnings("deprecation")
+ final File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ final File file = new File(path, name);
+ if (!path.isDirectory() && !path.mkdirs())
+ throw new IOException(context.getString(R.string.create_output_dir_error));
+ return new DownloadsFile(context, new FileOutputStream(file), file.getAbsolutePath(), null);
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ErrorMessages.java b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.java
new file mode 100644
index 00000000..481a6ffb
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright © 2018-2019 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 androidx.annotation.Nullable;
+
+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.BadConfigException.Location;
+import com.wireguard.config.InetEndpoint;
+import com.wireguard.config.InetNetwork;
+import com.wireguard.config.ParseException;
+import com.wireguard.crypto.Key.Format;
+import com.wireguard.crypto.KeyFormatException;
+import com.wireguard.crypto.KeyFormatException.Type;
+
+import java.net.InetAddress;
+import java.util.EnumMap;
+import java.util.Map;
+
+import java9.util.Maps;
+
+public final class ErrorMessages {
+ private static final Map<BadConfigException.Reason, Integer> BCE_REASON_MAP = new EnumMap<>(Maps.of(
+ BadConfigException.Reason.INVALID_KEY, R.string.bad_config_reason_invalid_key,
+ BadConfigException.Reason.INVALID_NUMBER, R.string.bad_config_reason_invalid_number,
+ BadConfigException.Reason.INVALID_VALUE, R.string.bad_config_reason_invalid_value,
+ BadConfigException.Reason.MISSING_ATTRIBUTE, R.string.bad_config_reason_missing_attribute,
+ BadConfigException.Reason.MISSING_SECTION, R.string.bad_config_reason_missing_section,
+ BadConfigException.Reason.MISSING_VALUE, R.string.bad_config_reason_missing_value,
+ BadConfigException.Reason.SYNTAX_ERROR, R.string.bad_config_reason_syntax_error,
+ BadConfigException.Reason.UNKNOWN_ATTRIBUTE, R.string.bad_config_reason_unknown_attribute,
+ BadConfigException.Reason.UNKNOWN_SECTION, R.string.bad_config_reason_unknown_section
+ ));
+ private static final Map<BackendException.Reason, Integer> BE_REASON_MAP = new EnumMap<>(Maps.of(
+ BackendException.Reason.UNKNOWN_KERNEL_MODULE_NAME, R.string.module_version_error,
+ BackendException.Reason.WG_QUICK_CONFIG_ERROR_CODE, R.string.tunnel_config_error,
+ BackendException.Reason.TUNNEL_MISSING_CONFIG, R.string.no_config_error,
+ BackendException.Reason.VPN_NOT_AUTHORIZED, R.string.vpn_not_authorized_error,
+ BackendException.Reason.UNABLE_TO_START_VPN, R.string.vpn_start_error,
+ BackendException.Reason.TUN_CREATION_ERROR, R.string.tun_create_error,
+ BackendException.Reason.GO_ACTIVATION_ERROR_CODE, R.string.tunnel_on_error
+ ));
+ private static final Map<RootShellException.Reason, Integer> RSE_REASON_MAP = new EnumMap<>(Maps.of(
+ RootShellException.Reason.NO_ROOT_ACCESS, R.string.error_root,
+ RootShellException.Reason.SHELL_MARKER_COUNT_ERROR, R.string.shell_marker_count_error,
+ RootShellException.Reason.SHELL_EXIT_STATUS_READ_ERROR, R.string.shell_exit_status_read_error,
+ RootShellException.Reason.SHELL_START_ERROR, R.string.shell_start_error,
+ RootShellException.Reason.CREATE_BIN_DIR_ERROR, R.string.create_bin_dir_error,
+ RootShellException.Reason.CREATE_TEMP_DIR_ERROR, R.string.create_temp_dir_error
+ ));
+ private static final Map<Format, Integer> KFE_FORMAT_MAP = new EnumMap<>(Maps.of(
+ Format.BASE64, R.string.key_length_explanation_base64,
+ Format.BINARY, R.string.key_length_explanation_binary,
+ Format.HEX, R.string.key_length_explanation_hex
+ ));
+ private static final Map<Type, Integer> KFE_TYPE_MAP = new EnumMap<>(Maps.of(
+ Type.CONTENTS, R.string.key_contents_error,
+ Type.LENGTH, R.string.key_length_error
+ ));
+ private static final Map<Class, Integer> PE_CLASS_MAP = Maps.of(
+ InetAddress.class, R.string.parse_error_inet_address,
+ InetEndpoint.class, R.string.parse_error_inet_endpoint,
+ InetNetwork.class, R.string.parse_error_inet_network,
+ Integer.class, R.string.parse_error_integer
+ );
+
+ private ErrorMessages() {
+ // Prevent instantiation
+ }
+
+ public static String get(@Nullable final Throwable throwable) {
+ final Resources resources = Application.get().getResources();
+ if (throwable == null)
+ return resources.getString(R.string.unknown_error);
+ final Throwable rootCause = rootCause(throwable);
+ final String message;
+ if (rootCause instanceof BadConfigException) {
+ final BadConfigException bce = (BadConfigException) rootCause;
+ final String reason = getBadConfigExceptionReason(resources, bce);
+ final String context = bce.getLocation() == Location.TOP_LEVEL ?
+ resources.getString(R.string.bad_config_context_top_level,
+ bce.getSection().getName()) :
+ resources.getString(R.string.bad_config_context,
+ bce.getSection().getName(),
+ bce.getLocation().getName());
+ final String explanation = getBadConfigExceptionExplanation(resources, bce);
+ message = resources.getString(R.string.bad_config_error, reason, context) + explanation;
+ } else if (rootCause instanceof BackendException) {
+ final BackendException be = (BackendException) rootCause;
+ message = resources.getString(BE_REASON_MAP.get(be.getReason()), be.getFormat());
+ } else if (rootCause instanceof RootShellException) {
+ final RootShellException rse = (RootShellException) rootCause;
+ message = resources.getString(RSE_REASON_MAP.get(rse.getReason()), rse.getFormat());
+ } else if (rootCause.getMessage() != null) {
+ message = rootCause.getMessage();
+ } else {
+ final String errorType = rootCause.getClass().getSimpleName();
+ message = resources.getString(R.string.generic_error, errorType);
+ }
+ return message;
+ }
+
+ private static String getBadConfigExceptionExplanation(final Resources resources,
+ final BadConfigException bce) {
+ if (bce.getCause() instanceof KeyFormatException) {
+ final KeyFormatException kfe = (KeyFormatException) bce.getCause();
+ if (kfe.getType() == Type.LENGTH)
+ return resources.getString(KFE_FORMAT_MAP.get(kfe.getFormat()));
+ } else if (bce.getCause() instanceof ParseException) {
+ final ParseException pe = (ParseException) bce.getCause();
+ if (pe.getMessage() != null)
+ return ": " + pe.getMessage();
+ } else if (bce.getLocation() == Location.LISTEN_PORT) {
+ return resources.getString(R.string.bad_config_explanation_udp_port);
+ } else if (bce.getLocation() == Location.MTU) {
+ return resources.getString(R.string.bad_config_explanation_positive_number);
+ } else if (bce.getLocation() == Location.PERSISTENT_KEEPALIVE) {
+ return resources.getString(R.string.bad_config_explanation_pka);
+ }
+ return "";
+ }
+
+ private static String getBadConfigExceptionReason(final Resources resources,
+ final BadConfigException bce) {
+ if (bce.getCause() instanceof KeyFormatException) {
+ final KeyFormatException kfe = (KeyFormatException) bce.getCause();
+ return resources.getString(KFE_TYPE_MAP.get(kfe.getType()));
+ } else if (bce.getCause() instanceof ParseException) {
+ final ParseException pe = (ParseException) bce.getCause();
+ final String type = resources.getString(PE_CLASS_MAP.containsKey(pe.getParsingClass()) ?
+ PE_CLASS_MAP.get(pe.getParsingClass()) : R.string.parse_error_generic);
+ return resources.getString(R.string.parse_error_reason, type, pe.getText());
+ }
+ return resources.getString(BCE_REASON_MAP.get(bce.getReason()), bce.getText());
+ }
+
+ private static Throwable rootCause(final Throwable throwable) {
+ Throwable cause = throwable;
+ while (cause.getCause() != null) {
+ if (cause instanceof BadConfigException || cause instanceof BackendException ||
+ cause instanceof RootShellException)
+ break;
+ final Throwable nextCause = cause.getCause();
+ if (nextCause instanceof RemoteException)
+ break;
+ cause = nextCause;
+ }
+ return cause;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java b/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java
new file mode 100644
index 00000000..5c7a38c0
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import androidx.annotation.Nullable;
+import android.util.Log;
+
+import java9.util.function.BiConsumer;
+
+/**
+ * Helpers for logging exceptions from asynchronous tasks. These can be passed to
+ * {@code CompletionStage.whenComplete()} at the end of an asynchronous future chain.
+ */
+
+public enum ExceptionLoggers implements BiConsumer<Object, Throwable> {
+ D(Log.DEBUG),
+ E(Log.ERROR);
+
+ private static final String TAG = "WireGuard/" + ExceptionLoggers.class.getSimpleName();
+ private final int priority;
+
+ ExceptionLoggers(final int priority) {
+ this.priority = priority;
+ }
+
+ @Override
+ public void accept(final Object result, @Nullable final Throwable throwable) {
+ if (throwable != null)
+ Log.println(Log.ERROR, TAG, Log.getStackTraceString(throwable));
+ else if (priority <= Log.DEBUG)
+ Log.println(priority, TAG, "Future completed successfully");
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/Extensions.kt b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
new file mode 100644
index 00000000..6b528a85
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright © 2020 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
+
+fun Context.resolveAttribute(@AttrRes attrRes: Int): Int {
+ val typedValue = TypedValue()
+ theme.resolveAttribute(attrRes, typedValue, true)
+ return typedValue.data
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/FragmentUtils.java b/ui/src/main/java/com/wireguard/android/util/FragmentUtils.java
new file mode 100644
index 00000000..5fb9a3bc
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/FragmentUtils.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.util;
+
+import android.content.Context;
+import androidx.preference.Preference;
+import android.view.ContextThemeWrapper;
+
+import com.wireguard.android.activity.SettingsActivity;
+
+public final class FragmentUtils {
+ private FragmentUtils() {
+ // Prevent instantiation
+ }
+
+ public static SettingsActivity getPrefActivity(final Preference preference) {
+ final Context context = preference.getContext();
+ if (context instanceof ContextThemeWrapper) {
+ if (context instanceof SettingsActivity) {
+ return ((SettingsActivity) context);
+ }
+ }
+ return null;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ModuleLoader.java b/ui/src/main/java/com/wireguard/android/util/ModuleLoader.java
new file mode 100644
index 00000000..bf094a5e
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ModuleLoader.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright © 2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import android.content.Context;
+import android.system.OsConstants;
+import android.util.Base64;
+
+import com.wireguard.android.util.RootShell.RootShellException;
+
+import net.i2p.crypto.eddsa.EdDSAEngine;
+import net.i2p.crypto.eddsa.EdDSAPublicKey;
+import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable;
+import net.i2p.crypto.eddsa.spec.EdDSAParameterSpec;
+import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidParameterException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.Signature;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nullable;
+
+public class ModuleLoader {
+ private static final String MODULE_PUBLIC_KEY_BASE64 = "RWRmHuT9PSqtwfsLtEx+QS06BJtLgFYteL9WCNjH7yuyu5Y1DieSN7If";
+ private static final String MODULE_LIST_URL = "https://download.wireguard.com/android-module/modules.txt.sig";
+ private static final String MODULE_URL = "https://download.wireguard.com/android-module/%s";
+ private static final String MODULE_NAME = "wireguard-%s.ko";
+
+ private final RootShell rootShell;
+ private final String userAgent;
+ private final File moduleDir;
+ private final File tmpDir;
+
+ public ModuleLoader(final Context context, final RootShell rootShell, final String userAgent) {
+ moduleDir = new File(context.getCacheDir(), "kmod");
+ tmpDir = new File(context.getCacheDir(), "tmp");
+ this.rootShell = rootShell;
+ this.userAgent = userAgent;
+ }
+
+ public boolean moduleMightExist() {
+ return moduleDir.exists() && moduleDir.isDirectory();
+ }
+
+ public void loadModule() throws IOException, RootShellException {
+ rootShell.run(null, String.format("insmod \"%s/wireguard-$(sha256sum /proc/version|cut -d ' ' -f 1).ko\"", moduleDir.getAbsolutePath()));
+ }
+
+ public static boolean isModuleLoaded() {
+ return new File("/sys/module/wireguard").exists();
+ }
+
+ private static final class Sha256Digest {
+ private byte[] bytes;
+ private Sha256Digest(final String hex) {
+ if (hex.length() != 64)
+ throw new InvalidParameterException("SHA256 hashes must be 32 bytes long");
+ bytes = new byte[32];
+ for (int i = 0; i < 32; ++i)
+ bytes[i] = (byte)Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
+ }
+ }
+
+ @Nullable
+ private Map<String, Sha256Digest> verifySignedHashes(final String signifyDigest) {
+ final byte[] publicKeyBytes = Base64.decode(MODULE_PUBLIC_KEY_BASE64, Base64.DEFAULT);
+
+ if (publicKeyBytes == null || publicKeyBytes.length != 32 + 10 || publicKeyBytes[0] != 'E' || publicKeyBytes[1] != 'd')
+ return null;
+
+ final String[] lines = signifyDigest.split("\n", 3);
+ if (lines.length != 3)
+ return null;
+ if (!lines[0].startsWith("untrusted comment: "))
+ return null;
+
+ final byte[] signatureBytes = Base64.decode(lines[1], Base64.DEFAULT);
+ if (signatureBytes == null || signatureBytes.length != 64 + 10)
+ return null;
+ for (int i = 0; i < 10; ++i) {
+ if (signatureBytes[i] != publicKeyBytes[i])
+ return null;
+ }
+
+ try {
+ EdDSAParameterSpec parameterSpec = EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519);
+ Signature signature = new EdDSAEngine(MessageDigest.getInstance(parameterSpec.getHashAlgorithm()));
+ byte[] rawPublicKeyBytes = new byte[32];
+ System.arraycopy(publicKeyBytes, 10, rawPublicKeyBytes, 0, 32);
+ signature.initVerify(new EdDSAPublicKey(new EdDSAPublicKeySpec(rawPublicKeyBytes, parameterSpec)));
+ signature.update(lines[2].getBytes(StandardCharsets.UTF_8));
+ if (!signature.verify(signatureBytes, 10, 64))
+ return null;
+ } catch (final Exception ignored) {
+ return null;
+ }
+
+ Map<String, Sha256Digest> hashes = new HashMap<>();
+ for (final String line : lines[2].split("\n")) {
+ final String[] components = line.split(" ", 2);
+ if (components.length != 2)
+ return null;
+ try {
+ hashes.put(components[1], new Sha256Digest(components[0]));
+ } catch (final Exception ignored) {
+ return null;
+ }
+ }
+ return hashes;
+ }
+
+ public Integer download() throws IOException, RootShellException, NoSuchAlgorithmException {
+ final List<String> output = new ArrayList<>();
+ rootShell.run(output, "sha256sum /proc/version|cut -d ' ' -f 1");
+ if (output.size() != 1 || output.get(0).length() != 64)
+ throw new InvalidParameterException("Invalid sha256 of /proc/version");
+ final String moduleName = String.format(MODULE_NAME, output.get(0));
+ HttpURLConnection connection = (HttpURLConnection)new URL(MODULE_LIST_URL).openConnection();
+ connection.setRequestProperty("User-Agent", userAgent);
+ connection.connect();
+ if (connection.getResponseCode() != HttpURLConnection.HTTP_OK)
+ throw new IOException("Hash list could not be found");
+ byte[] input = new byte[1024 * 1024 * 3 /* 3MiB */];
+ int len;
+ try (final InputStream inputStream = connection.getInputStream()) {
+ len = inputStream.read(input);
+ }
+ if (len <= 0)
+ throw new IOException("Hash list was empty");
+ final Map<String, Sha256Digest> modules = verifySignedHashes(new String(input, 0, len, StandardCharsets.UTF_8));
+ if (modules == null)
+ throw new InvalidParameterException("The signature did not verify or invalid hash list format");
+ if (!modules.containsKey(moduleName))
+ return OsConstants.ENOENT;
+ connection = (HttpURLConnection)new URL(String.format(MODULE_URL, moduleName)).openConnection();
+ connection.setRequestProperty("User-Agent", userAgent);
+ connection.connect();
+ if (connection.getResponseCode() != HttpURLConnection.HTTP_OK)
+ throw new IOException("Module file could not be found, despite being on hash list");
+
+ tmpDir.mkdirs();
+ moduleDir.mkdir();
+ File tempFile = null;
+ try {
+ tempFile = File.createTempFile("UNVERIFIED-", null, tmpDir);
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ try (final InputStream inputStream = connection.getInputStream();
+ final FileOutputStream outputStream = new FileOutputStream(tempFile)) {
+ int total = 0;
+ while ((len = inputStream.read(input)) > 0) {
+ total += len;
+ if (total > 1024 * 1024 * 15 /* 15 MiB */)
+ throw new IOException("File too big");
+ outputStream.write(input, 0, len);
+ digest.update(input, 0, len);
+ }
+ outputStream.getFD().sync();
+ }
+ if (!Arrays.equals(digest.digest(), modules.get(moduleName).bytes))
+ throw new IOException("Incorrect file hash");
+
+ if (!tempFile.renameTo(new File(moduleDir, moduleName)))
+ throw new IOException("Unable to rename to final destination");
+ } finally {
+ if (tempFile != null)
+ tempFile.delete();
+ }
+ return OsConstants.EXIT_SUCCESS;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java
new file mode 100644
index 00000000..0ba02184
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import androidx.databinding.ObservableArrayList;
+import androidx.annotation.Nullable;
+
+import com.wireguard.util.Keyed;
+
+import java.util.Collection;
+import java.util.ListIterator;
+import java.util.Objects;
+
+/**
+ * ArrayList that allows looking up elements by some key property. As the key property must always
+ * be retrievable, this list cannot hold {@code 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.
+ */
+
+public class ObservableKeyedArrayList<K, E extends Keyed<? extends K>>
+ extends ObservableArrayList<E> implements ObservableKeyedList<K, E> {
+ @Override
+ public boolean add(@Nullable final E e) {
+ if (e == null)
+ throw new NullPointerException("Trying to add a null element");
+ return super.add(e);
+ }
+
+ @Override
+ public void add(final int index, @Nullable final E e) {
+ if (e == null)
+ throw new NullPointerException("Trying to add a null element");
+ super.add(index, e);
+ }
+
+ @Override
+ public boolean addAll(final Collection<? extends E> c) {
+ if (c.contains(null))
+ throw new NullPointerException("Trying to add a collection with null element(s)");
+ return super.addAll(c);
+ }
+
+ @Override
+ public boolean addAll(final int index, final Collection<? extends E> c) {
+ if (c.contains(null))
+ throw new NullPointerException("Trying to add a collection with null element(s)");
+ return super.addAll(index, c);
+ }
+
+ @Override
+ public boolean containsAllKeys(final Collection<K> keys) {
+ for (final K key : keys)
+ if (!containsKey(key))
+ return false;
+ return true;
+ }
+
+ @Override
+ public boolean containsKey(final K key) {
+ return indexOfKey(key) >= 0;
+ }
+
+ @Nullable
+ @Override
+ public E get(final K key) {
+ final int index = indexOfKey(key);
+ return index >= 0 ? get(index) : null;
+ }
+
+ @Nullable
+ @Override
+ public E getLast(final K key) {
+ final int index = lastIndexOfKey(key);
+ return index >= 0 ? get(index) : null;
+ }
+
+ @Override
+ public int indexOfKey(final K key) {
+ final ListIterator<E> iterator = listIterator();
+ while (iterator.hasNext()) {
+ final int index = iterator.nextIndex();
+ if (Objects.equals(iterator.next().getKey(), key))
+ return index;
+ }
+ return -1;
+ }
+
+ @Override
+ public int lastIndexOfKey(final K key) {
+ final ListIterator<E> iterator = listIterator(size());
+ while (iterator.hasPrevious()) {
+ final int index = iterator.previousIndex();
+ if (Objects.equals(iterator.previous().getKey(), key))
+ return index;
+ }
+ return -1;
+ }
+
+ @Override
+ public E set(final int index, @Nullable final E e) {
+ if (e == null)
+ throw new NullPointerException("Trying to set a null key");
+ return super.set(index, e);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java
new file mode 100644
index 00000000..be8ceb9b
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import androidx.databinding.ObservableList;
+
+import com.wireguard.util.Keyed;
+import com.wireguard.util.KeyedList;
+
+/**
+ * A list that is both keyed and observable.
+ */
+
+public interface ObservableKeyedList<K, E extends Keyed<? extends K>>
+ extends KeyedList<K, E>, ObservableList<E> {
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java
new file mode 100644
index 00000000..1d585856
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import androidx.annotation.Nullable;
+
+import com.wireguard.util.Keyed;
+import com.wireguard.util.SortedKeyedList;
+
+import java.util.AbstractList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Set;
+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.
+ */
+
+public class ObservableSortedKeyedArrayList<K, E extends Keyed<? extends K>>
+ extends ObservableKeyedArrayList<K, E> implements ObservableSortedKeyedList<K, E> {
+ @Nullable private final Comparator<? super K> comparator;
+ private final transient KeyList<K, E> keyList = new KeyList<>(this);
+
+ @SuppressWarnings("WeakerAccess")
+ public ObservableSortedKeyedArrayList() {
+ comparator = null;
+ }
+
+ public ObservableSortedKeyedArrayList(final Comparator<? super K> comparator) {
+ this.comparator = comparator;
+ }
+
+ public ObservableSortedKeyedArrayList(final Collection<? extends E> c) {
+ this();
+ addAll(c);
+ }
+
+ public ObservableSortedKeyedArrayList(final SortedKeyedList<K, E> other) {
+ this(other.comparator());
+ addAll(other);
+ }
+
+ @Override
+ public boolean add(final E e) {
+ final int insertionPoint = getInsertionPoint(e);
+ if (insertionPoint < 0) {
+ // Skipping insertion is non-destructive if the new and existing objects are the same.
+ if (e == get(-insertionPoint - 1))
+ return false;
+ throw new IllegalArgumentException("Element with same key already exists in list");
+ }
+ super.add(insertionPoint, e);
+ return true;
+ }
+
+ @Override
+ public void add(final int index, final E e) {
+ final int insertionPoint = getInsertionPoint(e);
+ if (insertionPoint < 0)
+ throw new IllegalArgumentException("Element with same key already exists in list");
+ if (insertionPoint != index)
+ throw new IndexOutOfBoundsException("Wrong index given for element");
+ super.add(index, e);
+ }
+
+ @Override
+ public boolean addAll(final Collection<? extends E> c) {
+ boolean didChange = false;
+ for (final E e : c)
+ if (add(e))
+ didChange = true;
+ return didChange;
+ }
+
+ @Override
+ public boolean addAll(int index, final Collection<? extends E> c) {
+ for (final E e : c)
+ add(index++, e);
+ return true;
+ }
+
+ @Nullable
+ @Override
+ public Comparator<? super K> comparator() {
+ return comparator;
+ }
+
+ @Override
+ public K firstKey() {
+ if (isEmpty())
+ // The parameter in the exception is only to shut
+ // lint up, we never care for the exception message.
+ throw new NoSuchElementException("Empty set");
+ return get(0).getKey();
+ }
+
+ private int getInsertionPoint(final E e) {
+ if (comparator != null) {
+ return -Collections.binarySearch(keyList, e.getKey(), comparator) - 1;
+ } else {
+ @SuppressWarnings("unchecked") final List<Comparable<? super K>> list =
+ (List<Comparable<? super K>>) keyList;
+ return -Collections.binarySearch(list, e.getKey()) - 1;
+ }
+ }
+
+ @Override
+ public int indexOfKey(final K key) {
+ final int index;
+ if (comparator != null) {
+ index = Collections.binarySearch(keyList, key, comparator);
+ } else {
+ @SuppressWarnings("unchecked") final List<Comparable<? super K>> list =
+ (List<Comparable<? super K>>) keyList;
+ index = Collections.binarySearch(list, key);
+ }
+ return index >= 0 ? index : -1;
+ }
+
+ @Override
+ public Set<K> keySet() {
+ return keyList;
+ }
+
+ @Override
+ public int lastIndexOfKey(final K key) {
+ // There can never be more than one element with the same key in the list.
+ return indexOfKey(key);
+ }
+
+ @Override
+ public K lastKey() {
+ if (isEmpty())
+ // The parameter in the exception is only to shut
+ // lint up, we never care for the exception message.
+ throw new NoSuchElementException("Empty set");
+ return get(size() - 1).getKey();
+ }
+
+ @Override
+ public E set(final int index, final E e) {
+ final int order;
+ if (comparator != null) {
+ order = comparator.compare(e.getKey(), get(index).getKey());
+ } else {
+ @SuppressWarnings("unchecked") final Comparable<? super K> key =
+ (Comparable<? super K>) e.getKey();
+ order = key.compareTo(get(index).getKey());
+ }
+ if (order != 0) {
+ // Allow replacement if the new key would be inserted adjacent to the replaced element.
+ final int insertionPoint = getInsertionPoint(e);
+ if (insertionPoint < index || insertionPoint > index + 1)
+ throw new IndexOutOfBoundsException("Wrong index given for element");
+ }
+ return super.set(index, e);
+ }
+
+ @Override
+ public Collection<E> values() {
+ return this;
+ }
+
+ private static final class KeyList<K, E extends Keyed<? extends K>>
+ extends AbstractList<K> implements Set<K> {
+ private final ObservableSortedKeyedArrayList<K, E> list;
+
+ private KeyList(final ObservableSortedKeyedArrayList<K, E> list) {
+ this.list = list;
+ }
+
+ @Override
+ public K get(final int index) {
+ return list.get(index).getKey();
+ }
+
+ @Override
+ public int size() {
+ return list.size();
+ }
+
+ @Override
+ @SuppressWarnings("EmptyMethod")
+ public Spliterator<K> spliterator() {
+ return super.spliterator();
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java
new file mode 100644
index 00000000..d796704e
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java
@@ -0,0 +1,17 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import com.wireguard.util.Keyed;
+import com.wireguard.util.SortedKeyedList;
+
+/**
+ * A list that is both sorted/keyed and observable.
+ */
+
+public interface ObservableSortedKeyedList<K, E extends Keyed<? extends K>>
+ extends ObservableKeyedList<K, E>, SortedKeyedList<K, E> {
+}
diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java
new file mode 100644
index 00000000..bcfe14e3
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.viewmodel;
+
+import androidx.databinding.ObservableArrayList;
+import androidx.databinding.ObservableList;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.Config;
+import com.wireguard.config.Peer;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
+public class ConfigProxy implements Parcelable {
+ public static final Parcelable.Creator<ConfigProxy> CREATOR = new ConfigProxyCreator();
+
+ private final InterfaceProxy interfaze;
+ private final ObservableList<PeerProxy> peers = new ObservableArrayList<>();
+
+ private ConfigProxy(final Parcel in) {
+ interfaze = in.readParcelable(InterfaceProxy.class.getClassLoader());
+ in.readTypedList(peers, PeerProxy.CREATOR);
+ for (final PeerProxy proxy : peers)
+ proxy.bind(this);
+ }
+
+ public ConfigProxy(final Config other) {
+ interfaze = new InterfaceProxy(other.getInterface());
+ for (final Peer peer : other.getPeers()) {
+ final PeerProxy proxy = new PeerProxy(peer);
+ peers.add(proxy);
+ proxy.bind(this);
+ }
+ }
+
+ public ConfigProxy() {
+ interfaze = new InterfaceProxy();
+ }
+
+ public PeerProxy addPeer() {
+ final PeerProxy proxy = new PeerProxy();
+ peers.add(proxy);
+ proxy.bind(this);
+ return proxy;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public InterfaceProxy getInterface() {
+ return interfaze;
+ }
+
+ public ObservableList<PeerProxy> getPeers() {
+ return peers;
+ }
+
+ public Config resolve() throws BadConfigException {
+ final Collection<Peer> resolvedPeers = new ArrayList<>();
+ for (final PeerProxy proxy : peers)
+ resolvedPeers.add(proxy.resolve());
+ return new Config.Builder()
+ .setInterface(interfaze.resolve())
+ .addPeers(resolvedPeers)
+ .build();
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(interfaze, flags);
+ dest.writeTypedList(peers);
+ }
+
+ private static class ConfigProxyCreator implements Parcelable.Creator<ConfigProxy> {
+ @Override
+ public ConfigProxy createFromParcel(final Parcel in) {
+ return new ConfigProxy(in);
+ }
+
+ @Override
+ public ConfigProxy[] newArray(final int size) {
+ return new ConfigProxy[size];
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java
new file mode 100644
index 00000000..cc9f2dd8
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.viewmodel;
+
+import androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import androidx.databinding.ObservableArrayList;
+import androidx.databinding.ObservableList;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+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;
+
+import java.net.InetAddress;
+import java.util.List;
+
+import java9.util.stream.Collectors;
+import java9.util.stream.StreamSupport;
+
+public class InterfaceProxy extends BaseObservable implements Parcelable {
+ public static final Parcelable.Creator<InterfaceProxy> CREATOR = new InterfaceProxyCreator();
+
+ private final ObservableList<String> excludedApplications = new ObservableArrayList<>();
+ private String addresses;
+ private String dnsServers;
+ private String listenPort;
+ private String mtu;
+ private String privateKey;
+ private String publicKey;
+
+ private InterfaceProxy(final Parcel in) {
+ addresses = in.readString();
+ dnsServers = in.readString();
+ in.readStringList(excludedApplications);
+ listenPort = in.readString();
+ mtu = in.readString();
+ privateKey = in.readString();
+ publicKey = in.readString();
+ }
+
+ public InterfaceProxy(final Interface other) {
+ addresses = Attribute.join(other.getAddresses());
+ final List<String> dnsServerStrings = StreamSupport.stream(other.getDnsServers())
+ .map(InetAddress::getHostAddress)
+ .collect(Collectors.toUnmodifiableList());
+ dnsServers = Attribute.join(dnsServerStrings);
+ excludedApplications.addAll(other.getExcludedApplications());
+ listenPort = other.getListenPort().map(String::valueOf).orElse("");
+ mtu = other.getMtu().map(String::valueOf).orElse("");
+ final KeyPair keyPair = other.getKeyPair();
+ privateKey = keyPair.getPrivateKey().toBase64();
+ publicKey = keyPair.getPublicKey().toBase64();
+ }
+
+ public InterfaceProxy() {
+ addresses = "";
+ dnsServers = "";
+ listenPort = "";
+ mtu = "";
+ privateKey = "";
+ publicKey = "";
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public void generateKeyPair() {
+ final KeyPair keyPair = new KeyPair();
+ privateKey = keyPair.getPrivateKey().toBase64();
+ publicKey = keyPair.getPublicKey().toBase64();
+ notifyPropertyChanged(BR.privateKey);
+ notifyPropertyChanged(BR.publicKey);
+ }
+
+ @Bindable
+ public String getAddresses() {
+ return addresses;
+ }
+
+ @Bindable
+ public String getDnsServers() {
+ return dnsServers;
+ }
+
+ public ObservableList<String> getExcludedApplications() {
+ return excludedApplications;
+ }
+
+ @Bindable
+ public String getListenPort() {
+ return listenPort;
+ }
+
+ @Bindable
+ public String getMtu() {
+ return mtu;
+ }
+
+ @Bindable
+ public String getPrivateKey() {
+ return privateKey;
+ }
+
+ @Bindable
+ public String getPublicKey() {
+ return publicKey;
+ }
+
+ public Interface resolve() throws BadConfigException {
+ final Interface.Builder builder = new Interface.Builder();
+ if (!addresses.isEmpty())
+ builder.parseAddresses(addresses);
+ if (!dnsServers.isEmpty())
+ builder.parseDnsServers(dnsServers);
+ if (!excludedApplications.isEmpty())
+ builder.excludeApplications(excludedApplications);
+ if (!listenPort.isEmpty())
+ builder.parseListenPort(listenPort);
+ if (!mtu.isEmpty())
+ builder.parseMtu(mtu);
+ if (!privateKey.isEmpty())
+ builder.parsePrivateKey(privateKey);
+ return builder.build();
+ }
+
+ public void setAddresses(final String addresses) {
+ this.addresses = addresses;
+ notifyPropertyChanged(BR.addresses);
+ }
+
+ public void setDnsServers(final String dnsServers) {
+ this.dnsServers = dnsServers;
+ notifyPropertyChanged(BR.dnsServers);
+ }
+
+ public void setListenPort(final String listenPort) {
+ this.listenPort = listenPort;
+ notifyPropertyChanged(BR.listenPort);
+ }
+
+ public void setMtu(final String mtu) {
+ this.mtu = mtu;
+ notifyPropertyChanged(BR.mtu);
+ }
+
+ public void setPrivateKey(final String privateKey) {
+ this.privateKey = privateKey;
+ try {
+ publicKey = new KeyPair(Key.fromBase64(privateKey)).getPublicKey().toBase64();
+ } catch (final KeyFormatException ignored) {
+ publicKey = "";
+ }
+ notifyPropertyChanged(BR.privateKey);
+ notifyPropertyChanged(BR.publicKey);
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeString(addresses);
+ dest.writeString(dnsServers);
+ dest.writeStringList(excludedApplications);
+ dest.writeString(listenPort);
+ dest.writeString(mtu);
+ dest.writeString(privateKey);
+ dest.writeString(publicKey);
+ }
+
+ private static class InterfaceProxyCreator implements Parcelable.Creator<InterfaceProxy> {
+ @Override
+ public InterfaceProxy createFromParcel(final Parcel in) {
+ return new InterfaceProxy(in);
+ }
+
+ @Override
+ public InterfaceProxy[] newArray(final int size) {
+ return new InterfaceProxy[size];
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java
new file mode 100644
index 00000000..7dc50f09
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java
@@ -0,0 +1,380 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.viewmodel;
+
+import androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import androidx.databinding.Observable;
+import androidx.databinding.ObservableList;
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.BR;
+import com.wireguard.config.Attribute;
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.InetEndpoint;
+import com.wireguard.config.Peer;
+import com.wireguard.crypto.Key;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import java9.util.Lists;
+import java9.util.Sets;
+import java9.util.stream.Collectors;
+import java9.util.stream.Stream;
+
+public class PeerProxy extends BaseObservable implements Parcelable {
+ public static final Parcelable.Creator<PeerProxy> CREATOR = new PeerProxyCreator();
+ private static final Set<String> IPV4_PUBLIC_NETWORKS = new LinkedHashSet<>(Lists.of(
+ "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 static final Set<String> IPV4_WILDCARD = Sets.of("0.0.0.0/0");
+
+ private final List<String> dnsRoutes = new ArrayList<>();
+ private String allowedIps;
+ private AllowedIpsState allowedIpsState = AllowedIpsState.INVALID;
+ private String endpoint;
+ @Nullable private InterfaceDnsListener interfaceDnsListener;
+ @Nullable private ConfigProxy owner;
+ @Nullable private PeerListListener peerListListener;
+ private String persistentKeepalive;
+ private String preSharedKey;
+ private String publicKey;
+ private int totalPeers;
+
+ private PeerProxy(final Parcel in) {
+ allowedIps = in.readString();
+ endpoint = in.readString();
+ persistentKeepalive = in.readString();
+ preSharedKey = in.readString();
+ publicKey = in.readString();
+ }
+
+ public PeerProxy(final Peer other) {
+ allowedIps = Attribute.join(other.getAllowedIps());
+ endpoint = other.getEndpoint().map(InetEndpoint::toString).orElse("");
+ persistentKeepalive = other.getPersistentKeepalive().map(String::valueOf).orElse("");
+ preSharedKey = other.getPreSharedKey().map(Key::toBase64).orElse("");
+ publicKey = other.getPublicKey().toBase64();
+ }
+
+ public PeerProxy() {
+ allowedIps = "";
+ endpoint = "";
+ persistentKeepalive = "";
+ preSharedKey = "";
+ publicKey = "";
+ }
+
+ public void bind(final ConfigProxy owner) {
+ final InterfaceProxy interfaze = owner.getInterface();
+ final ObservableList<PeerProxy> peers = owner.getPeers();
+ if (interfaceDnsListener == null)
+ interfaceDnsListener = new InterfaceDnsListener(this);
+ interfaze.addOnPropertyChangedCallback(interfaceDnsListener);
+ setInterfaceDns(interfaze.getDnsServers());
+ if (peerListListener == null)
+ peerListListener = new PeerListListener(this);
+ peers.addOnListChangedCallback(peerListListener);
+ setTotalPeers(peers.size());
+ this.owner = owner;
+ }
+
+ private void calculateAllowedIpsState() {
+ final 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.
+ final Collection<String> networkStrings = getAllowedIpsSet();
+ // If allowedIps contains both the wildcard and the public networks, then private
+ // networks aren't excluded!
+ if (networkStrings.containsAll(IPV4_WILDCARD))
+ newState = AllowedIpsState.CONTAINS_IPV4_WILDCARD;
+ else if (networkStrings.containsAll(IPV4_PUBLIC_NETWORKS))
+ newState = AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS;
+ else
+ newState = AllowedIpsState.OTHER;
+ } else {
+ newState = AllowedIpsState.INVALID;
+ }
+ if (newState != allowedIpsState) {
+ allowedIpsState = newState;
+ notifyPropertyChanged(BR.ableToExcludePrivateIps);
+ notifyPropertyChanged(BR.excludingPrivateIps);
+ }
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Bindable
+ public String getAllowedIps() {
+ return allowedIps;
+ }
+
+ private Set<String> getAllowedIpsSet() {
+ return new LinkedHashSet<>(Lists.of(Attribute.split(allowedIps)));
+ }
+
+ @Bindable
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ @Bindable
+ public String getPersistentKeepalive() {
+ return persistentKeepalive;
+ }
+
+ @Bindable
+ public String getPreSharedKey() {
+ return preSharedKey;
+ }
+
+ @Bindable
+ public String getPublicKey() {
+ return publicKey;
+ }
+
+ @Bindable
+ public boolean isAbleToExcludePrivateIps() {
+ return allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS
+ || allowedIpsState == AllowedIpsState.CONTAINS_IPV4_WILDCARD;
+ }
+
+ @Bindable
+ public boolean isExcludingPrivateIps() {
+ return allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS;
+ }
+
+ public Peer resolve() throws BadConfigException {
+ final Peer.Builder builder = new Peer.Builder();
+ if (!allowedIps.isEmpty())
+ builder.parseAllowedIPs(allowedIps);
+ if (!endpoint.isEmpty())
+ builder.parseEndpoint(endpoint);
+ if (!persistentKeepalive.isEmpty())
+ builder.parsePersistentKeepalive(persistentKeepalive);
+ if (!preSharedKey.isEmpty())
+ builder.parsePreSharedKey(preSharedKey);
+ if (!publicKey.isEmpty())
+ builder.parsePublicKey(publicKey);
+ return builder.build();
+ }
+
+ public void setAllowedIps(final String allowedIps) {
+ this.allowedIps = allowedIps;
+ notifyPropertyChanged(BR.allowedIps);
+ calculateAllowedIpsState();
+ }
+
+ public void setEndpoint(final String endpoint) {
+ this.endpoint = endpoint;
+ notifyPropertyChanged(BR.endpoint);
+ }
+
+ public void setExcludingPrivateIps(final boolean excludingPrivateIps) {
+ if (!isAbleToExcludePrivateIps() || isExcludingPrivateIps() == excludingPrivateIps)
+ return;
+ final Set<String> oldNetworks = excludingPrivateIps ? IPV4_WILDCARD : IPV4_PUBLIC_NETWORKS;
+ final Set<String> newNetworks = excludingPrivateIps ? IPV4_PUBLIC_NETWORKS : IPV4_WILDCARD;
+ final Collection<String> input = getAllowedIpsSet();
+ final int outputSize = input.size() - oldNetworks.size() + newNetworks.size();
+ final Collection<String> output = new LinkedHashSet<>(outputSize);
+ boolean replaced = false;
+ // Replace the first instance of the wildcard with the public network list, or vice versa.
+ for (final String network : input) {
+ if (oldNetworks.contains(network)) {
+ if (!replaced) {
+ for (final String replacement : 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 = excludingPrivateIps ?
+ AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS : AllowedIpsState.CONTAINS_IPV4_WILDCARD;
+ notifyPropertyChanged(BR.allowedIps);
+ notifyPropertyChanged(BR.excludingPrivateIps);
+ }
+
+ private void setInterfaceDns(final CharSequence dnsServers) {
+ final List<String> newDnsRoutes = Stream.of(Attribute.split(dnsServers))
+ .filter(server -> !server.contains(":"))
+ .map(server -> server + "/32")
+ .collect(Collectors.toUnmodifiableList());
+ if (allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS) {
+ final Collection<String> input = getAllowedIpsSet();
+ final Collection<String> output = new LinkedHashSet<>(input.size() + 1);
+ // Yes, this is quadratic in the number of DNS servers, but most users have 1 or 2.
+ for (final String network : input)
+ if (!dnsRoutes.contains(network) || newDnsRoutes.contains(network))
+ output.add(network);
+ // Since output is a Set, this does the Right Thing™ (it does not duplicate networks).
+ output.addAll(newDnsRoutes);
+ // 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);
+ }
+
+ public void setPersistentKeepalive(final String persistentKeepalive) {
+ this.persistentKeepalive = persistentKeepalive;
+ notifyPropertyChanged(BR.persistentKeepalive);
+ }
+
+ public void setPreSharedKey(final String preSharedKey) {
+ this.preSharedKey = preSharedKey;
+ notifyPropertyChanged(BR.preSharedKey);
+ }
+
+ public void setPublicKey(final String publicKey) {
+ this.publicKey = publicKey;
+ notifyPropertyChanged(BR.publicKey);
+ }
+
+ private void setTotalPeers(final int totalPeers) {
+ if (this.totalPeers == totalPeers)
+ return;
+ this.totalPeers = totalPeers;
+ calculateAllowedIpsState();
+ }
+
+ public void unbind() {
+ if (owner == null)
+ return;
+ final InterfaceProxy interfaze = owner.getInterface();
+ final ObservableList<PeerProxy> peers = owner.getPeers();
+ if (interfaceDnsListener != null)
+ interfaze.removeOnPropertyChangedCallback(interfaceDnsListener);
+ if (peerListListener != null)
+ peers.removeOnListChangedCallback(peerListListener);
+ peers.remove(this);
+ setInterfaceDns("");
+ setTotalPeers(0);
+ owner = null;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeString(allowedIps);
+ dest.writeString(endpoint);
+ dest.writeString(persistentKeepalive);
+ dest.writeString(preSharedKey);
+ dest.writeString(publicKey);
+ }
+
+ private enum AllowedIpsState {
+ CONTAINS_IPV4_PUBLIC_NETWORKS,
+ CONTAINS_IPV4_WILDCARD,
+ INVALID,
+ OTHER
+ }
+
+ private static final class InterfaceDnsListener extends Observable.OnPropertyChangedCallback {
+ private final WeakReference<PeerProxy> weakPeerProxy;
+
+ private InterfaceDnsListener(final PeerProxy peerProxy) {
+ weakPeerProxy = new WeakReference<>(peerProxy);
+ }
+
+ @Override
+ public void onPropertyChanged(final Observable sender, final int propertyId) {
+ @Nullable final PeerProxy peerProxy = weakPeerProxy.get();
+ if (peerProxy == null) {
+ sender.removeOnPropertyChangedCallback(this);
+ return;
+ }
+ // This shouldn't be possible, but try to avoid a ClassCastException anyway.
+ if (!(sender instanceof InterfaceProxy))
+ return;
+ if (!(propertyId == BR._all || propertyId == BR.dnsServers))
+ return;
+ peerProxy.setInterfaceDns(((InterfaceProxy) sender).getDnsServers());
+ }
+ }
+
+ private static final class PeerListListener
+ extends ObservableList.OnListChangedCallback<ObservableList<PeerProxy>> {
+ private final WeakReference<PeerProxy> weakPeerProxy;
+
+ private PeerListListener(final PeerProxy peerProxy) {
+ weakPeerProxy = new WeakReference<>(peerProxy);
+ }
+
+ @Override
+ public void onChanged(final ObservableList<PeerProxy> sender) {
+ @Nullable final PeerProxy peerProxy = weakPeerProxy.get();
+ if (peerProxy == null) {
+ sender.removeOnListChangedCallback(this);
+ return;
+ }
+ peerProxy.setTotalPeers(sender.size());
+ }
+
+ @Override
+ public void onItemRangeChanged(final ObservableList<PeerProxy> sender,
+ final int positionStart, final int itemCount) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onItemRangeInserted(final ObservableList<PeerProxy> sender,
+ final int positionStart, final int itemCount) {
+ onChanged(sender);
+ }
+
+ @Override
+ public void onItemRangeMoved(final ObservableList<PeerProxy> sender,
+ final int fromPosition, final int toPosition,
+ final int itemCount) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onItemRangeRemoved(final ObservableList<PeerProxy> sender,
+ final int positionStart, final int itemCount) {
+ onChanged(sender);
+ }
+ }
+
+ private static class PeerProxyCreator implements Parcelable.Creator<PeerProxy> {
+ @Override
+ public PeerProxy createFromParcel(final Parcel in) {
+ return new PeerProxy(in);
+ }
+
+ @Override
+ public PeerProxy[] newArray(final int size) {
+ return new PeerProxy[size];
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java
new file mode 100644
index 00000000..79572aa3
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.widget;
+
+import androidx.annotation.Nullable;
+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.
+ */
+
+public class KeyInputFilter implements InputFilter {
+ private static boolean isAllowed(final char c) {
+ return Character.isLetterOrDigit(c) || c == '+' || c == '/';
+ }
+
+ public static InputFilter newInstance() {
+ return new KeyInputFilter();
+ }
+
+ @Nullable
+ @Override
+ public CharSequence filter(final CharSequence source,
+ final int sStart, final int sEnd,
+ final Spanned dest,
+ final int dStart, final int dEnd) {
+ SpannableStringBuilder replacement = null;
+ int rIndex = 0;
+ final int dLength = dest.length();
+ for (int sIndex = sStart; sIndex < sEnd; ++sIndex) {
+ final char c = source.charAt(sIndex);
+ final int 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.getLength() && isAllowed(c)) ||
+ (dIndex + 1 == Key.Format.BASE64.getLength() && c == '=')) &&
+ dLength + (sIndex - sStart) < Key.Format.BASE64.getLength()) {
+ ++rIndex;
+ } else {
+ if (replacement == null)
+ replacement = new SpannableStringBuilder(source, sStart, sEnd);
+ replacement.delete(rIndex, rIndex + 1);
+ }
+ }
+ return replacement;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java
new file mode 100644
index 00000000..2fe9c924
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright © 2017-2019 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.widget.RelativeLayout;
+
+import com.wireguard.android.R;
+
+public class MultiselectableRelativeLayout extends RelativeLayout {
+ private static final int[] STATE_MULTISELECTED = {R.attr.state_multiselected};
+ private boolean multiselected;
+
+ public MultiselectableRelativeLayout(final Context context) {
+ super(context);
+ }
+
+ public MultiselectableRelativeLayout(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public MultiselectableRelativeLayout(final Context context, final AttributeSet attrs, final int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public MultiselectableRelativeLayout(final Context context, final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ @Override
+ protected int[] onCreateDrawableState(final int extraSpace) {
+ if (multiselected) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+ mergeDrawableStates(drawableState, STATE_MULTISELECTED);
+ return drawableState;
+ }
+ return super.onCreateDrawableState(extraSpace);
+ }
+
+ public void setMultiSelected(final boolean on) {
+ if (!multiselected) {
+ multiselected = true;
+ refreshDrawableState();
+ }
+ setActivated(on);
+ }
+
+ public void setSingleSelected(final boolean on) {
+ if (multiselected) {
+ multiselected = false;
+ refreshDrawableState();
+ }
+ setActivated(on);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java
new file mode 100644
index 00000000..030be25a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.widget;
+
+import androidx.annotation.Nullable;
+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).
+ */
+
+public class NameInputFilter implements InputFilter {
+ private static boolean isAllowed(final char c) {
+ return Character.isLetterOrDigit(c) || "_=+.-".indexOf(c) >= 0;
+ }
+
+ public static InputFilter newInstance() {
+ return new NameInputFilter();
+ }
+
+ @Nullable
+ @Override
+ public CharSequence filter(final CharSequence source,
+ final int sStart, final int sEnd,
+ final Spanned dest,
+ final int dStart, final int dEnd) {
+ SpannableStringBuilder replacement = null;
+ int rIndex = 0;
+ final int dLength = dest.length();
+ for (int sIndex = sStart; sIndex < sEnd; ++sIndex) {
+ final char c = source.charAt(sIndex);
+ final int 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 = new SpannableStringBuilder(source, sStart, sEnd);
+ replacement.delete(rIndex, rIndex + 1);
+ }
+ }
+ return replacement;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java
new file mode 100644
index 00000000..e020aa81
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright © 2018 The Android Open Source Project
+ * Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.widget;
+
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.content.res.ColorStateList;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Path.Direction;
+import android.graphics.PixelFormat;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Region;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import androidx.annotation.ColorInt;
+import androidx.annotation.IntRange;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import android.util.FloatProperty;
+
+@RequiresApi(Build.VERSION_CODES.N)
+public class SlashDrawable extends Drawable {
+
+ private static final float CENTER_X = 10.65f;
+ private static final float CENTER_Y = 11.869239f;
+ private static final float CORNER_RADIUS = Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? 0f : 1f;
+ // Draw the slash washington-monument style; rotate to no-u-turn style
+ private static final float DEFAULT_ROTATION = -45f;
+ private static final long QS_ANIM_LENGTH = 350;
+ private static final float SCALE = 24f;
+ private static final float SLASH_HEIGHT = 28f;
+ // These values are derived in un-rotated (vertical) orientation
+ private static final float SLASH_WIDTH = 1.8384776f;
+ // Bottom is derived during animation
+ private static final float LEFT = (CENTER_X - (SLASH_WIDTH / 2)) / SCALE;
+ private static final float RIGHT = (CENTER_X + (SLASH_WIDTH / 2)) / SCALE;
+ private static final float TOP = (CENTER_Y - (SLASH_HEIGHT / 2)) / SCALE;
+ private static final FloatProperty mSlashLengthProp = new FloatProperty<SlashDrawable>("slashLength") {
+ @Override
+ public Float get(final SlashDrawable object) {
+ return object.mCurrentSlashLength;
+ }
+
+ @Override
+ public void setValue(final SlashDrawable object, final float value) {
+ object.mCurrentSlashLength = value;
+ }
+ };
+ private final Drawable mDrawable;
+ private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path mPath = new Path();
+ private final RectF mSlashRect = new RectF(0, 0, 0, 0);
+ private boolean mAnimationEnabled = true;
+ // Animate this value on change
+ private float mCurrentSlashLength;
+ private float mRotation;
+ private boolean mSlashed;
+
+ public SlashDrawable(final Drawable d) {
+ mDrawable = d;
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public void draw(final Canvas canvas) {
+ canvas.save();
+ final Matrix m = new Matrix();
+ final int width = getBounds().width();
+ final int height = getBounds().height();
+ final float radiusX = scale(CORNER_RADIUS, width);
+ final float 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, Direction.CW);
+ // Rotate -45 + desired rotation
+ m.setRotate(mRotation + DEFAULT_ROTATION, width / 2, height / 2);
+ mPath.transform(m);
+ canvas.drawPath(mPath, mPaint);
+
+ // Rotate back to vertical
+ m.setRotate(-mRotation - DEFAULT_ROTATION, width / 2, height / 2);
+ mPath.transform(m);
+
+ // Draw another rect right next to the first, for clipping
+ m.setTranslate(mSlashRect.width(), 0);
+ mPath.transform(m);
+ mPath.addRoundRect(mSlashRect, 1.0f * width, 1.0f * height, Direction.CW);
+ m.setRotate(mRotation + DEFAULT_ROTATION, width / 2, height / 2);
+ mPath.transform(m);
+
+ 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
+ public int getIntrinsicHeight() {
+ return mDrawable.getIntrinsicHeight();
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ return mDrawable.getIntrinsicWidth();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public int getOpacity() {
+ return PixelFormat.OPAQUE;
+ }
+
+ @Override
+ protected void onBoundsChange(final Rect bounds) {
+ super.onBoundsChange(bounds);
+ mDrawable.setBounds(bounds);
+ }
+
+ private float scale(final float frac, final int width) {
+ return frac * width;
+ }
+
+ @Override
+ public void setAlpha(@IntRange(from = 0, to = 255) final int alpha) {
+ mDrawable.setAlpha(alpha);
+ mPaint.setAlpha(alpha);
+ }
+
+ public void setAnimationEnabled(final boolean enabled) {
+ mAnimationEnabled = enabled;
+ }
+
+ @Override
+ public void setColorFilter(@Nullable final ColorFilter colorFilter) {
+ mDrawable.setColorFilter(colorFilter);
+ mPaint.setColorFilter(colorFilter);
+ }
+
+ private void setDrawableTintList(@Nullable final ColorStateList tint) {
+ mDrawable.setTintList(tint);
+ }
+
+ public void setRotation(final float rotation) {
+ if (mRotation == rotation)
+ return;
+ mRotation = rotation;
+ invalidateSelf();
+ }
+
+ @SuppressWarnings("unchecked")
+ public void setSlashed(final boolean slashed) {
+ if (mSlashed == slashed) return;
+
+ mSlashed = slashed;
+
+ final float end = mSlashed ? SLASH_HEIGHT / SCALE : 0f;
+ final float start = mSlashed ? 0f : SLASH_HEIGHT / SCALE;
+
+ if (mAnimationEnabled) {
+ final ObjectAnimator anim = ObjectAnimator.ofFloat(this, mSlashLengthProp, start, end);
+ anim.addUpdateListener((ValueAnimator valueAnimator) -> invalidateSelf());
+ anim.setDuration(QS_ANIM_LENGTH);
+ anim.start();
+ } else {
+ mCurrentSlashLength = end;
+ invalidateSelf();
+ }
+ }
+
+ @Override
+ public void setTint(@ColorInt final int tintColor) {
+ super.setTint(tintColor);
+ mDrawable.setTint(tintColor);
+ mPaint.setColor(tintColor);
+ }
+
+ @Override
+ public void setTintList(@Nullable final ColorStateList tint) {
+ super.setTintList(tint);
+ setDrawableTintList(tint);
+ mPaint.setColor(tint == null ? 0 : tint.getDefaultColor());
+ invalidateSelf();
+ }
+
+ @Override
+ public void setTintMode(final Mode tintMode) {
+ super.setTintMode(tintMode);
+ mDrawable.setTintMode(tintMode);
+ }
+
+ private void updateRect(final float left, final float top, final float right, final float bottom) {
+ mSlashRect.left = left;
+ mSlashRect.top = top;
+ mSlashRect.right = right;
+ mSlashRect.bottom = bottom;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java
new file mode 100644
index 00000000..dcb9aceb
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright © 2013 The Android Open Source Project
+ * Copyright © 2017-2019 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 androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.widget.Switch;
+
+public class ToggleSwitch extends Switch {
+ private boolean isRestoringState;
+ @Nullable private OnBeforeCheckedChangeListener listener;
+
+ public ToggleSwitch(final Context context) {
+ this(context, null);
+ }
+
+ @SuppressWarnings({"SameParameterValue", "WeakerAccess"})
+ public ToggleSwitch(final Context context, @Nullable final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void onRestoreInstanceState(final Parcelable state) {
+ isRestoringState = true;
+ super.onRestoreInstanceState(state);
+ isRestoringState = false;
+ }
+
+ @Override
+ public void setChecked(final boolean checked) {
+ if (checked == isChecked())
+ return;
+ if (isRestoringState || listener == null) {
+ super.setChecked(checked);
+ return;
+ }
+ setEnabled(false);
+ listener.onBeforeCheckedChanged(this, checked);
+ }
+
+ public void setCheckedInternal(final boolean checked) {
+ super.setChecked(checked);
+ setEnabled(true);
+ }
+
+ public void setOnBeforeCheckedChangeListener(final OnBeforeCheckedChangeListener listener) {
+ this.listener = listener;
+ }
+
+ public interface OnBeforeCheckedChangeListener {
+ void onBeforeCheckedChanged(ToggleSwitch toggleSwitch, boolean checked);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/util/Keyed.java b/ui/src/main/java/com/wireguard/util/Keyed.java
new file mode 100644
index 00000000..f31a43a2
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/util/Keyed.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.util;
+
+/**
+ * Interface for objects that have a identifying key of the given type.
+ */
+
+public interface Keyed<K> {
+ K getKey();
+}
diff --git a/ui/src/main/java/com/wireguard/util/KeyedList.java b/ui/src/main/java/com/wireguard/util/KeyedList.java
new file mode 100644
index 00000000..c116c1da
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/util/KeyedList.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.util;
+
+import androidx.annotation.Nullable;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A list containing elements that can be looked up by key. A {@code KeyedList} cannot contain
+ * {@code null} elements.
+ */
+
+public interface KeyedList<K, E extends Keyed<? extends K>> extends List<E> {
+ boolean containsAllKeys(Collection<K> keys);
+
+ boolean containsKey(K key);
+
+ @Nullable
+ E get(K key);
+
+ @Nullable
+ E getLast(K key);
+
+ int indexOfKey(K key);
+
+ int lastIndexOfKey(K key);
+}
diff --git a/ui/src/main/java/com/wireguard/util/SortedKeyedList.java b/ui/src/main/java/com/wireguard/util/SortedKeyedList.java
new file mode 100644
index 00000000..b144fc85
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/util/SortedKeyedList.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.util;
+
+import androidx.annotation.Nullable;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Set;
+
+/**
+ * A keyed list where all elements are sorted by the comparator returned by {@code comparator()}
+ * applied to their keys.
+ */
+
+public interface SortedKeyedList<K, E extends Keyed<? extends K>> extends KeyedList<K, E> {
+ Comparator<? super K> comparator();
+
+ @Nullable
+ K firstKey();
+
+ Set<K> keySet();
+
+ @Nullable
+ K lastKey();
+
+ Collection<E> values();
+}
diff --git a/ui/src/main/res/drawable/ic_action_add_white.xml b/ui/src/main/res/drawable/ic_action_add_white.xml
new file mode 100644
index 00000000..cbb4c4e6
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_action_add_white.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24.0"
+ android:viewportWidth="24.0">
+ <path
+ android:fillColor="?attr/colorOnSecondary"
+ android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
+</vector>
diff --git a/ui/src/main/res/drawable/ic_action_delete.xml b/ui/src/main/res/drawable/ic_action_delete.xml
new file mode 100644
index 00000000..d4ebd61f
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_action_delete.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorOnPrimary"
+ 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>
diff --git a/ui/src/main/res/drawable/ic_action_edit.xml b/ui/src/main/res/drawable/ic_action_edit.xml
new file mode 100644
index 00000000..fc42d2ef
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_action_edit.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorOnPrimary"
+ 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>
diff --git a/ui/src/main/res/drawable/ic_action_edit_white.xml b/ui/src/main/res/drawable/ic_action_edit_white.xml
new file mode 100644
index 00000000..fa95f54b
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_action_edit_white.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorOnSecondary"
+ 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>
diff --git a/ui/src/main/res/drawable/ic_action_open_white.xml b/ui/src/main/res/drawable/ic_action_open_white.xml
new file mode 100644
index 00000000..01b2815c
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_action_open_white.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorOnSecondary"
+ 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>
diff --git a/ui/src/main/res/drawable/ic_action_save.xml b/ui/src/main/res/drawable/ic_action_save.xml
new file mode 100644
index 00000000..528bd997
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_action_save.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorOnPrimary"
+ 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>
diff --git a/ui/src/main/res/drawable/ic_action_scan_qr_code_white.xml b/ui/src/main/res/drawable/ic_action_scan_qr_code_white.xml
new file mode 100644
index 00000000..2383d032
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_action_scan_qr_code_white.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorOnSecondary"
+ 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> \ No newline at end of file
diff --git a/ui/src/main/res/drawable/ic_action_select_all.xml b/ui/src/main/res/drawable/ic_action_select_all.xml
new file mode 100644
index 00000000..28837423
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_action_select_all.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorOnPrimary"
+ 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>
diff --git a/ui/src/main/res/drawable/ic_launcher_foreground.xml b/ui/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..f9713f37
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportHeight="2160"
+ android:viewportWidth="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> \ No newline at end of file
diff --git a/ui/src/main/res/drawable/ic_settings.xml b/ui/src/main/res/drawable/ic_settings.xml
new file mode 100644
index 00000000..aabfce2a
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_settings.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?android:attr/colorForeground"
+ 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>
diff --git a/ui/src/main/res/drawable/ic_tile.xml b/ui/src/main/res/drawable/ic_tile.xml
new file mode 100644
index 00000000..eaf784c1
--- /dev/null
+++ b/ui/src/main/res/drawable/ic_tile.xml
@@ -0,0 +1,24 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="400dp"
+ android:height="400dp"
+ android:viewportHeight="400.0"
+ android:viewportWidth="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:strokeColor="#00000000"
+ android:strokeWidth="1.33333325" />
+ <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:strokeColor="#00000000"
+ android:strokeWidth="1.33333325" />
+ <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:strokeColor="#00000000"
+ android:strokeWidth="1.33333325" />
+</vector>
diff --git a/ui/src/main/res/drawable/list_item_background.xml b/ui/src/main/res/drawable/list_item_background.xml
new file mode 100644
index 00000000..f967f700
--- /dev/null
+++ b/ui/src/main/res/drawable/list_item_background.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+ <item>
+ <selector>
+ <item app:state_multiselected="true" android:state_activated="true">
+ <color android:color="?attr/colorMultiselectActiveBackground" />
+ </item>
+ </selector>
+ </item>
+ <item android:drawable="?attr/selectableItemBackground" />
+</layer-list>
diff --git a/ui/src/main/res/layout-sw600dp/main_activity.xml b/ui/src/main/res/layout-sw600dp/main_activity.xml
new file mode 100644
index 00000000..5104df93
--- /dev/null
+++ b/ui/src/main/res/layout-sw600dp/main_activity.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ 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>
diff --git a/ui/src/main/res/layout/add_tunnels_bottom_sheet.xml b/ui/src/main/res/layout/add_tunnels_bottom_sheet.xml
new file mode 100644
index 00000000..62f168b1
--- /dev/null
+++ b/ui/src/main/res/layout/add_tunnels_bottom_sheet.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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_empty"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/bottom_sheet_item_height"
+ android:layout_marginLeft="@dimen/normal_margin"
+ android:layout_marginRight="@dimen/normal_margin"
+ android:layout_marginStart="@dimen/normal_margin"
+ android:layout_marginEnd="@dimen/normal_margin"
+ 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_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toTopOf="@+id/create_from_file"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:rippleColor="?attr/colorSecondary"
+ style="@style/Widget.MaterialComponents.Button.TextButton.Icon"/>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/create_from_file"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/bottom_sheet_item_height"
+ android:layout_marginLeft="@dimen/normal_margin"
+ android:layout_marginRight="@dimen/normal_margin"
+ android:layout_marginStart="@dimen/normal_margin"
+ android:layout_marginEnd="@dimen/normal_margin"
+ android:text="@string/create_from_file"
+ android:textAlignment="viewStart"
+ android:textColor="?attr/colorOnSurface"
+ app:icon="@drawable/ic_action_open_white"
+ app:iconPadding="@dimen/bottom_sheet_icon_padding"
+ app:iconTint="?attr/colorSecondary"
+ app:layout_constraintTop_toBottomOf="@+id/create_empty"
+ app:layout_constraintBottom_toTopOf="@+id/create_from_qrcode"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:rippleColor="?attr/colorSecondary"
+ style="@style/Widget.MaterialComponents.Button.TextButton.Icon"/>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/create_from_qrcode"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/bottom_sheet_item_height"
+ android:layout_marginLeft="@dimen/normal_margin"
+ android:layout_marginRight="@dimen/normal_margin"
+ android:layout_marginStart="@dimen/normal_margin"
+ android:layout_marginEnd="@dimen/normal_margin"
+ android:text="@string/create_from_qr_code"
+ android:textAlignment="viewStart"
+ android:textColor="?attr/colorOnSurface"
+ app:icon="@drawable/ic_action_scan_qr_code_white"
+ app:iconPadding="@dimen/bottom_sheet_icon_padding"
+ app:iconTint="?attr/colorSecondary"
+ app:layout_constraintTop_toBottomOf="@+id/create_from_file"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:rippleColor="?attr/colorSecondary"
+ style="@style/Widget.MaterialComponents.Button.TextButton.Icon"/>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/ui/src/main/res/layout/app_list_dialog_fragment.xml b/ui/src/main/res/layout/app_list_dialog_fragment.xml
new file mode 100644
index 00000000..c91161e6
--- /dev/null
+++ b/ui/src/main/res/layout/app_list_dialog_fragment.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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.util.ObservableKeyedList&lt;String, ApplicationData&gt;" />
+ </data>
+
+ <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>
+
+
+</layout>
diff --git a/ui/src/main/res/layout/app_list_item.xml b/ui/src/main/res/layout/app_list_item.xml
new file mode 100644
index 00000000..1e81751b
--- /dev/null
+++ b/ui/src/main/res/layout/app_list_item.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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.util.ObservableKeyedList&lt;String, com.wireguard.android.model.ApplicationData&gt;" />
+
+ <variable
+ name="key"
+ type="String" />
+
+ <variable
+ name="item"
+ type="com.wireguard.android.model.ApplicationData" />
+ </data>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="@drawable/list_item_background"
+ android:gravity="center_vertical"
+ android:onClick="@{(view) -> item.setExcludedFromTunnel(!item.excludedFromTunnel)}"
+ 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"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:text="@{key}"
+ tools:text="@tools:sample/full_names" />
+
+ <CheckBox
+ android:id="@+id/excluded_checkbox"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:checked="@={item.excludedFromTunnel}"
+ tools:checked="true" />
+
+ </LinearLayout>
+</layout>
diff --git a/ui/src/main/res/layout/config_naming_dialog_fragment.xml b/ui/src/main/res/layout/config_naming_dialog_fragment.xml
new file mode 100644
index 00000000..a7017804
--- /dev/null
+++ b/ui/src/main/res/layout/config_naming_dialog_fragment.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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">
+
+ <EditText
+ android:id="@+id/tunnel_name_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/tunnel_name"
+ android:inputType="textNoSuggestions|textVisiblePassword"
+ app:filter="@{NameInputFilter.newInstance()}" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ </FrameLayout>
+
+
+</layout>
diff --git a/ui/src/main/res/layout/main_activity.xml b/ui/src/main/res/layout/main_activity.xml
new file mode 100644
index 00000000..cf892e45
--- /dev/null
+++ b/ui/src/main/res/layout/main_activity.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/master_detail_wrapper"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ tools:context=".activity.MainActivity"
+ android:fitsSystemWindows="true">
+
+ <androidx.fragment.app.FragmentContainerView
+ android:id="@+id/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>
diff --git a/ui/src/main/res/layout/tunnel_detail_fragment.xml b/ui/src/main/res/layout/tunnel_detail_fragment.xml
new file mode 100644
index 00000000..463f8b80
--- /dev/null
+++ b/ui/src/main/res/layout/tunnel_detail_fragment.xml
@@ -0,0 +1,139 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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/colorBackground">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <androidx.cardview.widget.CardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:layout_marginEnd="8dp"
+ android:layout_marginStart="8dp"
+ android:layout_marginTop="8dp"
+ android:background="?attr/colorBackground"
+ app:cardCornerRadius="4dp"
+ app:cardElevation="2dp"
+ app:contentPadding="8dp">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ android:id="@+id/interface_title"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_alignParentTop="true"
+ android:text="@string/interface_title" />
+
+ <com.wireguard.android.widget.ToggleSwitch
+ android:id="@+id/tunnel_switch"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/interface_title"
+ android:layout_alignParentEnd="true"
+ app:checked="@{tunnel.state == State.UP}"
+ app:onBeforeCheckedChanged="@{fragment::setTunnelState}" />
+
+ <TextView
+ android:id="@+id/interface_name_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_title"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/interface_name_text"
+ android:text="@string/name" />
+
+ <TextView
+ android:id="@+id/interface_name_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_name_label"
+ android:text="@{tunnel.name}" />
+
+ <TextView
+ android:id="@+id/public_key_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/interface_name_text"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/public_key_text"
+ android:text="@string/public_key" />
+
+ <TextView
+ android:id="@+id/public_key_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/public_key_label"
+ android:contentDescription="@string/public_key_description"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:onClick="@{ClipboardUtils::copyTextView}"
+ android:text="@{config.interface.keyPair.publicKey.toBase64}" />
+
+ <TextView
+ android:id="@+id/addresses_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/public_key_text"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/addresses_text"
+ android:text="@string/addresses" />
+
+ <TextView
+ android:id="@+id/addresses_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/addresses_label"
+ android:contentDescription="@string/addresses"
+ android:text="@{config.interface.addresses}" />
+ </RelativeLayout>
+ </androidx.cardview.widget.CardView>
+
+ <LinearLayout
+ android:id="@+id/peers_layout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:divider="@null"
+ android:orientation="vertical"
+ app:items="@{config.peers}"
+ app:layout="@{@layout/tunnel_detail_peer}"
+ tools:ignore="UselessLeaf" />
+ </LinearLayout>
+ </ScrollView>
+</layout>
diff --git a/ui/src/main/res/layout/tunnel_detail_peer.xml b/ui/src/main/res/layout/tunnel_detail_peer.xml
new file mode 100644
index 00000000..181a4a21
--- /dev/null
+++ b/ui/src/main/res/layout/tunnel_detail_peer.xml
@@ -0,0 +1,112 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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.util.ClipboardUtils" />
+
+ <variable
+ name="item"
+ type="com.wireguard.config.Peer" />
+ </data>
+
+ <androidx.cardview.widget.CardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:layout_marginEnd="8dp"
+ android:layout_marginStart="8dp"
+ android:layout_marginTop="4dp"
+ android:background="?attr/colorBackground"
+ app:cardCornerRadius="4dp"
+ app:cardElevation="2dp"
+ app:contentPadding="8dp">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ android:id="@+id/peer_title"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:text="@string/peer" />
+
+ <TextView
+ android:id="@+id/public_key_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/peer_title"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/public_key_text"
+ android:text="@string/public_key" />
+
+ <TextView
+ android:id="@+id/public_key_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/public_key_label"
+ android:contentDescription="@string/public_key_description"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:onClick="@{ClipboardUtils::copyTextView}"
+ android:text="@{item.publicKey.toBase64}" />
+
+ <TextView
+ android:id="@+id/allowed_ips_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/public_key_text"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/allowed_ips_text"
+ android:text="@string/allowed_ips" />
+
+ <TextView
+ android:id="@+id/allowed_ips_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/allowed_ips_label"
+ android:text="@{item.allowedIps}" />
+
+ <TextView
+ android:id="@+id/endpoint_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/allowed_ips_text"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/endpoint_text"
+ android:text="@string/endpoint" />
+
+ <TextView
+ android:id="@+id/endpoint_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/endpoint_label"
+ android:text="@{item.endpoint}" />
+
+ <TextView
+ android:id="@+id/transfer_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/endpoint_text"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/transfer_text"
+ android:text="@string/transfer"
+ android:visibility="gone" />
+
+ <TextView
+ android:id="@+id/transfer_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/transfer_label"
+ android:visibility="gone" />
+ </RelativeLayout>
+ </androidx.cardview.widget.CardView>
+</layout>
diff --git a/ui/src/main/res/layout/tunnel_editor_fragment.xml b/ui/src/main/res/layout/tunnel_editor_fragment.xml
new file mode 100644
index 00000000..887b3bb7
--- /dev/null
+++ b/ui/src/main/res/layout/tunnel_editor_fragment.xml
@@ -0,0 +1,254 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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="match_parent"
+ android:background="?attr/colorBackground">
+
+ <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">
+
+ <androidx.cardview.widget.CardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:layout_marginEnd="8dp"
+ android:layout_marginStart="8dp"
+ android:layout_marginTop="8dp"
+ android:background="?attr/colorBackground"
+ app:cardCornerRadius="4dp"
+ app:cardElevation="2dp"
+ app:contentPadding="8dp">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ android:id="@+id/interface_title"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:text="@string/interface_title" />
+
+ <TextView
+ android:id="@+id/interface_name_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_title"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/interface_name_text"
+ android:text="@string/name" />
+
+ <EditText
+ android:id="@+id/interface_name_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_name_label"
+ android:inputType="textNoSuggestions|textVisiblePassword"
+ android:text="@={name}"
+ app:filter="@{NameInputFilter.newInstance()}" />
+
+ <TextView
+ android:id="@+id/private_key_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_name_text"
+ android:labelFor="@+id/private_key_text"
+ android:text="@string/private_key" />
+
+ <EditText
+ android:id="@+id/private_key_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/private_key_label"
+ android:layout_toStartOf="@+id/generate_private_key_button"
+ android:contentDescription="@string/public_key_description"
+ android:inputType="textNoSuggestions|textVisiblePassword"
+ android:text="@={config.interface.privateKey}"
+ app:filter="@{KeyInputFilter.newInstance()}" />
+
+ <Button
+ android:id="@+id/generate_private_key_button"
+ style="@style/Widget.AppCompat.Button.Borderless.Colored"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBottom="@id/private_key_text"
+ android:layout_alignParentEnd="true"
+ android:layout_below="@+id/private_key_label"
+ android:onClick="@{() -> config.interface.generateKeyPair()}"
+ android:text="@string/generate" />
+
+ <TextView
+ android:id="@+id/public_key_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/private_key_text"
+ android:labelFor="@+id/public_key_text"
+ android:text="@string/public_key" />
+
+ <TextView
+ android:id="@+id/public_key_text"
+ style="?attr/editTextStyle"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/public_key_label"
+ android:contentDescription="@string/public_key_description"
+ android:ellipsize="end"
+ android:focusable="false"
+ android:hint="@string/hint_generated"
+ android:maxLines="1"
+ android:onClick="@{ClipboardUtils::copyTextView}"
+ android:text="@{config.interface.publicKey}" />
+
+ <TextView
+ android:id="@+id/addresses_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/public_key_text"
+ android:layout_toStartOf="@+id/listen_port_label"
+ android:labelFor="@+id/addresses_text"
+ android:text="@string/addresses" />
+
+ <EditText
+ android:id="@+id/addresses_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/addresses_label"
+ android:layout_toStartOf="@+id/listen_port_text"
+ android:inputType="textNoSuggestions|textVisiblePassword"
+ android:text="@={config.interface.addresses}" />
+
+ <TextView
+ android:id="@+id/listen_port_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/addresses_label"
+ android:layout_alignParentEnd="true"
+ android:layout_alignStart="@+id/generate_private_key_button"
+ android:labelFor="@+id/listen_port_text"
+ android:text="@string/listen_port" />
+
+ <EditText
+ android:id="@+id/listen_port_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/addresses_text"
+ android:layout_alignParentEnd="true"
+ android:layout_alignStart="@+id/generate_private_key_button"
+ android:hint="@string/hint_random"
+ android:inputType="number"
+ android:text="@={config.interface.listenPort}"
+ android:textAlignment="center" />
+
+ <TextView
+ android:id="@+id/dns_servers_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/addresses_text"
+ android:layout_toStartOf="@+id/mtu_label"
+ android:labelFor="@+id/dns_servers_text"
+ android:text="@string/dns_servers" />
+
+ <EditText
+ android:id="@+id/dns_servers_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/dns_servers_label"
+ android:layout_toStartOf="@+id/mtu_text"
+ android:inputType="textNoSuggestions|textVisiblePassword"
+ android:text="@={config.interface.dnsServers}" />
+
+ <TextView
+ android:id="@+id/mtu_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/dns_servers_label"
+ android:layout_alignParentEnd="true"
+ android:layout_alignStart="@+id/generate_private_key_button"
+ android:labelFor="@+id/mtu_text"
+ android:text="@string/mtu" />
+
+ <EditText
+ android:id="@+id/mtu_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/dns_servers_text"
+ android:layout_alignParentEnd="true"
+ android:layout_alignStart="@+id/generate_private_key_button"
+ android:hint="@string/hint_automatic"
+ android:inputType="number"
+ android:text="@={config.interface.mtu}"
+ android:textAlignment="center" />
+
+ <Button
+ android:id="@+id/set_excluded_applications"
+ style="@style/Widget.AppCompat.Button.Borderless.Colored"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/dns_servers_text"
+ android:layout_marginLeft="-8dp"
+ android:onClick="@{fragment::onRequestSetExcludedApplications}"
+ android:text="@{@plurals/set_excluded_applications(config.interface.excludedApplications.size, config.interface.excludedApplications.size)}" />
+ </RelativeLayout>
+ </androidx.cardview.widget.CardView>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:divider="@null"
+ android:orientation="vertical"
+ app:items="@{config.peers}"
+ app:layout="@{@layout/tunnel_editor_peer}"
+ tools:ignore="UselessLeaf" />
+
+ <Button
+ style="@style/Widget.AppCompat.Button.Colored"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:layout_marginEnd="4dp"
+ android:layout_marginStart="4dp"
+ android:onClick="@{() -> config.addPeer()}"
+ android:text="@string/add_peer" />
+ </LinearLayout>
+ </ScrollView>
+ </androidx.coordinatorlayout.widget.CoordinatorLayout>
+</layout>
diff --git a/ui/src/main/res/layout/tunnel_editor_peer.xml b/ui/src/main/res/layout/tunnel_editor_peer.xml
new file mode 100644
index 00000000..cf5d6286
--- /dev/null
+++ b/ui/src/main/res/layout/tunnel_editor_peer.xml
@@ -0,0 +1,161 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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" />
+
+ <variable
+ name="collection"
+ type="androidx.databinding.ObservableList&lt;com.wireguard.android.viewmodel.PeerProxy&gt;" />
+
+ <variable
+ name="item"
+ type="com.wireguard.android.viewmodel.PeerProxy" />
+ </data>
+
+ <androidx.cardview.widget.CardView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:layout_marginEnd="8dp"
+ android:layout_marginStart="8dp"
+ android:layout_marginTop="4dp"
+ android:background="?attr/colorBackground"
+ app:cardCornerRadius="4dp"
+ app:cardElevation="2dp"
+ app:contentPadding="8dp">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ android:id="@+id/peer_title"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_alignParentTop="true"
+ android:layout_marginBottom="8dp"
+ android:layout_toStartOf="@+id/peer_action_delete"
+ android:text="@string/peer" />
+
+ <ImageButton
+ android:id="@+id/peer_action_delete"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentEnd="true"
+ android:layout_alignParentTop="true"
+ android:background="@null"
+ android:contentDescription="@string/delete"
+ android:onClick="@{() -> item.unbind()}"
+ android:src="@drawable/ic_action_delete" />
+
+ <TextView
+ android:id="@+id/public_key_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/peer_title"
+ android:labelFor="@+id/public_key_text"
+ android:text="@string/public_key" />
+
+ <EditText
+ android:id="@+id/public_key_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/public_key_label"
+ android:inputType="textNoSuggestions|textVisiblePassword"
+ android:text="@={item.publicKey}"
+ app:filter="@{KeyInputFilter.newInstance()}" />
+
+ <TextView
+ android:id="@+id/pre_shared_key_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/public_key_text"
+ android:labelFor="@+id/pre_shared_key_text"
+ android:text="@string/pre_shared_key" />
+
+ <EditText
+ android:id="@+id/pre_shared_key_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/pre_shared_key_label"
+ android:hint="@string/hint_optional"
+ android:inputType="textNoSuggestions|textVisiblePassword"
+ android:text="@={item.preSharedKey}" />
+
+ <TextView
+ android:id="@+id/allowed_ips_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/pre_shared_key_text"
+ android:layout_toStartOf="@+id/exclude_private_ips"
+ android:labelFor="@+id/allowed_ips_text"
+ android:text="@string/allowed_ips" />
+
+ <CheckBox
+ android:id="@+id/exclude_private_ips"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/allowed_ips_label"
+ android:layout_alignParentEnd="true"
+ android:checked="@={item.excludingPrivateIps}"
+ android:text="@string/exclude_private_ips"
+ android:visibility="@{item.ableToExcludePrivateIps ? View.VISIBLE : View.GONE}" />
+
+ <EditText
+ android:id="@+id/allowed_ips_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/allowed_ips_label"
+ android:inputType="textNoSuggestions|textVisiblePassword"
+ android:text="@={item.allowedIps}" />
+
+ <TextView
+ android:id="@+id/endpoint_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/allowed_ips_text"
+ android:layout_toStartOf="@+id/persistent_keepalive_label"
+ android:labelFor="@+id/endpoint_text"
+ android:text="@string/endpoint" />
+
+ <EditText
+ android:id="@+id/endpoint_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/endpoint_label"
+ android:layout_toStartOf="@+id/persistent_keepalive_text"
+ android:inputType="textNoSuggestions|textVisiblePassword"
+ android:text="@={item.endpoint}" />
+
+ <TextView
+ android:id="@+id/persistent_keepalive_label"
+ android:layout_width="96dp"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/endpoint_label"
+ android:layout_alignParentEnd="true"
+ android:labelFor="@+id/persistent_keepalive_text"
+ android:text="@string/persistent_keepalive" />
+
+ <EditText
+ android:id="@+id/persistent_keepalive_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/endpoint_text"
+ android:layout_alignParentEnd="true"
+ android:layout_alignStart="@+id/persistent_keepalive_label"
+ android:hint="@string/hint_optional"
+ android:inputType="number"
+ android:text="@={item.persistentKeepalive}"
+ android:textAlignment="center" />
+ </RelativeLayout>
+ </androidx.cardview.widget.CardView>
+</layout>
diff --git a/ui/src/main/res/layout/tunnel_list_fragment.xml b/ui/src/main/res/layout/tunnel_list_fragment.xml
new file mode 100644
index 00000000..c8144dbb
--- /dev/null
+++ b/ui/src/main/res/layout/tunnel_list_fragment.xml
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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.util.ObservableKeyedList&lt;String, ObservableTunnel&gt;" />
+ </data>
+
+ <androidx.coordinatorlayout.widget.CoordinatorLayout
+ android:id="@+id/main_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorBackground"
+ android:clipChildren="false">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/tunnel_list"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:choiceMode="multipleChoiceModal"
+ android:clipToPadding="false"
+ 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:listitem="@layout/tunnel_list_item"
+ tools:itemCount="12" />
+
+ <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:text="@string/tunnel_list_placeholder"
+ android:textSize="20sp" />
+ </LinearLayout>
+ <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+ style="@style/Widget.MaterialComponents.ExtendedFloatingActionButton.Icon"
+ 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"
+ app:icon="@drawable/ic_action_add_white" />
+
+ </androidx.coordinatorlayout.widget.CoordinatorLayout>
+</layout>
diff --git a/ui/src/main/res/layout/tunnel_list_item.xml b/ui/src/main/res/layout/tunnel_list_item.xml
new file mode 100644
index 00000000..04c0f51e
--- /dev/null
+++ b/ui/src/main/res/layout/tunnel_list_item.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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.util.ObservableKeyedList&lt;String, ObservableTunnel&gt;" />
+
+ <variable
+ name="key"
+ type="String" />
+
+ <variable
+ name="item"
+ type="com.wireguard.android.model.ObservableTunnel" />
+
+ <variable
+ name="fragment"
+ type="com.wireguard.android.fragment.TunnelListFragment" />
+ </data>
+
+ <com.wireguard.android.widget.MultiselectableRelativeLayout
+ android:id="@+id/tunnel_list_item"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="@drawable/list_item_background"
+ android:descendantFocusability="beforeDescendants"
+ android:focusable="true"
+ android:nextFocusRight="@+id/tunnel_switch"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/tunnel_name"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_alignParentTop="true"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:text="@{key}"
+ 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_alignBaseline="@+id/tunnel_name"
+ android:layout_alignParentEnd="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>
diff --git a/ui/src/main/res/menu/config_editor.xml b/ui/src/main/res/menu/config_editor.xml
new file mode 100644
index 00000000..dd0137df
--- /dev/null
+++ b/ui/src/main/res/menu/config_editor.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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>
diff --git a/ui/src/main/res/menu/main_activity.xml b/ui/src/main/res/menu/main_activity.xml
new file mode 100644
index 00000000..68bce52e
--- /dev/null
+++ b/ui/src/main/res/menu/main_activity.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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>
diff --git a/ui/src/main/res/menu/tunnel_detail.xml b/ui/src/main/res/menu/tunnel_detail.xml
new file mode 100644
index 00000000..2834a661
--- /dev/null
+++ b/ui/src/main/res/menu/tunnel_detail.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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_edit"
+ android:alphabeticShortcut="e"
+ android:icon="@drawable/ic_action_edit"
+ android:title="@string/edit"
+ app:showAsAction="always" />
+</menu>
diff --git a/ui/src/main/res/menu/tunnel_list_action_mode.xml b/ui/src/main/res/menu/tunnel_list_action_mode.xml
new file mode 100644
index 00000000..22f61943
--- /dev/null
+++ b/ui/src/main/res/menu/tunnel_list_action_mode.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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_select_all"
+ android:alphabeticShortcut="s"
+ android:icon="@drawable/ic_action_select_all"
+ android:title="@string/select_all"
+ app:showAsAction="always" />
+ <item
+ android:id="@+id/menu_action_delete"
+ android:alphabeticShortcut="d"
+ android:icon="@drawable/ic_action_delete"
+ android:title="@string/delete"
+ app:showAsAction="always" />
+</menu>
diff --git a/ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..a8a8fa55
--- /dev/null
+++ b/ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_launcher_background" />
+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>
diff --git a/ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..a8a8fa55
--- /dev/null
+++ b/ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_launcher_background" />
+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>
diff --git a/ui/src/main/res/mipmap-hdpi/ic_launcher.png b/ui/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..8a5b8d69
--- /dev/null
+++ b/ui/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/ui/src/main/res/mipmap-hdpi/ic_launcher_round.png b/ui/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..56111942
--- /dev/null
+++ b/ui/src/main/res/mipmap-hdpi/ic_launcher_round.png
Binary files differ
diff --git a/ui/src/main/res/mipmap-mdpi/ic_launcher.png b/ui/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..aa5ac825
--- /dev/null
+++ b/ui/src/main/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/ui/src/main/res/mipmap-mdpi/ic_launcher_round.png b/ui/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..ebad3192
--- /dev/null
+++ b/ui/src/main/res/mipmap-mdpi/ic_launcher_round.png
Binary files differ
diff --git a/ui/src/main/res/mipmap-xhdpi/ic_launcher.png b/ui/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..ef183c3d
--- /dev/null
+++ b/ui/src/main/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/ui/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/ui/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..79f3fa98
--- /dev/null
+++ b/ui/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Binary files differ
diff --git a/ui/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ui/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..95e7241c
--- /dev/null
+++ b/ui/src/main/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/ui/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/ui/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..d1731b3b
--- /dev/null
+++ b/ui/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/ui/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ui/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..d3ec336b
--- /dev/null
+++ b/ui/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/ui/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/ui/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..c0ae03bc
--- /dev/null
+++ b/ui/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/ui/src/main/res/values-hi/strings.xml b/ui/src/main/res/values-hi/strings.xml
new file mode 100644
index 00000000..a9f01861
--- /dev/null
+++ b/ui/src/main/res/values-hi/strings.xml
@@ -0,0 +1,179 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <plurals name="delete_error">
+ <item quantity="one">%d टनल हटाने में असमर्थ: %s</item>
+ <item quantity="other">%d टनलस को हटाने में असमर्थ: %s</item>
+ </plurals>
+ <plurals name="delete_success">
+ <item quantity="one">%d टनल को सफलतापूर्वक हटा दिया गया</item>
+ <item quantity="other">%d टनलस को सफलतापूर्वक हटा दिया गया</item>
+ </plurals>
+ <plurals name="delete_title">
+ <item quantity="one">%d टनल चयनित</item>
+ <item quantity="other">%d टनलस का चयन किया गया</item>
+ </plurals>
+ <plurals name="import_partial_success">
+ <item quantity="one">आयातित %d %d टनल</item>
+ <item quantity="other">आयातित %d %d टनलस</item>
+ </plurals>
+ <plurals name="import_total_success">
+ <item quantity="one">आयातित %d टनल</item>
+ <item quantity="other">आयातित %d टनलस</item>
+ </plurals>
+ <plurals name="set_excluded_applications">
+ <item quantity="one">%d बहिष्कृत अनुप्रयोग</item>
+ <item quantity="other">%d बहिष्कृत अनुप्रयोग</item>
+ </plurals>
+ <string name="add_peer">पीयर जोड़ें</string>
+ <string name="addresses">एड्रेससैस</string>
+ <string name="allowed_ips">अनुमत आईपी</string>
+ <string name="app_name">WireGuard</string>
+ <string name="bad_config_context">%1$s\'s %2$s</string>
+ <string name="bad_config_context_top_level">%s</string>
+ <string name="bad_config_error">%1$s in %2$s</string>
+ <string name="bad_config_explanation_pka">: सकारात्मक होना चाहिए और 65535 से अधिक नहीं होना चाहिए</string>
+ <string name="bad_config_explanation_positive_number">: सकारात्मक होना चाहिए</string>
+ <string name="bad_config_explanation_udp_port">: एक वैध यूडीपी पोर्ट नंबर होना चाहिए</string>
+ <string name="bad_config_reason_invalid_key">अमान्य चाबी</string>
+ <string name="bad_config_reason_invalid_number">अमान्य संख्या</string>
+ <string name="bad_config_reason_invalid_value">अमान्य मूल्य</string>
+ <string name="bad_config_reason_missing_attribute">गुम विशेषता</string>
+ <string name="bad_config_reason_missing_section">छूटा हुआ भाग</string>
+ <string name="bad_config_reason_missing_value">अनुपस्थित मान</string>
+ <string name="bad_config_reason_syntax_error">वक्य रचना त्रुटि</string>
+ <string name="bad_config_reason_unknown_attribute">अज्ञात एट्रिब्यूट</string>
+ <string name="bad_config_reason_unknown_section">अज्ञात एट्रिब्यूट </string>
+ <string name="bad_config_reason_value_out_of_range">मूल्य सीमा से बाहर</string>
+ <string name="bad_extension_error">फ़ाइल .conf या .zip होनी चाहिए</string>
+ <string name="cancel">रद्द</string>
+ <string name="config_delete_error">कॉन्फ़िगरेशन फ़ाइल %s को नहीं हटा सकता</string>
+ <string name="config_exists_error">“%s” के लिए कॉन्फ़िगरेशन पहले से मौजूद है</string>
+ <string name="config_file_exists_error">कॉन्फ़िगरेशन फ़ाइल “%s” पहले से मौजूद है</string>
+ <string name="config_not_found_error">कॉन्फ़िगरेशन फ़ाइल “%s” नहीं मिली</string>
+ <string name="config_rename_error">कॉन्फ़िगरेशन फ़ाइल “%s” का नाम नहीं बदल सकता</string>
+ <string name="config_save_error">“%1$s” के लिए कॉन्फ़िगरेशन को नहीं बचा सकता: %2$s</string>
+ <string name="config_save_success">“%s” के लिए सफलतापूर्वक सहेजा गया कॉन्फ़िगरेशन</string>
+ <string name="create_activity_title">वायरगार्ड टनल बनाएं</string>
+ <string name="create_bin_dir_error">स्थानीय बाइनरी निर्देशिका नहीं बना सकते</string>
+ <string name="create_empty">स्क्रैच से बनाएँ</string>
+ <string name="create_from_file">फ़ाइल या संग्रह से बनाएँ</string>
+ <string name="create_from_qr_code">क्यूआर कोड से बनाएं</string>
+ <string name="create_output_dir_error">आउटपुट निर्देशिका नहीं बना सकता</string>
+ <string name="create_downloads_file_error">डाउनलोड निर्देशिका में फ़ाइल नहीं बना सकते</string>
+ <string name="create_temp_dir_error">स्थानीय अस्थायी निर्देशिका नहीं बना सकते</string>
+ <string name="create_tunnel">टनल बनाए</string>
+ <string name="dark_theme_summary_off">अभी प्रकाश (दिन) थीम का उपयोग कर रहे हैं</string>
+ <string name="dark_theme_summary_on">अभी डार्क (रात) थीम का उपयोग कर रहे हैं</string>
+ <string name="dark_theme_title">डार्क थीम का इस्तेमाल करें</string>
+ <string name="delete">हटाएं</string>
+ <string name="toggle_all">सबको स्विच करो</string>
+ <string name="dns_servers">DNS सर्वर</string>
+ <string name="edit">संपादित करें</string>
+ <string name="endpoint">अंतिम</string>
+ <string name="error_down">टनल को लाने में त्रुटि: %s</string>
+ <string name="error_fetching_apps">ऐप्स सूची लाने में त्रुटि: %s</string>
+ <string name="error_root">कृपया रूट एक्सेस प्राप्त करें और पुनः प्रयास करें</string>
+ <string name="error_up">टनल को लाने में त्रुटि: %s</string>
+ <string name="exclude_private_ips">निजी आईपी को छोड़ दें</string>
+ <string name="excluded_applications">निकाले गए ऐप्स</string>
+ <string name="generate">उत्पन्न</string>
+ <string name="generic_error">अज्ञात “%s” त्रुटि</string>
+ <string name="hint_automatic">(ऑटो)</string>
+ <string name="hint_generated">(उत्पन्न)</string>
+ <string name="hint_optional">(ऐच्छिक)</string>
+ <string name="hint_random">(क्रमरहित)</string>
+ <string name="illegal_filename_error">अवैध फ़ाइल नाम “%s”</string>
+ <string name="import_error">टनल को आयात करने में असमर्थ: %s</string>
+ <string name="import_from_qr_code">क्यूआर कोड से टनल को आयात करें</string>
+ <string name="import_success">आयातित “%s”</string>
+ <string name="interface_title">इंटरफेस</string>
+ <string name="key_length_explanation_base64">: वायरगार्ड बेस 64 कीज़ में 44 अक्षर (32 बाइट्स) होने चाहिए</string>
+ <string name="key_length_explanation_binary">: वायरगार्ड कीज 32 बाइट होनी चाहिए</string>
+ <string name="key_length_explanation_hex">: वायरगार्ड हेक्स कीज़ 64 अक्षरों की होनी चाहिए (32 बाइट्स)</string>
+ <string name="listen_port">पोर्ट सुनो</string>
+ <string name="log_export_error">लॉग निर्यात करने में असमर्थ: %s</string>
+ <string name="log_export_success">“%s” में सहेजा गया</string>
+ <string name="log_export_summary">लॉग फ़ाइल को डाउनलोड फ़ोल्डर में सहेजा जाएगा</string>
+ <string name="log_export_title">लॉग फ़ाइल निर्यात करें</string>
+ <string name="logcat_error">लॉगकैट चलाने में असमर्थ: </string>
+ <string name="module_version_error">कर्नेल मॉड्यूल संस्करण निर्धारित करने में असमर्थ</string>
+ <string name="module_installer_not_found">आपके डिवाइस के लिए कोई मॉड्यूल उपलब्ध नहीं हैं</string>
+ <string name="module_installer_initial">प्रयोगात्मक कर्नेल मॉड्यूल प्रदर्शन में सुधार कर सकता है</string>
+ <string name="module_installer_success">सफलता। ऐप 5 सेकंड में रीस्टार्ट होगा</string>
+ <string name="module_installer_title">कर्नेल मॉड्यूल डाउनलोड और इंस्टॉल करें</string>
+ <string name="module_installer_working">डाउनलोड कर रहा है और स्थापित कर रहा है…</string>
+ <string name="module_installer_error">कुछ गलत हो गया। कृपया पुन: प्रयास करें</string>
+ <string name="mtu">MTU</string>
+ <string name="name">नाम</string>
+ <string name="no_config_error">बिना किसी कॉन्फ़िगरेशन के एक टनल को लाने की कोशिश करना</string>
+ <string name="no_configs_error">कोई कॉन्फ़िगरेशन नहीं मिला</string>
+ <string name="no_tunnels_error">कोई टनल मौजूद नहीं है</string>
+ <string name="parse_error_generic">पाठ</string>
+ <string name="parse_error_inet_address">आईपी पता</string>
+ <string name="parse_error_inet_endpoint">समाप्त</string>
+ <string name="parse_error_inet_network">आईपी नेटवर्क</string>
+ <string name="parse_error_integer">संख्या</string>
+ <string name="parse_error_reason">%1$s “%2$s” को पार्स नहीं कर सकता</string>
+ <string name="peer">पीयर</string>
+ <string name="permission_description">किसी एप्लिकेशन को वायरगार्ड सुरंगों को नियंत्रित करने की अनुमति देता है। इस अनुमति वाले ऐप्स, इंटरनेट ट्रैफ़िक को संभावित रूप से गलत तरीके से वायरगार्ड सुरंगों को सक्षम और अक्षम कर सकते हैं।</string>
+ <string name="permission_label">वायरगार्ड सुरंगों को नियंत्रित करें</string>
+ <string name="persistent_keepalive">लगातार जिंदा रहो</string>
+ <string name="pre_shared_key">प्री-शेयर्ड कीस</string>
+ <string name="private_key">निजी कीस</string>
+ <string name="public_key">सार्वजनिक कीस</string>
+ <string name="public_key_description">सार्वजनिक कीस</string>
+ <string name="qr_code_hint">टिप: `qrencode -t ansiutf8 &lt; tunnel.conf` के साथ उत्पन्न करो</string>
+ <string name="restore_on_boot_summary">बूट पर पहले से सक्षम टनल को ऊपर लाएं</string>
+ <string name="restore_on_boot_title">बूट पर पुनर्स्थापित करें</string>
+ <string name="save">सहेजें</string>
+ <string name="select_all">सभी का चयन करे</string>
+ <string name="set_exclusions">बहिष्करण सेट करें</string>
+ <string name="settings">सेटिंग्स</string>
+ <string name="shell_exit_status_read_error">शेल बाहर निकलने की स्थिति नहीं पढ़ सकता</string>
+ <string name="shell_marker_count_error">शेल ने 4 मार्करों की अपेक्षा की, %d प्राप्त किया</string>
+ <string name="shell_start_error">शेल शुरू करने में विफल: %d</string>
+ <string name="toggle_error">वायरगार्ड टनल टॉगल करने में त्रुटि: %s</string>
+ <string name="tools_installer_already">wg और wg-quick पहले से इंस्टॉल हैं</string>
+ <string name="tools_installer_failure">कमांड-लाइन टूल स्थापित करने में असमर्थ (कोई रूट नहीं)</string>
+ <string name="tools_installer_initial">स्क्रिप्टिंग के लिए वैकल्पिक उपकरण स्थापित करें</string>
+ <string name="tools_installer_initial_magisk">Magisk मॉड्यूल के रूप में स्क्रिप्टिंग के लिए वैकल्पिक उपकरण स्थापित करें</string>
+ <string name="tools_installer_initial_system">सिस्टम विभाजन में स्क्रिप्टिंग के लिए वैकल्पिक उपकरण स्थापित करें</string>
+ <string name="tools_installer_success_magisk">wg और wg-quick को मैजिक मॉड्यूल के रूप में स्थापित किया गया है (रिबूट आवश्यक)</string>
+ <string name="tools_installer_success_system">wg और wg-quick सिस्टम विभाजन में स्थापित है</string>
+ <string name="tools_installer_title">कमांड लाइन उपकरण स्थापित करें</string>
+ <string name="tools_installer_working">Wg और wg-quick इंस्टॉल करना</string>
+ <string name="tools_unavailable_error">आवश्यक उपकरण अनुपलब्ध हैं</string>
+ <string name="transfer">स्थानांतरण</string>
+ <string name="transfer_rx_tx">rx: %1$s, tx: %2$s</string>
+ <string name="transfer_bytes">%d B</string>
+ <string name="transfer_kibibytes">%.2f KiB</string>
+ <string name="transfer_mibibytes">%.2f MiB</string>
+ <string name="transfer_gibibytes">%.2f GiB</string>
+ <string name="transfer_tibibytes">%.2f TiB</string>
+ <string name="tun_create_error">ट्यून डिवाइस बनाने में असमर्थ</string>
+ <string name="tunnel_config_error">टनल को कॉन्फ़िगर करने में असमर्थ (wg-quick लौटा %d)</string>
+ <string name="tunnel_create_error">टनल बनाने में असमर्थ: %s</string>
+ <string name="tunnel_create_success">सफलतापूर्वक बनाया गया टनल “%s”</string>
+ <string name="tunnel_error_already_exists">टनल “%s” पहले से मौजूद है</string>
+ <string name="tunnel_error_invalid_name">गलत नाम</string>
+ <string name="tunnel_list_placeholder">नीले बटन का उपयोग करके एक टनल को जोड़ें</string>
+ <string name="tunnel_name">टनल का नाम</string>
+ <string name="tunnel_on_error">टनल चालू करने में असमर्थ (wgTurnOn लौटा %d)</string>
+ <string name="tunnel_rename_error">टनल का नाम बदलने में असमर्थ: %s</string>
+ <string name="tunnel_rename_success">टनल का नाम बदलकर “%s” करने के लिए</string>
+ <string name="type_name_go_userspace">userspace पे जाए </string>
+ <string name="type_name_kernel_module">कर्नेल मॉड्यूल</string>
+ <string name="unknown_error">अज्ञात त्रुटि</string>
+ <string name="version_summary">%1$s बैकएंड v%2$s</string>
+ <string name="version_summary_checking">%s बैकएंड संस्करण की जाँच कर रहा है</string>
+ <string name="version_summary_unknown">अज्ञात %s संस्करण</string>
+ <string name="version_title">WireGuard for Android v%s</string>
+ <string name="vpn_not_authorized_error">वीपीएन सेवा उपयोगकर्ता द्वारा अधिकृत नहीं है</string>
+ <string name="vpn_start_error">Android VPN सेवा प्रारंभ करने में असमर्थ</string>
+ <string name="zip_export_error">टनल का निर्यात करने में असमर्थ: %s</string>
+ <string name="zip_export_success">“%s” पर सहेजा गया</string>
+ <string name="zip_export_summary">ज़िप फ़ाइल को डाउनलोड फ़ोल्डर में सहेजा जाएगा</string>
+ <string name="zip_export_title">जिप फाइल के लिए टनल को एक्सपोर्ट करें</string>
+ <string name="key_length_error">चाबी की लम्बाई गलत </string>
+ <string name="key_contents_error">चाबी में खराब वर्ण</string>
+</resources>
diff --git a/ui/src/main/res/values-it/strings.xml b/ui/src/main/res/values-it/strings.xml
new file mode 100644
index 00000000..b76c39cd
--- /dev/null
+++ b/ui/src/main/res/values-it/strings.xml
@@ -0,0 +1,179 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <plurals name="delete_error">
+ <item quantity="one">Impossibile eliminare %d tunnel: %s</item>
+ <item quantity="other">Impossibile eliminare %d tunnel: %s</item>
+ </plurals>
+ <plurals name="delete_success">
+ <item quantity="one">%d tunnel eliminato correttamente</item>
+ <item quantity="other">%d tunnel eliminati correttamente</item>
+ </plurals>
+ <plurals name="delete_title">
+ <item quantity="one">%d tunnel selezionato</item>
+ <item quantity="other">%d tunnel selezionati</item>
+ </plurals>
+ <plurals name="import_partial_success">
+ <item quantity="one">Importato %d di %d tunnel</item>
+ <item quantity="other">Importati %d di %d tunnel</item>
+ </plurals>
+ <plurals name="import_total_success">
+ <item quantity="one">Importato %d tunnel</item>
+ <item quantity="other">Importati %d tunnel</item>
+ </plurals>
+ <plurals name="set_excluded_applications">
+ <item quantity="one">%d applicazione esclusa</item>
+ <item quantity="other">%d applicazioni escluse</item>
+ </plurals>
+ <string name="add_peer">Aggiungi peer</string>
+ <string name="addresses">Indirizzi</string>
+ <string name="allowed_ips">IP consentiti</string>
+ <string name="app_name">WireGuard</string>
+ <string name="bad_config_context">%2$s di %1$s</string>
+ <string name="bad_config_context_top_level">%s</string>
+ <string name="bad_config_error">%1$s in %2$s</string>
+ <string name="bad_config_explanation_pka">: deve essere positivo e non maggiore di 65535</string>
+ <string name="bad_config_explanation_positive_number">: deve essere positivo</string>
+ <string name="bad_config_explanation_udp_port">: deve essere un numero di porta UDP valido</string>
+ <string name="bad_config_reason_invalid_key">Chiave non valida</string>
+ <string name="bad_config_reason_invalid_number">Numero non valido</string>
+ <string name="bad_config_reason_invalid_value">Valore non valido</string>
+ <string name="bad_config_reason_missing_attribute">Attributo mancante</string>
+ <string name="bad_config_reason_missing_section">Sezione mancante</string>
+ <string name="bad_config_reason_missing_value">Valore mancante</string>
+ <string name="bad_config_reason_syntax_error">Errore di sintassi</string>
+ <string name="bad_config_reason_unknown_attribute">Attributo sconosciuto</string>
+ <string name="bad_config_reason_unknown_section">Sezione sconosciuta</string>
+ <string name="bad_config_reason_value_out_of_range">Valore fuori scala</string>
+ <string name="bad_extension_error">Il file deve essere .conf o .zip</string>
+ <string name="cancel">Annulla</string>
+ <string name="config_delete_error">Impossibile eliminare il file di configurazione %s</string>
+ <string name="config_exists_error">La configurazione per “%s” esiste già</string>
+ <string name="config_file_exists_error">Il file di configurazione “%s” esiste già</string>
+ <string name="config_not_found_error">File di configurazione “%s” non trovato</string>
+ <string name="config_rename_error">Impossibile rinominare il file di configurazione “%s”</string>
+ <string name="config_save_error">Impossibile salvare la configurazione per “%1$s”: %2$s</string>
+ <string name="config_save_success">Configurazione per “%s” salvata correttamente</string>
+ <string name="create_activity_title">Crea un tunnel WireGuard</string>
+ <string name="create_bin_dir_error">Impossibile creare cartella locale binari</string>
+ <string name="create_empty">Crea da zero</string>
+ <string name="create_from_file">Crea da un file o archivio</string>
+ <string name="create_from_qr_code">Crea da un codice QR</string>
+ <string name="create_output_dir_error">Impossibile creare la cartella di output</string>
+ <string name="create_downloads_file_error">Impossibile creare il file nella cartella di download</string>
+ <string name="create_temp_dir_error">Impossibile creare la cartella locale temporanea</string>
+ <string name="create_tunnel">Crea tunnel</string>
+ <string name="dark_theme_summary_off">Stai usando il tema chiaro (giorno)</string>
+ <string name="dark_theme_summary_on">Stai usando il tema scuro (notte)</string>
+ <string name="dark_theme_title">Usa tema scuro</string>
+ <string name="delete">Elimina</string>
+ <string name="toggle_all">Inverti tutto</string>
+ <string name="dns_servers">Server DNS</string>
+ <string name="edit">Modifica</string>
+ <string name="endpoint">Endpoint</string>
+ <string name="error_down">Errore di disattivazione del tunnel: %s</string>
+ <string name="error_fetching_apps">Errore di recupero della lista app: %s</string>
+ <string name="error_root">Si prega di ottenere l\'accesso root e riprovare</string>
+ <string name="error_up">Errore di attivazione del tunnel: %s</string>
+ <string name="exclude_private_ips">Escludi IP privati</string>
+ <string name="excluded_applications">Applicazioni escluse</string>
+ <string name="generate">Genera</string>
+ <string name="generic_error">Errore “%s” sconosciuto</string>
+ <string name="hint_automatic">(auto)</string>
+ <string name="hint_generated">(generato)</string>
+ <string name="hint_optional">(facoltativo)</string>
+ <string name="hint_random">(casuale)</string>
+ <string name="illegal_filename_error">Nome file “%s” non valido</string>
+ <string name="import_error">Impossibile importare il tunnel: %s</string>
+ <string name="import_from_qr_code">Importa tunnel da codice QR</string>
+ <string name="import_success">Importato “%s”</string>
+ <string name="interface_title">Interfaccia</string>
+ <string name="key_length_explanation_base64">: le chiavi base64 di WireGuard devono essere di 44 caratteri (32 byte)</string>
+ <string name="key_length_explanation_binary">: le chiavi di WireGuard devono essere di 32 byte</string>
+ <string name="key_length_explanation_hex">: le chiavi hex di WireGuard devono essere di 64 caratteri (32 byte)</string>
+ <string name="listen_port">Porta in ascolto</string>
+ <string name="log_export_error">Impossibile esportare il registro: %s</string>
+ <string name="log_export_success">Salvato in “%s”</string>
+ <string name="log_export_summary">Il file del registro verrà salvato nella cartella di download</string>
+ <string name="log_export_title">Esporta file registro</string>
+ <string name="logcat_error">Impossibile eseguire logcat: </string>
+ <string name="module_version_error">Impossibile determinare la versione modulo del kernel</string>
+ <string name="module_installer_not_found">Nessun modulo disponibile per il tuo dispositivo</string>
+ <string name="module_installer_initial">Il modulo del kernel sperimentale può migliorare le prestazioni</string>
+ <string name="module_installer_success">Fatto. L\'applicazione si riavvierà in 5 secondi</string>
+ <string name="module_installer_title">Scarica e installa il modulo del kernel</string>
+ <string name="module_installer_working">Scaricamento e installazione…</string>
+ <string name="module_installer_error">Qualcosa è andato storto. Riprova</string>
+ <string name="mtu">MTU</string>
+ <string name="name">Nome</string>
+ <string name="no_config_error">Tentativo di attivare un tunnel senza configurazione</string>
+ <string name="no_configs_error">Nessuna configurazione trovata</string>
+ <string name="no_tunnels_error">Non esistono tunnel</string>
+ <string name="parse_error_generic">stringa</string>
+ <string name="parse_error_inet_address">indirizzo IP</string>
+ <string name="parse_error_inet_endpoint">endpoint</string>
+ <string name="parse_error_inet_network">rete IP</string>
+ <string name="parse_error_integer">numero</string>
+ <string name="parse_error_reason">Impossibile analizzare %1$s “%2$s”</string>
+ <string name="peer">Peer</string>
+ <string name="permission_description">Permette ad un\'app di controllare i tunnel WireGuard. Le app con questa autorizzazione possono attivare e disattivare i tunnel WireGuard a piacimento, potenzialmente deviando il traffico internet.</string>
+ <string name="permission_label">controlla tunnel WireGuard</string>
+ <string name="persistent_keepalive">Tieni sempre attivo</string>
+ <string name="pre_shared_key">Chiave condivisa (PSK)</string>
+ <string name="private_key">Chiave privata</string>
+ <string name="public_key">Chiave pubblica</string>
+ <string name="public_key_description">La chiave pubblica</string>
+ <string name="qr_code_hint">Suggerimento: genera con `qrencode -t ansiutf8 &lt; tunnel.conf`.</string>
+ <string name="restore_on_boot_summary">Attiva tunnel attivati in precedenza all\'avvio</string>
+ <string name="restore_on_boot_title">Ripristina all\'avvio</string>
+ <string name="save">Salva</string>
+ <string name="select_all">Seleziona tutto</string>
+ <string name="set_exclusions">Imposta esclusioni</string>
+ <string name="settings">Impostazioni</string>
+ <string name="shell_exit_status_read_error">La shell non riesce a leggere l\'exit status</string>
+ <string name="shell_marker_count_error">La shell si aspettava 4 marker, ne ha ricevuti %d</string>
+ <string name="shell_start_error">Avvio della shell fallito: %d</string>
+ <string name="toggle_error">Errore di commutazione tunnel WireGuard: %s</string>
+ <string name="tools_installer_already">wg e wg-quick sono già installati</string>
+ <string name="tools_installer_failure">Impossibile installare strumenti di riga di comando (non root?)</string>
+ <string name="tools_installer_initial">Installa strumenti facoltativi per script</string>
+ <string name="tools_installer_initial_magisk">Installa strumenti facoltativi per script come moduli Magisk</string>
+ <string name="tools_installer_initial_system">Installa strumenti facoltativi per script nella partizione di sistema</string>
+ <string name="tools_installer_success_magisk">wg e wg-quick installati come moduli Magisk (riavvio necessario)</string>
+ <string name="tools_installer_success_system">wg e wg-quick installati nella partizione di sistema</string>
+ <string name="tools_installer_title">Installa strumenti di riga di comando</string>
+ <string name="tools_installer_working">Installazione di wg e wg-quick</string>
+ <string name="tools_unavailable_error">Strumenti necessari non disponibili</string>
+ <string name="transfer">Trasferisci</string>
+ <string name="transfer_rx_tx">rx: %1$s, tx: %2$s</string>
+ <string name="transfer_bytes">%d B</string>
+ <string name="transfer_kibibytes">%.2f KiB</string>
+ <string name="transfer_mibibytes">%.2f MiB</string>
+ <string name="transfer_gibibytes">%.2f GiB</string>
+ <string name="transfer_tibibytes">%.2f TiB</string>
+ <string name="tun_create_error">Impossibile creare il dispositivo tun</string>
+ <string name="tunnel_config_error">Impossibile configurare il tunnel (wg-quick ha risposto %d)</string>
+ <string name="tunnel_create_error">Impossibile creare il tunnel: %s</string>
+ <string name="tunnel_create_success">Tunnel “%s” creato correttamente</string>
+ <string name="tunnel_error_already_exists">Il tunnel “%s” esiste già</string>
+ <string name="tunnel_error_invalid_name">Nome non valido</string>
+ <string name="tunnel_list_placeholder">Aggiungi un tunnel usando il pulsante blu</string>
+ <string name="tunnel_name">Nome tunnel</string>
+ <string name="tunnel_on_error">Impossibile attivare il tunnel (wgTurnOn ha risposto %d)</string>
+ <string name="tunnel_rename_error">Impossibile rinominare il tunnel: %s</string>
+ <string name="tunnel_rename_success">Tunnel rinominato correttamente in “%s”</string>
+ <string name="type_name_go_userspace">Spazio utente Go</string>
+ <string name="type_name_kernel_module">Modulo kernel</string>
+ <string name="unknown_error">Errore sconosciuto</string>
+ <string name="version_summary">%1$s backend v%2$s</string>
+ <string name="version_summary_checking">Controllo versione backend %s</string>
+ <string name="version_summary_unknown">Versione %s sconosciuta</string>
+ <string name="version_title">WireGuard per Android v%s</string>
+ <string name="vpn_not_authorized_error">Servizio VPN non autorizzato dall\'utente</string>
+ <string name="vpn_start_error">Impossibile avviare il servizio VPN di Android</string>
+ <string name="zip_export_error">Impossibile esportare i tunnel: %s</string>
+ <string name="zip_export_success">Salvato in “%s”</string>
+ <string name="zip_export_summary">Il file zip verrà salvato nella cartella di download</string>
+ <string name="zip_export_title">Esporta i tunnel in un file zip</string>
+ <string name="key_length_error">Lunghezza chiave non valida</string>
+ <string name="key_contents_error">Caratteri errati nella chiave</string>
+</resources>
diff --git a/ui/src/main/res/values-ja/strings.xml b/ui/src/main/res/values-ja/strings.xml
new file mode 100644
index 00000000..22d1b72f
--- /dev/null
+++ b/ui/src/main/res/values-ja/strings.xml
@@ -0,0 +1,173 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <plurals name="delete_error">
+ <item quantity="other">%d トンネルを削除できません: %s</item>
+ </plurals>
+ <plurals name="delete_success">
+ <item quantity="other">%d トンネルを削除しました</item>
+ </plurals>
+ <plurals name="delete_title">
+ <item quantity="other">%d トンネルを選択</item>
+ </plurals>
+ <plurals name="import_partial_success">
+ <item quantity="other">%d 個(全 %d 個)のトンネル設定をインポート</item>
+ </plurals>
+ <plurals name="import_total_success">
+ <item quantity="other">%d 個のトンネル設定をインポート済</item>
+ </plurals>
+ <plurals name="set_excluded_applications">
+ <item quantity="other">除外アプリ %d 個</item>
+ </plurals>
+ <string name="add_peer">ピアを追加する</string>
+ <string name="addresses">Addresses</string>
+ <string name="allowed_ips">Allowed IPs</string>
+ <string name="app_name">WireGuard</string>
+ <string name="bad_config_context">%1$s\'s %2$s</string>
+ <string name="bad_config_context_top_level">%s</string>
+ <string name="bad_config_error">%1$s in %2$s</string>
+ <string name="bad_config_explanation_pka">: 65535未満の正の整数を指定してください</string>
+ <string name="bad_config_explanation_positive_number">: 正の整数を指定</string>
+ <string name="bad_config_explanation_udp_port">: 有効な UDP ポート番号を指定してください</string>
+ <string name="bad_config_reason_invalid_key">無効な鍵</string>
+ <string name="bad_config_reason_invalid_number">無効な数字</string>
+ <string name="bad_config_reason_invalid_value">無効な値</string>
+ <string name="bad_config_reason_missing_attribute">属性が不足しています</string>
+ <string name="bad_config_reason_missing_section">セクションが不足しています</string>
+ <string name="bad_config_reason_missing_value">値が不足しています</string>
+ <string name="bad_config_reason_syntax_error">構文エラー</string>
+ <string name="bad_config_reason_unknown_attribute">未知の属性</string>
+ <string name="bad_config_reason_unknown_section">未知のセクション</string>
+ <string name="bad_config_reason_value_out_of_range">範囲外の値</string>
+ <string name="bad_extension_error">ファイルの拡張子は .conf か .zip です</string>
+ <string name="cancel">キャンセル</string>
+ <string name="config_delete_error">%s の定義を削除できません</string>
+ <string name="config_exists_error">"%s" の定義はすでに存在します</string>
+ <string name="config_file_exists_error">設定ファイル "%s" はすでに存在します</string>
+ <string name="config_not_found_error">設定ファイル "%s" が見つかりません</string>
+ <string name="config_rename_error">設定ファイル "%s" の名前を変更できません</string>
+ <string name="config_save_error">“%1$s” の設定を保存できません: %2$s</string>
+ <string name="config_save_success">"%s" の設定を保存しました</string>
+ <string name="create_activity_title">WireGuard トンネルの作成</string>
+ <string name="create_bin_dir_error">ローカルバイナリディレクトリを作成できません</string>
+ <string name="create_empty">空の状態から作成</string>
+ <string name="create_from_file">ファイル、アーカイブから作成</string>
+ <string name="create_from_qr_code">QRコードから作成</string>
+ <string name="create_output_dir_error">出力ディレクトリを作成できません</string>
+ <string name="create_downloads_file_error">ダウンロードディレクトリにファイルを作成できません</string>
+ <string name="create_temp_dir_error">ローカルに一時ディレクトリを作成できません</string>
+ <string name="create_tunnel">トンネルを作成</string>
+ <string name="dark_theme_summary_off">ライト(日中)テーマを使用中</string>
+ <string name="dark_theme_summary_on">ダーク(夜間)テーマを使用中</string>
+ <string name="dark_theme_title">ダークテーマを使用する</string>
+ <string name="delete">削除</string>
+ <string name="toggle_all">すべての状態を切り替え</string>
+ <string name="dns_servers">DNS サーバ</string>
+ <string name="edit">編集</string>
+ <string name="endpoint">エンドポイント</string>
+ <string name="error_down">トンネル停止時エラー: %s</string>
+ <string name="error_fetching_apps">アプリ一覧取得エラー: %s</string>
+ <string name="error_root">root 権限を取得して再試行してください</string>
+ <string name="error_up">トンネル起動時エラー: %s</string>
+ <string name="exclude_private_ips">プライベート IP アドレスの除外</string>
+ <string name="excluded_applications">除外されたアプリケーション</string>
+ <string name="generate">生成</string>
+ <string name="generic_error">未知のエラー “%s”</string>
+ <string name="hint_automatic">(自動)</string>
+ <string name="hint_generated">(生成済み)</string>
+ <string name="hint_optional">(任意)</string>
+ <string name="hint_random">(ランダム)</string>
+ <string name="illegal_filename_error">不正なファイル名 “%s”</string>
+ <string name="import_error">トンネル設定をインポートできません: %s</string>
+ <string name="import_from_qr_code">QR コードからトンネル設定をインポートできません</string>
+ <string name="import_success">インポートしました “%s”</string>
+ <string name="interface_title">インターフェース</string>
+ <string name="key_length_explanation_base64">: WireGuard base64 鍵は44文字(32バイト)でなければなりません</string>
+ <string name="key_length_explanation_binary">: WireGuard 鍵は32バイトでなければなりません</string>
+ <string name="key_length_explanation_hex">: WireGuard hex 鍵は64文字(32バイト)でなければなりません</string>
+ <string name="listen_port">Listen ポート</string>
+ <string name="log_export_error">ログをエクスポートできません: %s</string>
+ <string name="log_export_success">“%s” に保存しました</string>
+ <string name="log_export_summary">ログはダウンロードフォルダに保存されます</string>
+ <string name="log_export_title">ログのエクスポート</string>
+ <string name="logcat_error">logcat を実行できません: </string>
+ <string name="module_version_error">カーネルモジュールバージョンを特定できません</string>
+ <string name="module_installer_not_found">このデバイス用のモジュールは利用できません</string>
+ <string name="module_installer_initial">実験的カーネルモジュールはパフォーマンスが向上する場合があります</string>
+ <string name="module_installer_success">成功. アプリは5秒後以内に再起動します</string>
+ <string name="module_installer_title">カーネルモジュールをダウンロードしてインストールする</string>
+ <string name="module_installer_working">ダウンロードしてインストールしています…</string>
+ <string name="module_installer_error">失敗しました. 再度実行してみてください</string>
+ <string name="mtu">MTU</string>
+ <string name="name">名前</string>
+ <string name="no_config_error">未設定のままトンネルを起動しようとしています</string>
+ <string name="no_configs_error">設定が見つかりません</string>
+ <string name="no_tunnels_error">トンネルが存在しません</string>
+ <string name="parse_error_generic">文字</string>
+ <string name="parse_error_inet_address">IP アドレス</string>
+ <string name="parse_error_inet_endpoint">エンドポイント</string>
+ <string name="parse_error_inet_network">IP ネットワーク</string>
+ <string name="parse_error_integer">数値</string>
+ <string name="parse_error_reason">%1$s の内容を解読できません “%2$s”</string>
+ <string name="peer">ピア</string>
+ <string name="permission_description">アプリに WireGuard トンネルの制御を許可します。この権限を持つアプリはトンネルの起動停止ができるようになりますが、インターネットトラフィックが意図しない方向に向かう可能性があります。</string>
+ <string name="permission_label">control WireGuard tunnels</string>
+ <string name="persistent_keepalive">持続的キープアライブ</string>
+ <string name="pre_shared_key">事前共有鍵</string>
+ <string name="private_key">秘密鍵</string>
+ <string name="public_key">公開鍵</string>
+ <string name="public_key_description">公開鍵</string>
+ <string name="qr_code_hint">Tip: `qrencode -t ansiutf8 &lt; tunnel.conf` で生成できます</string>
+ <string name="restore_on_boot_summary">起動時に前回有効だったトンネルを起動する</string>
+ <string name="restore_on_boot_title">起動時に復元</string>
+ <string name="save">保存</string>
+ <string name="select_all">すべて選択</string>
+ <string name="set_exclusions">例外を設定</string>
+ <string name="settings">設定</string>
+ <string name="shell_exit_status_read_error">シェルは終了ステータスを取得できません</string>
+ <string name="shell_marker_count_error">シェルは 4 マーカーを期待していますが、 %d を受け取りました</string>
+ <string name="shell_start_error">シェル実行に失敗しました: %d</string>
+ <string name="toggle_error">WireGuard トンネルのトグル時にエラー: %s</string>
+ <string name="tools_installer_already">wg および wg-quick はインストール済みです</string>
+ <string name="tools_installer_failure">コマンドラインツールをインストールできません(rootではない?)</string>
+ <string name="tools_installer_initial">スクリプティングのためのオプションツールのインストール</string>
+ <string name="tools_installer_initial_magisk">スクリプティングのためのオプションツールを Magisk モジュールとしてインストール</string>
+ <string name="tools_installer_initial_system">スクリプティングのためのオプションツールをシステムパーティションにインストール</string>
+ <string name="tools_installer_success_magisk">wg および wg-quick を Magisk モジュールとしてインストール(再起動必須)</string>
+ <string name="tools_installer_success_system">wg および wg-quick をシステムパーティションにインストール</string>
+ <string name="tools_installer_title">コマンドラインツールのインストール</string>
+ <string name="tools_installer_working">wg および wg-quick のインストール</string>
+ <string name="tools_unavailable_error">リクエストされたツールは利用できません</string>
+ <string name="transfer">転送</string>
+ <string name="transfer_rx_tx">受信: %1$s, 送信: %2$s</string>
+ <string name="transfer_bytes">%d B</string>
+ <string name="transfer_kibibytes">%.2f KiB</string>
+ <string name="transfer_mibibytes">%.2f MiB</string>
+ <string name="transfer_gibibytes">%.2f GiB</string>
+ <string name="transfer_tibibytes">%.2f TiB</string>
+ <string name="tun_create_error">tun デバイスを作成できません</string>
+ <string name="tunnel_config_error">トンネルを設定できません (wg-quick が %d を返却)</string>
+ <string name="tunnel_create_error">トンネルを作成できません: %s</string>
+ <string name="tunnel_create_success">トンネル "%s" を作成しました</string>
+ <string name="tunnel_error_already_exists">トンネル “%s” は存在します</string>
+ <string name="tunnel_error_invalid_name">不正な名前</string>
+ <string name="tunnel_list_placeholder">青ボタンでトンネルを追加</string>
+ <string name="tunnel_name">トンネル名</string>
+ <string name="tunnel_on_error">トンネルを有効にできません (wgTurnOn が %d を返却)</string>
+ <string name="tunnel_rename_error">トンネル名を変更できません: %s</string>
+ <string name="tunnel_rename_success">トンネル名を “%s” に変更しました</string>
+ <string name="type_name_go_userspace">Go ユーザースペース</string>
+ <string name="type_name_kernel_module">カーネルモジュール</string>
+ <string name="unknown_error">未知のエラー</string>
+ <string name="version_summary">%1$s backend v%2$s</string>
+ <string name="version_summary_checking">%s バックエンドのバージョンを確認中</string>
+ <string name="version_summary_unknown">未知の %s バージョン</string>
+ <string name="version_title">WireGuard for Android v%s</string>
+ <string name="vpn_not_authorized_error">VPN サービスはユーザに認証されていません</string>
+ <string name="vpn_start_error">Android VPN サービスを開始できません</string>
+ <string name="zip_export_error">トンネル設定をエクスポートできません: %s</string>
+ <string name="zip_export_success">“%s” に保存</string>
+ <string name="zip_export_summary">Zip ファイルはダウンロードフォルダに保存されます</string>
+ <string name="zip_export_title">トンネル設定を zip ファイルにエクスポート</string>
+ <string name="key_length_error">鍵の長さが不正</string>
+ <string name="key_contents_error">鍵に不正な文字があります</string>
+</resources>
diff --git a/ui/src/main/res/values-night/bools.xml b/ui/src/main/res/values-night/bools.xml
new file mode 100644
index 00000000..b02fcc05
--- /dev/null
+++ b/ui/src/main/res/values-night/bools.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <bool name="light_status_bar">false</bool>
+ <bool name="light_navigation_bar">false</bool>
+</resources>
diff --git a/ui/src/main/res/values-night/colors.xml b/ui/src/main/res/values-night/colors.xml
new file mode 100644
index 00000000..314142d9
--- /dev/null
+++ b/ui/src/main/res/values-night/colors.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Base palette -->
+ <color name="primary_color">#ff212121</color>
+ <color name="primary_light_color">#ff484848</color>
+ <color name="primary_dark_color">#ff000000</color>
+ <color name="secondary_color">#ff4285f4</color>
+ <color name="secondary_light_color">#ff80b4ff</color>
+ <color name="secondary_dark_color">#ff0059c1</color>
+ <color name="primary_text_color">#ffffffff</color>
+ <color name="secondary_text_color">#ffffffff</color>
+
+ <!-- Theme variables -->
+ <color name="list_multiselect_background">#1aeeeeee</color>
+ <color name="status_bar_color">#21242424</color>
+ <color name="navigation_bar_color">#aa242424</color>
+</resources>
diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml
new file mode 100644
index 00000000..c8f1b8aa
--- /dev/null
+++ b/ui/src/main/res/values-ru/strings.xml
@@ -0,0 +1,179 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <plurals name="delete_error">
+ <item quantity="one">Не удалось удалить %d туннель: %s</item>
+ <item quantity="other">Не удалось удалить %d туннелей: %s</item>
+ </plurals>
+ <plurals name="delete_success">
+ <item quantity="one">Успешно удален %d туннель</item>
+ <item quantity="other">Успешно удалено %d туннелей</item>
+ </plurals>
+ <plurals name="delete_title">
+ <item quantity="one">%d туннель выбран</item>
+ <item quantity="other">%d туннелей выбрано</item>
+ </plurals>
+ <plurals name="import_partial_success">
+ <item quantity="one">Импортирован %d из %d туннелей</item>
+ <item quantity="other">Импортировано %d из %d туннелей</item>
+ </plurals>
+ <plurals name="import_total_success">
+ <item quantity="one">Импортированный %d туннель</item>
+ <item quantity="other">Импортировано %d туннелей</item>
+ </plurals>
+ <plurals name="set_excluded_applications">
+ <item quantity="one">%d Исключенное приложение</item>
+ <item quantity="other">%d Исключенных приложений</item>
+ </plurals>
+ <string name="add_peer">Добавить пира</string>
+ <string name="addresses">IP-адреса</string>
+ <string name="allowed_ips">Разрешенные IP-адреса</string>
+ <string name="app_name">WireGuard</string>
+ <string name="bad_config_context">%1$s из %2$s</string>
+ <string name="bad_config_context_top_level">%s</string>
+ <string name="bad_config_error">%1$s в %2$s</string>
+ <string name="bad_config_explanation_pka">: Должен быть положительный и не больше 65535</string>
+ <string name="bad_config_explanation_positive_number">: Должен быть положительный</string>
+ <string name="bad_config_explanation_udp_port">: Должен быть действительный номер порта UDP</string>
+ <string name="bad_config_reason_invalid_key">Неправильный ключ</string>
+ <string name="bad_config_reason_invalid_number">Неправильный номер</string>
+ <string name="bad_config_reason_invalid_value">Неправильное значение</string>
+ <string name="bad_config_reason_missing_attribute">Несуществующий атрибут</string>
+ <string name="bad_config_reason_missing_section">Несуществующий раздел</string>
+ <string name="bad_config_reason_missing_value">Несуществющее значение</string>
+ <string name="bad_config_reason_syntax_error">Синтаксическая ошибка</string>
+ <string name="bad_config_reason_unknown_attribute">Неизвестный атрибут</string>
+ <string name="bad_config_reason_unknown_section">Неизвестный раздел</string>
+ <string name="bad_config_reason_value_out_of_range">Значение вне диапазона</string>
+ <string name="bad_extension_error">Файл должен быть .conf или .zip</string>
+ <string name="cancel">Отмена</string>
+ <string name="config_delete_error">Не удалось удалить файл конфигурации %s</string>
+ <string name="config_exists_error">Конфигурация для “%s” уже существует</string>
+ <string name="config_file_exists_error">Файл конфигурации “%s” уже существует</string>
+ <string name="config_not_found_error">Файл конфигурации “%s” не найден</string>
+ <string name="config_rename_error">Не удалось переименовать файл конфигурации “%s”</string>
+ <string name="config_save_error">Не удается сохранить конфигурацию для “%1$s”: %2$s</string>
+ <string name="config_save_success">Конфигурация для “%s” успешно сохранена</string>
+ <string name="create_activity_title">Создать WireGuard туннель</string>
+ <string name="create_bin_dir_error">Не удалось создать локальный двоичный каталог</string>
+ <string name="create_empty">Создать вручную</string>
+ <string name="create_from_file">Создать из файла или архива</string>
+ <string name="create_from_qr_code">Создать из QR-кода</string>
+ <string name="create_output_dir_error">Не удалось создать выходной каталог</string>
+ <string name="create_downloads_file_error">Не удалось создать файл в каталоге загрузок</string>
+ <string name="create_temp_dir_error">Не удалось создать временный локальный каталог</string>
+ <string name="create_tunnel">Создать туннель</string>
+ <string name="dark_theme_summary_off">В данный момент используется светлая (дневная) тема</string>
+ <string name="dark_theme_summary_on">В данный момент используется темная (ночная) тема</string>
+ <string name="dark_theme_title">Использовать темную тему</string>
+ <string name="delete">Удалить</string>
+ <string name="toggle_all">Инвертировать все</string>
+ <string name="dns_servers">DNS-серверы</string>
+ <string name="edit">Редактировать</string>
+ <string name="endpoint">Конечная точка</string>
+ <string name="error_down">Ошибка при выходе из туннеля: %s</string>
+ <string name="error_fetching_apps">Ошибка при получении списка приложений: %s</string>
+ <string name="error_root">Пожалуйста, получите root-доступ и попробуйте снова</string>
+ <string name="error_up">Ошибка при запуске туннеля: %s</string>
+ <string name="exclude_private_ips">Исключить частные IP-адреса</string>
+ <string name="excluded_applications">Исключенные приложения</string>
+ <string name="generate">Создать</string>
+ <string name="generic_error">Неизвестная “%s” ошибка</string>
+ <string name="hint_automatic">(авто)</string>
+ <string name="hint_generated">(авто)</string>
+ <string name="hint_optional">(авто)</string>
+ <string name="hint_random">(авто)</string>
+ <string name="illegal_filename_error">Неверное имя файла “%s”</string>
+ <string name="import_error">Не удалось импортировать туннель: %s</string>
+ <string name="import_from_qr_code">Импортировать туннель из QR-кода</string>
+ <string name="import_success">Импортировано “%s”</string>
+ <string name="interface_title">Интерфейс</string>
+ <string name="key_length_explanation_base64">: Ключи WireGuard base64 должны содержать 44 символа (32 байта)</string>
+ <string name="key_length_explanation_binary">: Ключи WireGuard должны быть 32 байта</string>
+ <string name="key_length_explanation_hex">: HEX ключи WireGuard должны содержать 64 символа (32 байта)</string>
+ <string name="listen_port">Порт</string>
+ <string name="log_export_error">Не удалось экспортировать логи: %s</string>
+ <string name="log_export_success">Сохранено в “%s”</string>
+ <string name="log_export_summary">Файл логов будет сохранен в папке загрузок</string>
+ <string name="log_export_title">Экспорт логов в файл</string>
+ <string name="logcat_error">Не удалось запустить logcat: </string>
+ <string name="module_version_error">Не удалось определить версию модуля ядра</string>
+ <string name="module_installer_not_found">Для вашего устройства нет доступных модулей</string>
+ <string name="module_installer_initial">Экспериментальный модуль ядра может улучшить производительность</string>
+ <string name="module_installer_success">Успех. Приложение перезапустится через 5 секунд</string>
+ <string name="module_installer_title">Скачать и установить модуль ядра</string>
+ <string name="module_installer_working">Скачивание и установка…</string>
+ <string name="module_installer_error">Что-то пошло не так. Пожалуйста, попробуйте еще раз</string>
+ <string name="mtu">MTU</string>
+ <string name="name">Имя</string>
+ <string name="no_config_error">Попытка поднять туннель без конфигурации</string>
+ <string name="no_configs_error">Конфигурации не найдены</string>
+ <string name="no_tunnels_error">Туннелей не существует</string>
+ <string name="parse_error_generic">строка</string>
+ <string name="parse_error_inet_address">IP-адрес</string>
+ <string name="parse_error_inet_endpoint">конечная точка</string>
+ <string name="parse_error_inet_network">IP-сеть</string>
+ <string name="parse_error_integer">число</string>
+ <string name="parse_error_reason">Не могу разобрать %1$s “%2$s”</string>
+ <string name="peer">Пир</string>
+ <string name="permission_description">Приложение сможет управлять туннелями WireGuard. Приложения с таким разрешением могут по желанию включать и отключать туннели WireGuard, что может привести к неправильному перенаправлению интернет-трафика.</string>
+ <string name="permission_label">управлять туннелями WireGuard</string>
+ <string name="persistent_keepalive">Постоянное соединение</string>
+ <string name="pre_shared_key">Общий ключ</string>
+ <string name="private_key">Приватный ключ</string>
+ <string name="public_key">Публичный ключ</string>
+ <string name="public_key_description">Публичный ключ</string>
+ <string name="qr_code_hint">Совет: генерировать с `qrencode -t ansiutf8 &lt; tunnel.conf`.</string>
+ <string name="restore_on_boot_summary">Поднимать ранее выбранные туннели при загрузке</string>
+ <string name="restore_on_boot_title">Восстанавливать при загрузке</string>
+ <string name="save">Сохранить</string>
+ <string name="select_all">Выбрать все</string>
+ <string name="set_exclusions">ОК</string>
+ <string name="settings">Настройки</string>
+ <string name="shell_exit_status_read_error">Shell не может прочитать статус выхода</string>
+ <string name="shell_marker_count_error">Shell ожидает 4 маркера, получено %d</string>
+ <string name="shell_start_error">Не удалось запустить Shell: %d</string>
+ <string name="toggle_error">Ошибка переключения туннеля WireGuard: %s</string>
+ <string name="tools_installer_already">wg и wg-quick уже установлены</string>
+ <string name="tools_installer_failure">Не удалось установить инструменты командной строки (нет root?)</string>
+ <string name="tools_installer_initial">Установите дополнительные инструменты для сценариев</string>
+ <string name="tools_installer_initial_magisk">Установите дополнительные инструменты для сценариев в качестве модуля Magisk</string>
+ <string name="tools_installer_initial_system">Установите дополнительные инструменты для сценариев в системный раздел</string>
+ <string name="tools_installer_success_magisk">wg и wg-quick установлены как модуль Magisk (требуется перезагрузка)</string>
+ <string name="tools_installer_success_system">wg и wg-quick установлены в системный раздел</string>
+ <string name="tools_installer_title">Установить инструменты командной строки</string>
+ <string name="tools_installer_working">Установка wg и wg-quick</string>
+ <string name="tools_unavailable_error">Необходимые инструменты недоступны</string>
+ <string name="transfer">Статистика</string>
+ <string name="transfer_rx_tx">Принято: %1$s, Отдано: %2$s</string>
+ <string name="transfer_bytes">%d Б</string>
+ <string name="transfer_kibibytes">%.2f Кб</string>
+ <string name="transfer_mibibytes">%.2f Мб</string>
+ <string name="transfer_gibibytes">%.2f Гб</string>
+ <string name="transfer_tibibytes">%.2f Тб</string>
+ <string name="tun_create_error">Не удалось создать устройство tun</string>
+ <string name="tunnel_config_error">Не удалось настроить туннель (wg-quick вернул %d)</string>
+ <string name="tunnel_create_error">Не удалось создать туннель: %s</string>
+ <string name="tunnel_create_success">Успешно созданный туннель “%s”</string>
+ <string name="tunnel_error_already_exists">Туннель “%s” уже существует</string>
+ <string name="tunnel_error_invalid_name">Неправильное имя</string>
+ <string name="tunnel_list_placeholder">Добавьте туннель с помощью синей кнопки</string>
+ <string name="tunnel_name">Название туннеля</string>
+ <string name="tunnel_on_error">Не удалось включить туннель (wgTurnOn вернул %d)</string>
+ <string name="tunnel_rename_error">Не удалось переименовать туннель: %s</string>
+ <string name="tunnel_rename_success">Туннель успешно переименован в “%s”</string>
+ <string name="type_name_go_userspace">Пользовательское пространство</string>
+ <string name="type_name_kernel_module">Модуль ядра</string>
+ <string name="unknown_error">Неизвестная ошибка</string>
+ <string name="version_summary">%1$s v%2$s</string>
+ <string name="version_summary_checking">Проверка версии %s</string>
+ <string name="version_summary_unknown">Неизвестная версия %s</string>
+ <string name="version_title">WireGuard для Android v%s</string>
+ <string name="vpn_not_authorized_error">VPN-сервис не авторизован пользователем</string>
+ <string name="vpn_start_error">Не удалось запустить службу Android VPN</string>
+ <string name="zip_export_error">Не удалось экспортировать туннели: %s</string>
+ <string name="zip_export_success">Сохранено в “%s”</string>
+ <string name="zip_export_summary">Zip-архив будет сохранен в папке загрузок</string>
+ <string name="zip_export_title">Экспорт туннелей в zip-архив</string>
+ <string name="key_length_error">Неверная длина ключа</string>
+ <string name="key_contents_error">Плохие символы в ключе</string>
+</resources>
diff --git a/ui/src/main/res/values-v27/styles.xml b/ui/src/main/res/values-v27/styles.xml
new file mode 100644
index 00000000..2f4b7107
--- /dev/null
+++ b/ui/src/main/res/values-v27/styles.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <style name="AppTheme" parent="Theme.MaterialComponents.DayNight">
+ <item name="colorPrimary">@color/primary_color</item>
+ <item name="colorOnPrimary">@color/color_control_normal</item>
+ <item name="colorPrimaryDark">@color/primary_color</item>
+ <item name="colorPrimaryVariant">@color/primary_light_color</item>
+ <item name="colorSecondary">@color/secondary_color</item>
+ <item name="colorOnSecondary">@color/secondary_text_color</item>
+ <item name="colorSurface">@color/primary_color</item>
+ <item name="colorOnSurface">@color/color_control_normal</item>
+ <item name="colorBackground">@color/primary_color</item>
+ <item name="colorControlNormal">@color/color_control_normal</item>
+ <item name="colorMultiselectActiveBackground">@color/list_multiselect_background</item>
+ <item name="elevationOverlayColor">@color/primary_light_color</item>
+ <item name="elevationOverlayEnabled">true</item>
+ <item name="android:colorBackground">@color/primary_color</item>
+ <item name="android:navigationBarColor">@color/navigation_bar_color</item>
+ <item name="android:statusBarColor">@color/status_bar_color</item>
+ <item name="android:windowLightNavigationBar">@bool/light_navigation_bar</item>
+ <item name="android:windowLightStatusBar">@bool/light_status_bar</item>
+ <item name="android:windowBackground">@color/primary_color</item>
+ <item name="alertDialogTheme">@style/AppTheme.Dialog</item>
+ <item name="materialAlertDialogTheme">@style/AppTheme.Dialog</item>
+ <item name="actionBarPopupTheme">@style/ThemeOverlay.MaterialComponents.ActionBar</item>
+ </style>
+</resources>
diff --git a/ui/src/main/res/values-zh-rCN/strings.xml b/ui/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 00000000..8f7ab987
--- /dev/null
+++ b/ui/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,173 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <plurals name="delete_error">
+ <item quantity="other">无法删除 %d 项:%s</item>
+ </plurals>
+ <plurals name="delete_success">
+ <item quantity="other">删除了 %d 项</item>
+ </plurals>
+ <plurals name="delete_title">
+ <item quantity="other">已选择 %d 项</item>
+ </plurals>
+ <plurals name="import_partial_success">
+ <item quantity="other">导入了 %d 项,读取到 %d 项</item>
+ </plurals>
+ <plurals name="import_total_success">
+ <item quantity="other">导入了 %d 项</item>
+ </plurals>
+ <plurals name="set_excluded_applications">
+ <item quantity="other">%d 个排除应用</item>
+ </plurals>
+ <string name="add_peer">添加节点</string>
+ <string name="addresses">局域网 IP 地址</string>
+ <string name="allowed_ips">允许的 IP 地址(段)</string>
+ <string name="app_name">WireGuard</string>
+ <string name="bad_config_context">%1$s 的 %2$s 字段</string>
+ <string name="bad_config_context_top_level">%s</string>
+ <string name="bad_config_error">在 %2$s发生了%1$s的问题</string>
+ <string name="bad_config_explanation_pka">:必须为正整数且不超过 65535</string>
+ <string name="bad_config_explanation_positive_number">:必须为正整数</string>
+ <string name="bad_config_explanation_udp_port">:必须为有效的 UDP 端口号</string>
+ <string name="bad_config_reason_invalid_key">密钥无效</string>
+ <string name="bad_config_reason_invalid_number">数字无效</string>
+ <string name="bad_config_reason_invalid_value">数值无效</string>
+ <string name="bad_config_reason_missing_attribute">属性缺失</string>
+ <string name="bad_config_reason_missing_section">节缺失</string>
+ <string name="bad_config_reason_missing_value">数值缺失</string>
+ <string name="bad_config_reason_syntax_error">语法错误</string>
+ <string name="bad_config_reason_unknown_attribute">属性未知</string>
+ <string name="bad_config_reason_unknown_section">节未知</string>
+ <string name="bad_config_reason_value_out_of_range">数值超出范围</string>
+ <string name="bad_extension_error">扩展名必须为 .conf 或 .zip</string>
+ <string name="cancel">取消</string>
+ <string name="config_delete_error">无法删除配置 “%s”</string>
+ <string name="config_exists_error">“%s” 的配置已存在</string>
+ <string name="config_file_exists_error">配置 “%s” 已存在</string>
+ <string name="config_not_found_error">找不到配置 “%s”</string>
+ <string name="config_rename_error">无法重命名配置 “%s”</string>
+ <string name="config_save_error">无法保存 “%1$s” 的配置:%2$s</string>
+ <string name="config_save_success">已保存 “%s” 的配置</string>
+ <string name="create_activity_title">创建 WireGuard 隧道</string>
+ <string name="create_bin_dir_error">无法创建本地二进制文件目录</string>
+ <string name="create_empty">手动配置</string>
+ <string name="create_from_file">导入配置或压缩包</string>
+ <string name="create_from_qr_code">扫描二维码</string>
+ <string name="create_output_dir_error">无法创建输出目录</string>
+ <string name="create_downloads_file_error">无法在下载目录中创建文件</string>
+ <string name="create_temp_dir_error">无法创建本地临时目录</string>
+ <string name="create_tunnel">创建隧道</string>
+ <string name="dark_theme_summary_off">正在使用亮色(白昼)主题</string>
+ <string name="dark_theme_summary_on">正在使用暗色(黑夜)主题</string>
+ <string name="dark_theme_title">使用暗色主题</string>
+ <string name="delete">删除</string>
+ <string name="toggle_all">反选</string>
+ <string name="dns_servers">DNS 服务器</string>
+ <string name="edit">编辑</string>
+ <string name="endpoint">对端地址</string>
+ <string name="error_down">断开连接时出错:%s</string>
+ <string name="error_fetching_apps">获取应用列表时出错:%s</string>
+ <string name="error_root">请获取 root 权限并重试</string>
+ <string name="error_up">建立连接时出错:%s</string>
+ <string name="exclude_private_ips">排除局域网 IP</string>
+ <string name="excluded_applications">排除的应用</string>
+ <string name="generate">生成密钥</string>
+ <string name="generic_error">未知的 “%s” 错误</string>
+ <string name="hint_automatic">(自动)</string>
+ <string name="hint_generated">(生成)</string>
+ <string name="hint_optional">(可选)</string>
+ <string name="hint_random">(随机)</string>
+ <string name="illegal_filename_error">文件名 “%s” 不合法</string>
+ <string name="import_error">无法导入隧道:%s</string>
+ <string name="import_from_qr_code">从二维码导入隧道</string>
+ <string name="import_success">导入了 “%s”</string>
+ <string name="interface_title">接口 / Interface</string>
+ <string name="key_length_explanation_base64">:WireGuard 的 Base64 密钥长度必须为 44 个字符(32 字节)</string>
+ <string name="key_length_explanation_binary">:WireGuard 密钥大小必须为 32 字节</string>
+ <string name="key_length_explanation_hex">:WireGuard 的十六进制密钥长度必须为 64 个字符(32 字节)</string>
+ <string name="listen_port">监听端口</string>
+ <string name="log_export_error">无法导出日志:%s</string>
+ <string name="log_export_success">已保存至 “%s”</string>
+ <string name="log_export_summary">日志文件将保存至下载文件夹</string>
+ <string name="log_export_title">导出日志文件</string>
+ <string name="logcat_error">无法运行 logcat:</string>
+ <string name="module_version_error">无法确定内核模块版本</string>
+ <string name="module_installer_not_found">没有可用于此设备的模块</string>
+ <string name="module_installer_initial">此实验性的内核模块可以提升性能</string>
+ <string name="module_installer_success">安装成功,应用将在 5 秒后重启</string>
+ <string name="module_installer_title">下载并安装内核模块</string>
+ <string name="module_installer_working">正在下载安装...</string>
+ <string name="module_installer_error">发生错误,请重试</string>
+ <string name="mtu">MTU</string>
+ <string name="name">名称</string>
+ <string name="no_config_error">尝试在无配置情况下建立连接</string>
+ <string name="no_configs_error">未找到配置</string>
+ <string name="no_tunnels_error">无隧道</string>
+ <string name="parse_error_generic">字符串</string>
+ <string name="parse_error_inet_address"> IP 地址</string>
+ <string name="parse_error_inet_endpoint">对端地址</string>
+ <string name="parse_error_inet_network"> IP 网络</string>
+ <string name="parse_error_integer">数字</string>
+ <string name="parse_error_reason">无法解析%1$s “%2$s” </string>
+ <string name="peer">节点 / Peer</string>
+ <string name="permission_description">允许一个应用对 WireGuard 隧道进行控制(开启 / 关闭隧道),但可能会误传一些流量</string>
+ <string name="permission_label">控制 WireGuard 隧道</string>
+ <string name="persistent_keepalive">连接保活间隔</string>
+ <string name="pre_shared_key">预共享密钥</string>
+ <string name="private_key">私钥</string>
+ <string name="public_key">公钥</string>
+ <string name="public_key_description">公钥</string>
+ <string name="qr_code_hint">提示:使用命令 `qrencode -t ansiutf8 &lt; tunnel.conf` 生成二维码</string>
+ <string name="restore_on_boot_summary">设备启动时自动开启上一次使用的隧道</string>
+ <string name="restore_on_boot_title">开机自启</string>
+ <string name="save">保存</string>
+ <string name="select_all">全选</string>
+ <string name="set_exclusions">确定</string>
+ <string name="settings">设置</string>
+ <string name="shell_exit_status_read_error">Shell 无法读取退出状态</string>
+ <string name="shell_marker_count_error">Shell 应获取 4 个标记,获取到 %d 个</string>
+ <string name="shell_start_error">Shell 启动失败:%d</string>
+ <string name="toggle_error">切换隧道状态时出错:%s</string>
+ <string name="tools_installer_already">wg 与 wg-quick 已安装</string>
+ <string name="tools_installer_failure">无法安装命令行工具(尚未获取 root 权限?)</string>
+ <string name="tools_installer_initial">安装脚本工具(可选)</string>
+ <string name="tools_installer_initial_magisk">安装脚本工具为 Magisk 模块(可选)</string>
+ <string name="tools_installer_initial_system">安装脚本工具至系统分区(可选)</string>
+ <string name="tools_installer_success_magisk">wg 与 wg-quick 已安装为 Magisk 模块(重启后生效)</string>
+ <string name="tools_installer_success_system">wg 与 wg-quick 已安装至系统分区</string>
+ <string name="tools_installer_title">安装命令行工具</string>
+ <string name="tools_installer_working">正在安装 wg 与 wg-quick...</string>
+ <string name="tools_unavailable_error">所需工具不可用</string>
+ <string name="transfer">流量</string>
+ <string name="transfer_rx_tx">接收:%1$s,发送:%2$s</string>
+ <string name="transfer_bytes">%d B</string>
+ <string name="transfer_kibibytes">%.2f KiB</string>
+ <string name="transfer_mibibytes">%.2f MiB</string>
+ <string name="transfer_gibibytes">%.2f GiB</string>
+ <string name="transfer_tibibytes">%.2f TiB</string>
+ <string name="tun_create_error">无法创建 tun 设备</string>
+ <string name="tunnel_config_error">无法配置隧道(wg-quick returned %d)</string>
+ <string name="tunnel_create_error">无法创建隧道:%s</string>
+ <string name="tunnel_create_success">成功创建隧道 “%s”</string>
+ <string name="tunnel_error_already_exists">名称 “%s” 已存在</string>
+ <string name="tunnel_error_invalid_name">名称无效</string>
+ <string name="tunnel_list_placeholder">点击下方按钮添加隧道</string>
+ <string name="tunnel_name">隧道名称</string>
+ <string name="tunnel_on_error">无法开启隧道(wgTurnOn returned %d)</string>
+ <string name="tunnel_rename_error">无法重命名隧道:%s</string>
+ <string name="tunnel_rename_success">隧道已重命名为 “%s”</string>
+ <string name="type_name_go_userspace">Go userspace</string>
+ <string name="type_name_kernel_module">Kernel module</string>
+ <string name="unknown_error">未知错误</string>
+ <string name="version_summary">%1$s backend v%2$s</string>
+ <string name="version_summary_checking">正在检查 %s backend 版本</string>
+ <string name="version_summary_unknown">未知的 %s 版本</string>
+ <string name="version_title">WireGuard for Android v%s</string>
+ <string name="vpn_not_authorized_error">用户未授权 VPN 服务</string>
+ <string name="vpn_start_error">无法启动 Android VPN 服务</string>
+ <string name="zip_export_error">无法导出隧道配置:%s</string>
+ <string name="zip_export_success">已保存至 “%s”</string>
+ <string name="zip_export_summary">zip 压缩包将保存至下载文件夹</string>
+ <string name="zip_export_title">导出隧道配置为 zip 压缩包</string>
+ <string name="key_length_error">密钥长度错误</string>
+ <string name="key_contents_error">密钥中含有错误字符</string>
+</resources>
diff --git a/ui/src/main/res/values/attrs.xml b/ui/src/main/res/values/attrs.xml
new file mode 100644
index 00000000..68a8db07
--- /dev/null
+++ b/ui/src/main/res/values/attrs.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <declare-styleable name="Multiselected">
+ <attr name="state_multiselected" format="boolean" />
+ <attr name="colorMultiselectActiveBackground" format="reference|color"/>
+ </declare-styleable>
+
+ <declare-styleable name="custom_color">
+ <attr name="colorBackground" format="reference|color"/>
+ </declare-styleable>
+</resources>
diff --git a/ui/src/main/res/values/bools.xml b/ui/src/main/res/values/bools.xml
new file mode 100644
index 00000000..288f85a5
--- /dev/null
+++ b/ui/src/main/res/values/bools.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <bool name="light_status_bar">true</bool>
+ <bool name="light_navigation_bar">true</bool>
+</resources>
diff --git a/ui/src/main/res/values/colors.xml b/ui/src/main/res/values/colors.xml
new file mode 100644
index 00000000..06bcd143
--- /dev/null
+++ b/ui/src/main/res/values/colors.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools">
+ <!-- Base palette -->
+ <color name="primary_color">#ffffffff</color>
+ <color name="primary_light_color">#ffffffff</color>
+ <color name="primary_dark_color">#ffcccccc</color>
+ <color name="secondary_color">#ff1a73e8</color>
+ <color name="secondary_light_color">#ff1a73e8</color>
+ <color name="secondary_dark_color">#ff1a73e8</color>
+ <color name="primary_text_color">#ff000000</color>
+ <color name="secondary_text_color">#ffffffff</color>
+
+ <!-- Theme variables -->
+ <color name="color_control_normal">@color/primary_text_color</color>
+ <color name="status_bar_color">@color/primary_color</color>
+ <color name="navigation_bar_color">#aaffffff</color>
+ <color name="list_multiselect_background">#ffeeeeee</color>
+ <color name="mtrl_textinput_default_box_stroke_color" tools:override="true">@color/secondary_color</color>
+ <color name="white">#ffffffff</color>
+
+</resources>
diff --git a/ui/src/main/res/values/dimens.xml b/ui/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..c6abf8eb
--- /dev/null
+++ b/ui/src/main/res/values/dimens.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <dimen name="fab_margin">16dp</dimen>
+ <dimen name="extra_margin">12dp</dimen>
+ <dimen name="bottom_sheet_item_height">56dp</dimen>
+ <dimen name="normal_margin">8dp</dimen>
+ <dimen name="bottom_sheet_top_padding">8dp</dimen>
+ <dimen name="bottom_sheet_icon_padding">16dp</dimen>
+</resources>
diff --git a/ui/src/main/res/values/ic_launcher_background.xml b/ui/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 00000000..f8bad52e
--- /dev/null
+++ b/ui/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="ic_launcher_background">#871719</color>
+</resources> \ No newline at end of file
diff --git a/ui/src/main/res/values/ids.xml b/ui/src/main/res/values/ids.xml
new file mode 100644
index 00000000..7f34f808
--- /dev/null
+++ b/ui/src/main/res/values/ids.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <item name="item_change_listener" type="id" />
+</resources>
diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml
new file mode 100644
index 00000000..45964eec
--- /dev/null
+++ b/ui/src/main/res/values/strings.xml
@@ -0,0 +1,179 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <plurals name="delete_error">
+ <item quantity="one">Unable to delete %d tunnel: %s</item>
+ <item quantity="other">Unable to delete %d tunnels: %s</item>
+ </plurals>
+ <plurals name="delete_success">
+ <item quantity="one">Successfully deleted %d tunnel</item>
+ <item quantity="other">Successfully deleted %d tunnels</item>
+ </plurals>
+ <plurals name="delete_title">
+ <item quantity="one">%d tunnel selected</item>
+ <item quantity="other">%d tunnels selected</item>
+ </plurals>
+ <plurals name="import_partial_success">
+ <item quantity="one">Imported %d of %d tunnels</item>
+ <item quantity="other">Imported %d of %d tunnels</item>
+ </plurals>
+ <plurals name="import_total_success">
+ <item quantity="one">Imported %d tunnel</item>
+ <item quantity="other">Imported %d tunnels</item>
+ </plurals>
+ <plurals name="set_excluded_applications">
+ <item quantity="one">%d Excluded Application</item>
+ <item quantity="other">%d Excluded Applications</item>
+ </plurals>
+ <string name="add_peer">Add peer</string>
+ <string name="addresses">Addresses</string>
+ <string name="allowed_ips">Allowed IPs</string>
+ <string name="app_name">WireGuard</string>
+ <string name="bad_config_context">%1$s\'s %2$s</string>
+ <string name="bad_config_context_top_level">%s</string>
+ <string name="bad_config_error">%1$s in %2$s</string>
+ <string name="bad_config_explanation_pka">: Must be positive and no more than 65535</string>
+ <string name="bad_config_explanation_positive_number">: Must be positive</string>
+ <string name="bad_config_explanation_udp_port">: Must be a valid UDP port number</string>
+ <string name="bad_config_reason_invalid_key">Invalid key</string>
+ <string name="bad_config_reason_invalid_number">Invalid number</string>
+ <string name="bad_config_reason_invalid_value">Invalid value</string>
+ <string name="bad_config_reason_missing_attribute">Missing attribute</string>
+ <string name="bad_config_reason_missing_section">Missing section</string>
+ <string name="bad_config_reason_missing_value">Missing value</string>
+ <string name="bad_config_reason_syntax_error">Syntax error</string>
+ <string name="bad_config_reason_unknown_attribute">Unknown attribute</string>
+ <string name="bad_config_reason_unknown_section">Unknown section</string>
+ <string name="bad_config_reason_value_out_of_range">Value out of range</string>
+ <string name="bad_extension_error">File must be .conf or .zip</string>
+ <string name="cancel">Cancel</string>
+ <string name="config_delete_error">Cannot delete configuration file %s</string>
+ <string name="config_exists_error">Configuration for “%s” already exists</string>
+ <string name="config_file_exists_error">Configuration file “%s” already exists</string>
+ <string name="config_not_found_error">Configuration file “%s” not found</string>
+ <string name="config_rename_error">Cannot rename configuration file “%s”</string>
+ <string name="config_save_error">Cannot save configuration for “%1$s”: %2$s</string>
+ <string name="config_save_success">Successfully saved configuration for “%s”</string>
+ <string name="create_activity_title">Create WireGuard Tunnel</string>
+ <string name="create_bin_dir_error">Cannot create local binary directory</string>
+ <string name="create_empty">Create from scratch</string>
+ <string name="create_from_file">Create from file or archive</string>
+ <string name="create_from_qr_code">Create from QR code</string>
+ <string name="create_output_dir_error">Cannot create output directory</string>
+ <string name="create_downloads_file_error">Cannot create file in downloads directory</string>
+ <string name="create_temp_dir_error">Cannot create local temporary directory</string>
+ <string name="create_tunnel">Create Tunnel</string>
+ <string name="dark_theme_summary_off">Currently using light (day) theme</string>
+ <string name="dark_theme_summary_on">Currently using dark (night) theme</string>
+ <string name="dark_theme_title">Use dark theme</string>
+ <string name="delete">Delete</string>
+ <string name="toggle_all">Toggle All</string>
+ <string name="dns_servers">DNS servers</string>
+ <string name="edit">Edit</string>
+ <string name="endpoint">Endpoint</string>
+ <string name="error_down">Error bringing down tunnel: %s</string>
+ <string name="error_fetching_apps">Error fetching apps list: %s</string>
+ <string name="error_root">Please obtain root access and try again</string>
+ <string name="error_up">Error bringing up tunnel: %s</string>
+ <string name="exclude_private_ips">Exclude private IPs</string>
+ <string name="excluded_applications">Excluded Applications</string>
+ <string name="generate">Generate</string>
+ <string name="generic_error">Unknown “%s” error</string>
+ <string name="hint_automatic">(auto)</string>
+ <string name="hint_generated">(generated)</string>
+ <string name="hint_optional">(optional)</string>
+ <string name="hint_random">(random)</string>
+ <string name="illegal_filename_error">Illegal file name “%s”</string>
+ <string name="import_error">Unable to import tunnel: %s</string>
+ <string name="import_from_qr_code">Import Tunnel from QR Code</string>
+ <string name="import_success">Imported “%s”</string>
+ <string name="interface_title">Interface</string>
+ <string name="key_length_explanation_base64">: WireGuard base64 keys must be 44 characters (32 bytes)</string>
+ <string name="key_length_explanation_binary">: WireGuard keys must be 32 bytes</string>
+ <string name="key_length_explanation_hex">: WireGuard hex keys must be 64 characters (32 bytes)</string>
+ <string name="listen_port">Listen port</string>
+ <string name="log_export_error">Unable to export log: %s</string>
+ <string name="log_export_success">Saved to “%s”</string>
+ <string name="log_export_summary">Log file will be saved to downloads folder</string>
+ <string name="log_export_title">Export log file</string>
+ <string name="logcat_error">Unable to run logcat: </string>
+ <string name="module_version_error">Unable to determine kernel module version</string>
+ <string name="module_installer_not_found">No modules are available for your device</string>
+ <string name="module_installer_initial">The experimental kernel module can improve performance</string>
+ <string name="module_installer_success">Success. The application will restart in 5 seconds</string>
+ <string name="module_installer_title">Download and install kernel module</string>
+ <string name="module_installer_working">Downloading and installing…</string>
+ <string name="module_installer_error">Something went wrong. Please try again</string>
+ <string name="mtu">MTU</string>
+ <string name="name">Name</string>
+ <string name="no_config_error">Trying to bring up a tunnel with no config</string>
+ <string name="no_configs_error">No configurations found</string>
+ <string name="no_tunnels_error">No tunnels exist</string>
+ <string name="parse_error_generic">string</string>
+ <string name="parse_error_inet_address">IP address</string>
+ <string name="parse_error_inet_endpoint">endpoint</string>
+ <string name="parse_error_inet_network">IP network</string>
+ <string name="parse_error_integer">number</string>
+ <string name="parse_error_reason">Cannot parse %1$s “%2$s”</string>
+ <string name="peer">Peer</string>
+ <string name="permission_description">Allows an app to control WireGuard tunnels. Apps with this permission may enable and disable WireGuard tunnels at will, potentially misdirecting Internet traffic.</string>
+ <string name="permission_label">control WireGuard tunnels</string>
+ <string name="persistent_keepalive">Persistent keepalive</string>
+ <string name="pre_shared_key">Pre-shared key</string>
+ <string name="private_key">Private key</string>
+ <string name="public_key">Public key</string>
+ <string name="public_key_description">Public key</string>
+ <string name="qr_code_hint">Tip: generate with `qrencode -t ansiutf8 &lt; tunnel.conf`.</string>
+ <string name="restore_on_boot_summary">Bring up previously-enabled tunnels on boot</string>
+ <string name="restore_on_boot_title">Restore on boot</string>
+ <string name="save">Save</string>
+ <string name="select_all">Select all</string>
+ <string name="set_exclusions">Set Exclusions</string>
+ <string name="settings">Settings</string>
+ <string name="shell_exit_status_read_error">Shell cannot read exit status</string>
+ <string name="shell_marker_count_error">Shell expected 4 markers, received %d</string>
+ <string name="shell_start_error">Shell failed to start: %d</string>
+ <string name="toggle_error">Error toggling WireGuard tunnel: %s</string>
+ <string name="tools_installer_already">wg and wg-quick are already installed</string>
+ <string name="tools_installer_failure">Unable to install command-line tools (no root?)</string>
+ <string name="tools_installer_initial">Install optional tools for scripting</string>
+ <string name="tools_installer_initial_magisk">Install optional tools for scripting as Magisk module</string>
+ <string name="tools_installer_initial_system">Install optional tools for scripting into the system partition</string>
+ <string name="tools_installer_success_magisk">wg and wg-quick installed as a Magisk module (reboot required)</string>
+ <string name="tools_installer_success_system">wg and wg-quick installed into the system partition</string>
+ <string name="tools_installer_title">Install command line tools</string>
+ <string name="tools_installer_working">Installing wg and wg-quick</string>
+ <string name="tools_unavailable_error">Required tools unavailable</string>
+ <string name="transfer">Transfer</string>
+ <string name="transfer_rx_tx">rx: %1$s, tx: %2$s</string>
+ <string name="transfer_bytes">%d B</string>
+ <string name="transfer_kibibytes">%.2f KiB</string>
+ <string name="transfer_mibibytes">%.2f MiB</string>
+ <string name="transfer_gibibytes">%.2f GiB</string>
+ <string name="transfer_tibibytes">%.2f TiB</string>
+ <string name="tun_create_error">Unable to create tun device</string>
+ <string name="tunnel_config_error">Unable to configure tunnel (wg-quick returned %d)</string>
+ <string name="tunnel_create_error">Unable to create tunnel: %s</string>
+ <string name="tunnel_create_success">Successfully created tunnel “%s”</string>
+ <string name="tunnel_error_already_exists">Tunnel “%s” already exists</string>
+ <string name="tunnel_error_invalid_name">Invalid name</string>
+ <string name="tunnel_list_placeholder">Add a tunnel using the blue button</string>
+ <string name="tunnel_name">Tunnel Name</string>
+ <string name="tunnel_on_error">Unable to turn tunnel on (wgTurnOn returned %d)</string>
+ <string name="tunnel_rename_error">Unable to rename tunnel: %s</string>
+ <string name="tunnel_rename_success">Successfully renamed tunnel to “%s”</string>
+ <string name="type_name_go_userspace">Go userspace</string>
+ <string name="type_name_kernel_module">Kernel module</string>
+ <string name="unknown_error">Unknown error</string>
+ <string name="version_summary">%1$s backend v%2$s</string>
+ <string name="version_summary_checking">Checking %s backend version</string>
+ <string name="version_summary_unknown">Unknown %s version</string>
+ <string name="version_title">WireGuard for Android v%s</string>
+ <string name="vpn_not_authorized_error">VPN service not authorized by user</string>
+ <string name="vpn_start_error">Unable to start Android VPN service</string>
+ <string name="zip_export_error">Unable to export tunnels: %s</string>
+ <string name="zip_export_success">Saved to “%s”</string>
+ <string name="zip_export_summary">Zip file will be saved to downloads folder</string>
+ <string name="zip_export_title">Export tunnels to zip file</string>
+ <string name="key_length_error">Incorrect key length</string>
+ <string name="key_contents_error">Bad characters in key</string>
+</resources>
diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml
new file mode 100644
index 00000000..f5af8bce
--- /dev/null
+++ b/ui/src/main/res/values/styles.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <style name="AppTheme" parent="Theme.MaterialComponents.DayNight">
+ <item name="colorPrimary">@color/primary_color</item>
+ <item name="colorOnPrimary">@color/color_control_normal</item>
+ <item name="colorPrimaryDark">@color/primary_color</item>
+ <item name="colorPrimaryVariant">@color/primary_light_color</item>
+ <item name="colorSecondary">@color/secondary_color</item>
+ <item name="colorOnSecondary">@color/secondary_text_color</item>
+ <item name="colorSurface">@color/primary_color</item>
+ <item name="colorOnSurface">@color/color_control_normal</item>
+ <item name="colorBackground">@color/primary_color</item>
+ <item name="colorMultiselectActiveBackground">@color/list_multiselect_background</item>
+ <item name="colorControlNormal">@color/color_control_normal</item>
+ <item name="elevationOverlayColor">@color/primary_light_color</item>
+ <item name="elevationOverlayEnabled">true</item>
+ <item name="android:statusBarColor">@color/status_bar_color</item>
+ <item name="android:windowBackground">@color/primary_color</item>
+ <item name="alertDialogTheme">@style/AppTheme.Dialog</item>
+ <item name="materialAlertDialogTheme">@style/AppTheme.Dialog</item>
+ <item name="actionBarPopupTheme">@style/ThemeOverlay.MaterialComponents.ActionBar</item>
+ </style>
+
+ <style name="AppTheme.Dialog" parent="Theme.MaterialComponents.DayNight.Dialog.Alert">
+ <item name="colorPrimary">@color/secondary_color</item>
+ <item name="colorSecondary">@color/secondary_color</item>
+ <item name="android:windowBackground">?attr/colorBackground</item>
+ </style>
+
+ <style name="BottomSheetDialogTheme" parent="ThemeOverlay.MaterialComponents.BottomSheetDialog">
+ <item name="android:windowIsFloating">false</item>
+ <item name="android:navigationBarColor">?attr/colorBackground</item>
+ <item name="android:statusBarColor">@android:color/transparent</item>
+ <item name="android:windowTranslucentNavigation">false</item>
+ <item name="android:windowIsTranslucent">false</item>
+ <item name="android:backgroundDimEnabled">true</item>
+ <item name="android:backgroundDimAmount">0.5</item>
+ <item name="android:windowTranslucentStatus">false</item>
+ <item name="android:colorBackground">@android:color/transparent</item>
+ </style>
+
+ <style name="NoBackgroundTheme" parent="AppTheme">
+ <item name="android:windowIsTranslucent">true</item>
+ <item name="android:windowContentOverlay">@null</item>
+ <item name="android:windowNoTitle">true</item>
+ <item name="android:windowActionBar">false</item>
+ <item name="android:windowBackground">@android:color/transparent</item>
+ <item name="android:background">@android:color/transparent</item>
+ <item name="colorPrimaryDark">@android:color/transparent</item>
+ <item name="android:backgroundDimEnabled">true</item>
+ <item name="android:windowEnterAnimation">@android:anim/fade_in</item>
+ <item name="android:windowExitAnimation">@android:anim/fade_out</item>
+ </style>
+
+</resources>
diff --git a/ui/src/main/res/xml/preferences.xml b/ui/src/main/res/xml/preferences.xml
new file mode 100644
index 00000000..9c09ae89
--- /dev/null
+++ b/ui/src/main/res/xml/preferences.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
+ <com.wireguard.android.preference.VersionPreference android:icon="@mipmap/ic_launcher" />
+ <CheckBoxPreference
+ android:defaultValue="false"
+ android:key="restore_on_boot"
+ android:summary="@string/restore_on_boot_summary"
+ android:title="@string/restore_on_boot_title" />
+ <com.wireguard.android.preference.ModuleDownloaderPreference android:key="module_downloader" />
+ <com.wireguard.android.preference.ToolsInstallerPreference android:key="tools_installer" />
+ <com.wireguard.android.preference.ZipExporterPreference />
+ <com.wireguard.android.preference.LogExporterPreference />
+ <CheckBoxPreference
+ android:defaultValue="false"
+ android:key="dark_theme"
+ android:summaryOff="@string/dark_theme_summary_off"
+ android:summaryOn="@string/dark_theme_summary_on"
+ android:title="@string/dark_theme_title" />
+</androidx.preference.PreferenceScreen>