From db901828a816c3413abc81d4f69f82bea330bb26 Mon Sep 17 00:00:00 2001 From: Fr4nz D13trich Date: Tue, 10 Feb 2026 16:31:45 +0100 Subject: [PATCH] Repo cloned --- .gitignore | 18 + .gitmodules | 6 + .idea/codeStyles/Project.xml | 474 ++++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/copyright/Default.xml | 6 + .idea/copyright/profiles_settings.xml | 3 + .idea/inspectionProfiles/Default.xml | 527 ++++ .../inspectionProfiles/profiles_settings.xml | 6 + COPYING | 202 ++ README.md | 41 +- build.gradle.kts | 13 + gradle.properties | 49 + gradle/libs.versions.toml | 29 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 248 ++ settings.gradle.kts | 22 + sync-crowdin.sh | 10 + tunnel/build.gradle.kts | 143 + tunnel/src/main/AndroidManifest.xml | 18 + .../wireguard/android/backend/Backend.java | 89 + .../android/backend/BackendException.java | 61 + .../wireguard/android/backend/GoBackend.java | 429 +++ .../wireguard/android/backend/Statistics.java | 102 + .../com/wireguard/android/backend/Tunnel.java | 57 + .../android/backend/WgQuickBackend.java | 206 ++ .../com/wireguard/android/util/RootShell.java | 222 ++ .../android/util/SharedLibraryLoader.java | 95 + .../android/util/ToolsInstaller.java | 202 ++ .../java/com/wireguard/config/Attribute.java | 60 + .../wireguard/config/BadConfigException.java | 120 + .../java/com/wireguard/config/Config.java | 223 ++ .../com/wireguard/config/InetAddresses.java | 86 + .../com/wireguard/config/InetEndpoint.java | 126 + .../com/wireguard/config/InetNetwork.java | 79 + .../java/com/wireguard/config/Interface.java | 423 +++ .../com/wireguard/config/ParseException.java | 48 + .../main/java/com/wireguard/config/Peer.java | 307 ++ .../java/com/wireguard/crypto/Curve25519.java | 500 ++++ .../main/java/com/wireguard/crypto/Key.java | 290 ++ .../wireguard/crypto/KeyFormatException.java | 37 + .../java/com/wireguard/crypto/KeyPair.java | 54 + .../com/wireguard/util/NonNullForAll.java | 29 + .../config/BadConfigExceptionTest.java | 173 ++ .../java/com/wireguard/config/ConfigTest.java | 49 + tunnel/src/test/resources/broken.conf | 9 + tunnel/src/test/resources/invalid-key.conf | 9 + tunnel/src/test/resources/invalid-number.conf | 9 + tunnel/src/test/resources/invalid-value.conf | 9 + .../src/test/resources/missing-attribute.conf | 8 + .../src/test/resources/missing-section.conf | 5 + tunnel/src/test/resources/syntax-error.conf | 9 + .../src/test/resources/unknown-attribute.conf | 9 + .../src/test/resources/unknown-section.conf | 9 + tunnel/src/test/resources/working.conf | 9 + tunnel/tools/CMakeLists.txt | 44 + tunnel/tools/libwg-go/.gitignore | 1 + tunnel/tools/libwg-go/Makefile | 52 + tunnel/tools/libwg-go/api-android.go | 227 ++ tunnel/tools/libwg-go/go.mod | 14 + tunnel/tools/libwg-go/go.sum | 16 + .../goruntime-boottime-over-monotonic.diff | 171 ++ tunnel/tools/libwg-go/jni.c | 71 + tunnel/tools/ndk-compat/compat.c | 25 + tunnel/tools/ndk-compat/compat.h | 10 + ui/build.gradle.kts | 93 + ui/proguard-android-optimize.txt | 35 + ui/sampledata/interface_names.json | 34 + ui/src/debug/res/values/strings.xml | 4 + ui/src/googleplay/AndroidManifest.xml | 11 + ui/src/main/AndroidManifest.xml | 169 ++ .../java/com/wireguard/android/Application.kt | 157 ++ .../wireguard/android/BootShutdownReceiver.kt | 34 + .../com/wireguard/android/QuickTileService.kt | 203 ++ .../android/activity/BaseActivity.kt | 96 + .../android/activity/LogViewerActivity.kt | 382 +++ .../android/activity/MainActivity.kt | 129 + .../android/activity/SettingsActivity.kt | 113 + .../android/activity/TunnelCreatorActivity.kt | 24 + .../android/activity/TunnelToggleActivity.kt | 69 + .../android/activity/TvMainActivity.kt | 431 +++ .../android/configStore/ConfigStore.kt | 68 + .../android/configStore/FileConfigStore.kt | 82 + .../android/databinding/BindingAdapters.kt | 194 ++ .../android/databinding/ItemChangeListener.kt | 122 + .../wireguard/android/databinding/Keyed.kt | 12 + .../databinding/ObservableKeyedArrayList.kt | 32 + .../ObservableKeyedRecyclerViewAdapter.kt | 106 + .../ObservableSortedKeyedArrayList.kt | 82 + .../android/fragment/AddTunnelsSheet.kt | 104 + .../android/fragment/AppListDialogFragment.kt | 170 ++ .../android/fragment/BaseFragment.kt | 114 + .../fragment/ConfigNamingDialogFragment.kt | 82 + .../android/fragment/TunnelDetailFragment.kt | 150 + .../android/fragment/TunnelEditorFragment.kt | 333 +++ .../android/fragment/TunnelListFragment.kt | 342 +++ .../android/model/ApplicationData.kt | 22 + .../android/model/ObservableTunnel.kt | 146 + .../android/model/TunnelComparator.kt | 61 + .../wireguard/android/model/TunnelManager.kt | 254 ++ .../android/preference/DonatePreference.kt | 43 + .../KernelModuleEnablerPreference.kt | 88 + .../PreferencesPreferenceDataStore.kt | 135 + .../android/preference/QuickTilePreference.kt | 50 + .../preference/ToolsInstallerPreference.kt | 79 + .../android/preference/VersionPreference.kt | 63 + .../preference/ZipExporterPreference.kt | 113 + .../wireguard/android/updater/Ed25519.java | 2507 +++++++++++++++++ .../android/updater/SnackbarUpdateShower.kt | 173 ++ .../com/wireguard/android/updater/Updater.kt | 460 +++ .../com/wireguard/android/util/AdminKnobs.kt | 17 + .../android/util/BiometricAuthenticator.kt | 80 + .../wireguard/android/util/ClipboardUtils.kt | 37 + .../android/util/DownloadsFileSaver.kt | 104 + .../wireguard/android/util/ErrorMessages.kt | 158 ++ .../com/wireguard/android/util/Extensions.kt | 31 + .../android/util/QrCodeFromFileScanner.kt | 84 + .../android/util/QuantityFormatter.kt | 63 + .../wireguard/android/util/TunnelImporter.kt | 152 + .../com/wireguard/android/util/UserKnobs.kt | 121 + .../android/viewmodel/ConfigProxy.kt | 86 + .../android/viewmodel/InterfaceProxy.kt | 142 + .../wireguard/android/viewmodel/PeerProxy.kt | 294 ++ .../android/widget/KeyInputFilter.kt | 49 + .../widget/MultiselectableRelativeLayout.kt | 49 + .../android/widget/NameInputFilter.kt | 48 + .../wireguard/android/widget/SlashDrawable.kt | 175 ++ .../wireguard/android/widget/ToggleSwitch.kt | 44 + .../wireguard/android/widget/TvCardView.kt | 44 + ui/src/main/res/anim/scale_down.xml | 15 + ui/src/main/res/anim/scale_up.xml | 15 + ui/src/main/res/color/tv_list_item_tint.xml | 10 + .../main/res/drawable/ic_action_add_white.xml | 14 + ui/src/main/res/drawable/ic_action_delete.xml | 14 + ui/src/main/res/drawable/ic_action_edit.xml | 14 + .../main/res/drawable/ic_action_generate.xml | 14 + ui/src/main/res/drawable/ic_action_open.xml | 14 + ui/src/main/res/drawable/ic_action_save.xml | 14 + .../res/drawable/ic_action_scan_qr_code.xml | 14 + .../res/drawable/ic_action_select_all.xml | 14 + .../res/drawable/ic_action_share_white.xml | 14 + ui/src/main/res/drawable/ic_arrow_back.xml | 14 + .../res/drawable/ic_launcher_foreground.xml | 38 + ui/src/main/res/drawable/ic_settings.xml | 14 + ui/src/main/res/drawable/ic_tile.xml | 28 + .../res/drawable/list_item_background.xml | 20 + ui/src/main/res/drawable/tv_logo_banner.xml | 210 ++ .../main/res/layout-sw600dp/main_activity.xml | 37 + .../res/layout/add_tunnels_bottom_sheet.xml | 81 + .../res/layout/app_list_dialog_fragment.xml | 70 + ui/src/main/res/layout/app_list_item.xml | 64 + .../layout/config_naming_dialog_fragment.xml | 39 + .../main/res/layout/log_viewer_activity.xml | 28 + ui/src/main/res/layout/log_viewer_entry.xml | 32 + ui/src/main/res/layout/main_activity.xml | 19 + .../res/layout/tunnel_creator_activity.xml | 18 + .../res/layout/tunnel_detail_fragment.xml | 324 +++ ui/src/main/res/layout/tunnel_detail_peer.xml | 230 ++ .../res/layout/tunnel_editor_fragment.xml | 295 ++ ui/src/main/res/layout/tunnel_editor_peer.xml | 203 ++ .../main/res/layout/tunnel_list_fragment.xml | 85 + ui/src/main/res/layout/tunnel_list_item.xml | 66 + ui/src/main/res/layout/tv_activity.xml | 157 ++ ui/src/main/res/layout/tv_file_list_item.xml | 47 + .../main/res/layout/tv_tunnel_list_item.xml | 85 + ui/src/main/res/menu/config_editor.xml | 13 + ui/src/main/res/menu/log_viewer.xml | 12 + ui/src/main/res/menu/main_activity.xml | 14 + ui/src/main/res/menu/tunnel_detail.xml | 13 + .../main/res/menu/tunnel_list_action_mode.xml | 19 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 9 + ui/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4990 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5988 bytes ui/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2598 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3132 bytes ui/src/main/res/mipmap-xhdpi/banner.png | Bin 0 -> 8645 bytes ui/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6226 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7946 bytes ui/src/main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 11777 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 15449 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 16030 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 20741 bytes ui/src/main/res/resources.properties | 1 + ui/src/main/res/values-ar-rSA/strings.xml | 311 ++ ui/src/main/res/values-ca-rES/strings.xml | 259 ++ ui/src/main/res/values-cs-rCZ/strings.xml | 285 ++ ui/src/main/res/values-da-rDK/strings.xml | 259 ++ ui/src/main/res/values-de/strings.xml | 259 ++ ui/src/main/res/values-el-rGR/strings.xml | 144 + ui/src/main/res/values-es-rES/strings.xml | 259 ++ ui/src/main/res/values-et-rEE/strings.xml | 257 ++ ui/src/main/res/values-fa-rIR/strings.xml | 261 ++ ui/src/main/res/values-fi-rFI/strings.xml | 90 + ui/src/main/res/values-fr/strings.xml | 259 ++ ui/src/main/res/values-hi-rIN/strings.xml | 219 ++ ui/src/main/res/values-hi/strings.xml | 176 ++ ui/src/main/res/values-hu-rHU/strings.xml | 106 + ui/src/main/res/values-in/strings.xml | 246 ++ ui/src/main/res/values-it/strings.xml | 259 ++ ui/src/main/res/values-ja/strings.xml | 246 ++ ui/src/main/res/values-ko-rKR/strings.xml | 246 ++ ui/src/main/res/values-night/bools.xml | 8 + .../res/values-night/logviewer_colors.xml | 10 + ui/src/main/res/values-night/themes.xml | 34 + ui/src/main/res/values-nl-rNL/strings.xml | 251 ++ ui/src/main/res/values-no-rNO/strings.xml | 259 ++ ui/src/main/res/values-pa-rIN/strings.xml | 259 ++ ui/src/main/res/values-pl-rPL/strings.xml | 285 ++ ui/src/main/res/values-pt-rBR/strings.xml | 259 ++ ui/src/main/res/values-pt-rPT/strings.xml | 234 ++ ui/src/main/res/values-ro-rRO/strings.xml | 259 ++ ui/src/main/res/values-ru/strings.xml | 285 ++ ui/src/main/res/values-si-rLK/strings.xml | 204 ++ ui/src/main/res/values-sk-rSK/strings.xml | 158 ++ ui/src/main/res/values-sl/strings.xml | 285 ++ ui/src/main/res/values-sv-rSE/strings.xml | 259 ++ ui/src/main/res/values-tr-rTR/strings.xml | 259 ++ ui/src/main/res/values-uk-rUA/strings.xml | 285 ++ ui/src/main/res/values-v27/styles.xml | 12 + ui/src/main/res/values-vi-rVN/strings.xml | 139 + ui/src/main/res/values-zh-rCN/strings.xml | 246 ++ ui/src/main/res/values-zh-rTW/strings.xml | 246 ++ ui/src/main/res/values/attrs.xml | 13 + ui/src/main/res/values/bools.xml | 8 + ui/src/main/res/values/colors.xml | 67 + ui/src/main/res/values/dimens.xml | 12 + .../res/values/ic_launcher_background.xml | 7 + ui/src/main/res/values/ids.xml | 7 + ui/src/main/res/values/logviewer_colors.xml | 10 + ui/src/main/res/values/strings.xml | 263 ++ ui/src/main/res/values/styles.xml | 49 + ui/src/main/res/values/themes.xml | 34 + ui/src/main/res/xml/app_restrictions.xml | 12 + ui/src/main/res/xml/preferences.xml | 48 + 235 files changed, 27925 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/copyright/Default.xml create mode 100644 .idea/copyright/profiles_settings.xml create mode 100644 .idea/inspectionProfiles/Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 COPYING create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 settings.gradle.kts create mode 100755 sync-crowdin.sh create mode 100644 tunnel/build.gradle.kts create mode 100644 tunnel/src/main/AndroidManifest.xml create mode 100644 tunnel/src/main/java/com/wireguard/android/backend/Backend.java create mode 100644 tunnel/src/main/java/com/wireguard/android/backend/BackendException.java create mode 100644 tunnel/src/main/java/com/wireguard/android/backend/GoBackend.java create mode 100644 tunnel/src/main/java/com/wireguard/android/backend/Statistics.java create mode 100644 tunnel/src/main/java/com/wireguard/android/backend/Tunnel.java create mode 100644 tunnel/src/main/java/com/wireguard/android/backend/WgQuickBackend.java create mode 100644 tunnel/src/main/java/com/wireguard/android/util/RootShell.java create mode 100644 tunnel/src/main/java/com/wireguard/android/util/SharedLibraryLoader.java create mode 100644 tunnel/src/main/java/com/wireguard/android/util/ToolsInstaller.java create mode 100644 tunnel/src/main/java/com/wireguard/config/Attribute.java create mode 100644 tunnel/src/main/java/com/wireguard/config/BadConfigException.java create mode 100644 tunnel/src/main/java/com/wireguard/config/Config.java create mode 100644 tunnel/src/main/java/com/wireguard/config/InetAddresses.java create mode 100644 tunnel/src/main/java/com/wireguard/config/InetEndpoint.java create mode 100644 tunnel/src/main/java/com/wireguard/config/InetNetwork.java create mode 100644 tunnel/src/main/java/com/wireguard/config/Interface.java create mode 100644 tunnel/src/main/java/com/wireguard/config/ParseException.java create mode 100644 tunnel/src/main/java/com/wireguard/config/Peer.java create mode 100644 tunnel/src/main/java/com/wireguard/crypto/Curve25519.java create mode 100644 tunnel/src/main/java/com/wireguard/crypto/Key.java create mode 100644 tunnel/src/main/java/com/wireguard/crypto/KeyFormatException.java create mode 100644 tunnel/src/main/java/com/wireguard/crypto/KeyPair.java create mode 100644 tunnel/src/main/java/com/wireguard/util/NonNullForAll.java create mode 100644 tunnel/src/test/java/com/wireguard/config/BadConfigExceptionTest.java create mode 100644 tunnel/src/test/java/com/wireguard/config/ConfigTest.java create mode 100644 tunnel/src/test/resources/broken.conf create mode 100644 tunnel/src/test/resources/invalid-key.conf create mode 100644 tunnel/src/test/resources/invalid-number.conf create mode 100644 tunnel/src/test/resources/invalid-value.conf create mode 100644 tunnel/src/test/resources/missing-attribute.conf create mode 100644 tunnel/src/test/resources/missing-section.conf create mode 100644 tunnel/src/test/resources/syntax-error.conf create mode 100644 tunnel/src/test/resources/unknown-attribute.conf create mode 100644 tunnel/src/test/resources/unknown-section.conf create mode 100644 tunnel/src/test/resources/working.conf create mode 100644 tunnel/tools/CMakeLists.txt create mode 100644 tunnel/tools/libwg-go/.gitignore create mode 100644 tunnel/tools/libwg-go/Makefile create mode 100644 tunnel/tools/libwg-go/api-android.go create mode 100644 tunnel/tools/libwg-go/go.mod create mode 100644 tunnel/tools/libwg-go/go.sum create mode 100644 tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff create mode 100644 tunnel/tools/libwg-go/jni.c create mode 100644 tunnel/tools/ndk-compat/compat.c create mode 100644 tunnel/tools/ndk-compat/compat.h create mode 100644 ui/build.gradle.kts create mode 100644 ui/proguard-android-optimize.txt create mode 100644 ui/sampledata/interface_names.json create mode 100644 ui/src/debug/res/values/strings.xml create mode 100644 ui/src/googleplay/AndroidManifest.xml create mode 100644 ui/src/main/AndroidManifest.xml create mode 100644 ui/src/main/java/com/wireguard/android/Application.kt create mode 100644 ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt create mode 100644 ui/src/main/java/com/wireguard/android/QuickTileService.kt create mode 100644 ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt create mode 100644 ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt create mode 100644 ui/src/main/java/com/wireguard/android/activity/MainActivity.kt create mode 100644 ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt create mode 100644 ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt create mode 100644 ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt create mode 100644 ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt create mode 100644 ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt create mode 100644 ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt create mode 100644 ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt create mode 100644 ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt create mode 100644 ui/src/main/java/com/wireguard/android/databinding/Keyed.kt create mode 100644 ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt create mode 100644 ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt create mode 100644 ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt create mode 100644 ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt create mode 100644 ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt create mode 100644 ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt create mode 100644 ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt create mode 100644 ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt create mode 100644 ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt create mode 100644 ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt create mode 100644 ui/src/main/java/com/wireguard/android/model/ApplicationData.kt create mode 100644 ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt create mode 100644 ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt create mode 100644 ui/src/main/java/com/wireguard/android/model/TunnelManager.kt create mode 100644 ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt create mode 100644 ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt create mode 100644 ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt create mode 100644 ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt create mode 100644 ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt create mode 100644 ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt create mode 100644 ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt create mode 100644 ui/src/main/java/com/wireguard/android/updater/Ed25519.java create mode 100644 ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt create mode 100644 ui/src/main/java/com/wireguard/android/updater/Updater.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/Extensions.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/QuantityFormatter.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt create mode 100644 ui/src/main/java/com/wireguard/android/util/UserKnobs.kt create mode 100644 ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.kt create mode 100644 ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.kt create mode 100644 ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt create mode 100644 ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.kt create mode 100644 ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.kt create mode 100644 ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt create mode 100644 ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt create mode 100644 ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.kt create mode 100644 ui/src/main/java/com/wireguard/android/widget/TvCardView.kt create mode 100644 ui/src/main/res/anim/scale_down.xml create mode 100644 ui/src/main/res/anim/scale_up.xml create mode 100644 ui/src/main/res/color/tv_list_item_tint.xml create mode 100644 ui/src/main/res/drawable/ic_action_add_white.xml create mode 100644 ui/src/main/res/drawable/ic_action_delete.xml create mode 100644 ui/src/main/res/drawable/ic_action_edit.xml create mode 100644 ui/src/main/res/drawable/ic_action_generate.xml create mode 100644 ui/src/main/res/drawable/ic_action_open.xml create mode 100644 ui/src/main/res/drawable/ic_action_save.xml create mode 100644 ui/src/main/res/drawable/ic_action_scan_qr_code.xml create mode 100644 ui/src/main/res/drawable/ic_action_select_all.xml create mode 100644 ui/src/main/res/drawable/ic_action_share_white.xml create mode 100644 ui/src/main/res/drawable/ic_arrow_back.xml create mode 100644 ui/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 ui/src/main/res/drawable/ic_settings.xml create mode 100644 ui/src/main/res/drawable/ic_tile.xml create mode 100644 ui/src/main/res/drawable/list_item_background.xml create mode 100644 ui/src/main/res/drawable/tv_logo_banner.xml create mode 100644 ui/src/main/res/layout-sw600dp/main_activity.xml create mode 100644 ui/src/main/res/layout/add_tunnels_bottom_sheet.xml create mode 100644 ui/src/main/res/layout/app_list_dialog_fragment.xml create mode 100644 ui/src/main/res/layout/app_list_item.xml create mode 100644 ui/src/main/res/layout/config_naming_dialog_fragment.xml create mode 100644 ui/src/main/res/layout/log_viewer_activity.xml create mode 100644 ui/src/main/res/layout/log_viewer_entry.xml create mode 100644 ui/src/main/res/layout/main_activity.xml create mode 100644 ui/src/main/res/layout/tunnel_creator_activity.xml create mode 100644 ui/src/main/res/layout/tunnel_detail_fragment.xml create mode 100644 ui/src/main/res/layout/tunnel_detail_peer.xml create mode 100644 ui/src/main/res/layout/tunnel_editor_fragment.xml create mode 100644 ui/src/main/res/layout/tunnel_editor_peer.xml create mode 100644 ui/src/main/res/layout/tunnel_list_fragment.xml create mode 100644 ui/src/main/res/layout/tunnel_list_item.xml create mode 100644 ui/src/main/res/layout/tv_activity.xml create mode 100644 ui/src/main/res/layout/tv_file_list_item.xml create mode 100644 ui/src/main/res/layout/tv_tunnel_list_item.xml create mode 100644 ui/src/main/res/menu/config_editor.xml create mode 100644 ui/src/main/res/menu/log_viewer.xml create mode 100644 ui/src/main/res/menu/main_activity.xml create mode 100644 ui/src/main/res/menu/tunnel_detail.xml create mode 100644 ui/src/main/res/menu/tunnel_list_action_mode.xml create mode 100644 ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 ui/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 ui/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 ui/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 ui/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 ui/src/main/res/mipmap-xhdpi/banner.png create mode 100644 ui/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 ui/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 ui/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 ui/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 ui/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 ui/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 ui/src/main/res/resources.properties create mode 100644 ui/src/main/res/values-ar-rSA/strings.xml create mode 100644 ui/src/main/res/values-ca-rES/strings.xml create mode 100644 ui/src/main/res/values-cs-rCZ/strings.xml create mode 100644 ui/src/main/res/values-da-rDK/strings.xml create mode 100644 ui/src/main/res/values-de/strings.xml create mode 100644 ui/src/main/res/values-el-rGR/strings.xml create mode 100644 ui/src/main/res/values-es-rES/strings.xml create mode 100644 ui/src/main/res/values-et-rEE/strings.xml create mode 100644 ui/src/main/res/values-fa-rIR/strings.xml create mode 100644 ui/src/main/res/values-fi-rFI/strings.xml create mode 100644 ui/src/main/res/values-fr/strings.xml create mode 100644 ui/src/main/res/values-hi-rIN/strings.xml create mode 100644 ui/src/main/res/values-hi/strings.xml create mode 100644 ui/src/main/res/values-hu-rHU/strings.xml create mode 100644 ui/src/main/res/values-in/strings.xml create mode 100644 ui/src/main/res/values-it/strings.xml create mode 100644 ui/src/main/res/values-ja/strings.xml create mode 100644 ui/src/main/res/values-ko-rKR/strings.xml create mode 100644 ui/src/main/res/values-night/bools.xml create mode 100644 ui/src/main/res/values-night/logviewer_colors.xml create mode 100644 ui/src/main/res/values-night/themes.xml create mode 100644 ui/src/main/res/values-nl-rNL/strings.xml create mode 100644 ui/src/main/res/values-no-rNO/strings.xml create mode 100644 ui/src/main/res/values-pa-rIN/strings.xml create mode 100644 ui/src/main/res/values-pl-rPL/strings.xml create mode 100644 ui/src/main/res/values-pt-rBR/strings.xml create mode 100644 ui/src/main/res/values-pt-rPT/strings.xml create mode 100644 ui/src/main/res/values-ro-rRO/strings.xml create mode 100644 ui/src/main/res/values-ru/strings.xml create mode 100644 ui/src/main/res/values-si-rLK/strings.xml create mode 100644 ui/src/main/res/values-sk-rSK/strings.xml create mode 100644 ui/src/main/res/values-sl/strings.xml create mode 100644 ui/src/main/res/values-sv-rSE/strings.xml create mode 100644 ui/src/main/res/values-tr-rTR/strings.xml create mode 100644 ui/src/main/res/values-uk-rUA/strings.xml create mode 100644 ui/src/main/res/values-v27/styles.xml create mode 100644 ui/src/main/res/values-vi-rVN/strings.xml create mode 100644 ui/src/main/res/values-zh-rCN/strings.xml create mode 100644 ui/src/main/res/values-zh-rTW/strings.xml create mode 100644 ui/src/main/res/values/attrs.xml create mode 100644 ui/src/main/res/values/bools.xml create mode 100644 ui/src/main/res/values/colors.xml create mode 100644 ui/src/main/res/values/dimens.xml create mode 100644 ui/src/main/res/values/ic_launcher_background.xml create mode 100644 ui/src/main/res/values/ids.xml create mode 100644 ui/src/main/res/values/logviewer_colors.xml create mode 100644 ui/src/main/res/values/strings.xml create mode 100644 ui/src/main/res/values/styles.xml create mode 100644 ui/src/main/res/values/themes.xml create mode 100644 ui/src/main/res/xml/app_restrictions.xml create mode 100644 ui/src/main/res/xml/preferences.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ab9d46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +/.gradle/ +/.idea/*.xml +/.idea/caches/ +/.idea/dictionaries/ +/.idea/libraries/ +/captures/ +/local.properties +.DS_Store +.cxx/ +Thumbs.db +build/ +*.apk +*.class +*.dex +*.iml +*.jks +gradlew.bat +maint/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e649c86 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "tunnel/tools/wireguard-tools"] + path = tunnel/tools/wireguard-tools + url = https://git.zx2c4.com/wireguard-tools +[submodule "tunnel/tools/elf-cleaner"] + path = tunnel/tools/elf-cleaner + url = https://github.com/termux/termux-elf-cleaner diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..076a7f0 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,474 @@ + + + + + + + + + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/Default.xml b/.idea/copyright/Default.xml new file mode 100644 index 0000000..db8533c --- /dev/null +++ b/.idea/copyright/Default.xml @@ -0,0 +1,6 @@ + + + + diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000..eb83953 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,3 @@ + + + diff --git a/.idea/inspectionProfiles/Default.xml b/.idea/inspectionProfiles/Default.xml new file mode 100644 index 0000000..dd76635 --- /dev/null +++ b/.idea/inspectionProfiles/Default.xml @@ -0,0 +1,527 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..cfcfda2 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/COPYING @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index ba3a493..eaffeee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,40 @@ -# wireguard +# Android GUI for [WireGuard](https://www.wireguard.com/) -VPN for Android \ No newline at end of file +**[Download from the Play Store](https://play.google.com/store/apps/details?id=com.wireguard.android)** + +This is an Android GUI for [WireGuard](https://www.wireguard.com/). It [opportunistically uses the kernel implementation](https://git.zx2c4.com/android_kernel_wireguard/about/), and falls back to using the non-root [userspace implementation](https://git.zx2c4.com/wireguard-go/about/). + +## Building + +``` +$ git clone --recurse-submodules https://git.zx2c4.com/wireguard-android +$ cd wireguard-android +$ ./gradlew assembleRelease +``` + +macOS users may need [flock(1)](https://github.com/discoteq/flock). + +## Embedding + +The tunnel library is [on Maven Central](https://search.maven.org/artifact/com.wireguard.android/tunnel), alongside [extensive class library documentation](https://javadoc.io/doc/com.wireguard.android/tunnel). + +``` +implementation 'com.wireguard.android:tunnel:$wireguardTunnelVersion' +``` + +The library makes use of Java 8 features, so be sure to support those in your gradle configuration with [desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring): + +``` +compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + coreLibraryDesugaringEnabled = true +} +dependencies { + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.3" +} +``` + +## Translating + +Please help us translate the app into several languages on [our translation platform](https://crowdin.com/project/WireGuard). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a589663 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.kapt) apply false +} + +tasks { + wrapper { + gradleVersion = "8.14" + distributionSha256Sum = "61ad310d3c7d3e5da131b76bbf22b5a4c0786e9d892dae8c1658d4b484de3caa" + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..c11b0b0 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,49 @@ +wireguardVersionCode=518 +wireguardVersionName=1.0.20260102 +wireguardPackageName=com.wireguard.android + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true +org.gradle.configureondemand=true +org.gradle.caching=true + +# Enable Kotlin incremental compilation +kotlin.incremental=true + +# Enable AndroidX support +android.useAndroidX=true + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# Turn off AP discovery in compile path to enable compile avoidance +kapt.include.compile.classpath=false + +# Experimental AGP flags +# Generate compile-time only R class for app modules. +android.enableAppCompileTimeRClass=true +# Keep AAPT2 daemons alive between incremental builds. +android.keepWorkerActionServicesBetweenBuilds=true +# Generate manifest class as a .class directly rather than a Java source file. +android.generateManifestClass=true + +# Default Android build features +# Disable resource values generation by default in libraries +android.defaults.buildfeatures.resvalues=false +# Disable shader compilation by default +android.defaults.buildfeatures.shaders=false +# Disable Android resource processing by default +android.library.defaults.buildfeatures.androidresources=false + +# Suppress warnings for some features that aren't yet stabilized +android.suppressUnsupportedOptionWarnings=android.keepWorkerActionServicesBetweenBuilds,\ + android.enableAppCompileTimeRClass,\ + android.suppressUnsupportedOptionWarnings + +# OSSRH sometimes struggles with slow deployments, so this makes Gradle +# more tolerant to those delays. +systemProp.org.gradle.internal.http.connectionTimeout=500000 +systemProp.org.gradle.internal.http.socketTimeout=500000 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..bdcf434 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,29 @@ +[versions] +agp = "8.13.2" +kotlin = "2.3.0" + +[libraries] +androidx-activity-ktx = "androidx.activity:activity-ktx:1.12.2" +androidx-annotation = "androidx.annotation:annotation:1.9.1" +androidx-appcompat = "androidx.appcompat:appcompat:1.7.1" +androidx-biometric = "androidx.biometric:biometric:1.1.0" +androidx-collection = "androidx.collection:collection:1.5.0" +androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.2.1" +androidx-coordinatorlayout = "androidx.coordinatorlayout:coordinatorlayout:1.3.0" +androidx-core-ktx = "androidx.core:core-ktx:1.17.0" +androidx-datastore-preferences = "androidx.datastore:datastore-preferences:1.2.0" +androidx-fragment-ktx = "androidx.fragment:fragment-ktx:1.8.9" +androidx-lifecycle-runtime-ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.10.0" +androidx-preference-ktx = "androidx.preference:preference-ktx:1.2.1" +desugarJdkLibs = "com.android.tools:desugar_jdk_libs:2.1.5" +google-material = "com.google.android.material:material:1.13.0" +jsr305 = "com.google.code.findbugs:jsr305:3.0.2" +junit = "junit:junit:4.13.2" +kotlinx-coroutines-android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2" +zxing-android-embedded = "com.journeyapps:zxing-android-embedded:4.3.0" + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6a38a8c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..66a112c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,22 @@ +@file:Suppress("UnstableApiUsage") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "wireguard-android" + +include(":tunnel") +include(":ui") diff --git a/sync-crowdin.sh b/sync-crowdin.sh new file mode 100755 index 0000000..e6c7155 --- /dev/null +++ b/sync-crowdin.sh @@ -0,0 +1,10 @@ +#!/bin/bash +if [[ $# -ne 1 ]]; then + echo "Download https://crowdin.com/backend/download/project/wireguard.zip and provide path as argument" + exit 1 +fi + +set -ex + +bsdtar -C ui/src/main/res -x -f "$1" --strip-components 5 wireguard-android +find ui/src/main/res -name strings.xml -exec bash -c '[[ $(xmllint --xpath "count(//resources/*)" {}) -ne 0 ]] || rm -rf "$(dirname {})"' \; diff --git a/tunnel/build.gradle.kts b/tunnel/build.gradle.kts new file mode 100644 index 0000000..26cda3d --- /dev/null +++ b/tunnel/build.gradle.kts @@ -0,0 +1,143 @@ +@file:Suppress("UnstableApiUsage") + +import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.gradle.api.tasks.bundling.Zip + +val pkg: String = providers.gradleProperty("wireguardPackageName").get() + +plugins { + alias(libs.plugins.android.library) + `maven-publish` + signing +} + +android { + compileSdk = 36 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + namespace = "${pkg}.tunnel" + defaultConfig { + minSdk = 24 + } + externalNativeBuild { + cmake { + path("tools/CMakeLists.txt") + } + } + testOptions.unitTests.all { + it.testLogging { events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) } + } + buildTypes { + all { + externalNativeBuild { + cmake { + targets("libwg-go.so", "libwg.so", "libwg-quick.so") + arguments("-DGRADLE_USER_HOME=${project.gradle.gradleUserHomeDir}") + arguments("-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON") + } + } + } + release { + externalNativeBuild { + cmake { + arguments("-DANDROID_PACKAGE_NAME=${pkg}") + } + } + } + debug { + externalNativeBuild { + cmake { + arguments("-DANDROID_PACKAGE_NAME=${pkg}.debug") + } + } + } + } + lint { + disable += "LongLogTag" + disable += "NewApi" + } + publishing { + singleVariant("release") { + withJavadocJar() + withSourcesJar() + } + } +} + +dependencies { + implementation(libs.androidx.annotation) + implementation(libs.androidx.collection) + compileOnly(libs.jsr305) + testImplementation(libs.junit) +} + +publishing { + publications { + register("release") { + groupId = pkg + artifactId = "tunnel" + version = providers.gradleProperty("wireguardVersionName").get() + afterEvaluate { + from(components["release"]) + } + pom { + name = "WireGuard Tunnel Library" + description = "Embeddable tunnel library for WireGuard for Android" + url = "https://www.wireguard.com/" + + licenses { + license { + name = "The Apache Software License, Version 2.0" + url = "http://www.apache.org/licenses/LICENSE-2.0.txt" + distribution = "repo" + } + } + scm { + connection = "scm:git:https://git.zx2c4.com/wireguard-android" + developerConnection = "scm:git:https://git.zx2c4.com/wireguard-android" + url = "https://git.zx2c4.com/wireguard-android" + } + developers { + organization { + name = "WireGuard" + url = "https://www.wireguard.com/" + } + developer { + name = "WireGuard" + email = "team@wireguard.com" + } + } + } + } + } + repositories { + maven { + name = "SonatypeUpload" + setUrl(layout.buildDirectory.dir("sonatype")) + } + } +} + +val releasePublication = publishing.publications.named("release").get() as MavenPublication +val mavenGroupPath = releasePublication.groupId.replace('.', '/') +val mavenArtifactId = releasePublication.artifactId +val mavenVersion = releasePublication.version + +tasks.register("zipReleasePublication") { + dependsOn(tasks.named("publishReleasePublicationToSonatypeUploadRepository")) + group = "distribution" + description = "Zips the release publication in Maven repository layout." + + val sourceDir = layout.buildDirectory.dir("sonatype/${mavenGroupPath}/${mavenArtifactId}/${mavenVersion}") + from(sourceDir) + archiveFileName.set("${mavenArtifactId}-${mavenVersion}-maven.zip") + destinationDirectory.set(layout.buildDirectory.dir("distributions")) + into("${mavenGroupPath}/${mavenArtifactId}/${mavenVersion}") +} + +signing { + useGpgCmd() + sign(publishing.publications) +} diff --git a/tunnel/src/main/AndroidManifest.xml b/tunnel/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7ac3b12 --- /dev/null +++ b/tunnel/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/tunnel/src/main/java/com/wireguard/android/backend/Backend.java b/tunnel/src/main/java/com/wireguard/android/backend/Backend.java new file mode 100644 index 0000000..ff2fb81 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/backend/Backend.java @@ -0,0 +1,89 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.backend; + +import com.wireguard.config.Config; +import com.wireguard.util.NonNullForAll; + +import java.util.Set; + +import androidx.annotation.Nullable; + +/** + * Interface for implementations of the WireGuard secure network tunnel. + */ + +@NonNullForAll +public interface Backend { + /** + * Enumerate names of currently-running tunnels. + * + * @return The set of running tunnel names. + */ + Set getRunningTunnelNames(); + + /** + * Get the state of a tunnel. + * + * @param tunnel The tunnel to examine the state of. + * @return The state of the tunnel. + * @throws Exception Exception raised when retrieving tunnel's state. + */ + Tunnel.State getState(Tunnel tunnel) throws Exception; + + /** + * Get statistics about traffic and errors on this tunnel. If the tunnel is not running, the + * statistics object will be filled with zero values. + * + * @param tunnel The tunnel to retrieve statistics for. + * @return The statistics for the tunnel. + * @throws Exception Exception raised when retrieving statistics. + */ + Statistics getStatistics(Tunnel tunnel) throws Exception; + + /** + * Determine version of underlying backend. + * + * @return The version of the backend. + * @throws Exception Exception raised while retrieving version. + */ + String getVersion() throws Exception; + + /** + * Determines whether the service is running in always-on VPN mode. + * In this mode the system ensures that the service is always running by restarting it when necessary, + * e.g. after reboot. + * + * @return A boolean indicating whether the service is running in always-on VPN mode. + * @throws Exception Exception raised while retrieving the always-on status. + */ + + boolean isAlwaysOn() throws Exception; + + /** + * Determines whether the service is running in always-on VPN lockdown mode. + * In this mode the system ensures that the service is always running and that the apps + * aren't allowed to bypass the VPN. + * + * @return A boolean indicating whether the service is running in always-on VPN lockdown mode. + * @throws Exception Exception raised while retrieving the lockdown status. + */ + + boolean isLockdownEnabled() throws Exception; + + /** + * Set the state of a tunnel, updating it's configuration. If the tunnel is already up, config + * may update the running configuration; config may be null when setting the tunnel down. + * + * @param tunnel The tunnel to control the state of. + * @param state The new state for this tunnel. Must be {@code UP}, {@code DOWN}, or + * {@code TOGGLE}. + * @param config The configuration for this tunnel, may be null if state is {@code DOWN}. + * @return The updated state of the tunnel. + * @throws Exception Exception raised while changing state. + */ + Tunnel.State setState(Tunnel tunnel, Tunnel.State state, @Nullable Config config) throws Exception; +} diff --git a/tunnel/src/main/java/com/wireguard/android/backend/BackendException.java b/tunnel/src/main/java/com/wireguard/android/backend/BackendException.java new file mode 100644 index 0000000..94f7b09 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/backend/BackendException.java @@ -0,0 +1,61 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.backend; + +import com.wireguard.util.NonNullForAll; + +/** + * A subclass of {@link Exception} that encapsulates the reasons for a failure originating in + * implementations of {@link Backend}. + */ +@NonNullForAll +public final class BackendException extends Exception { + private final Object[] format; + private final Reason reason; + + /** + * Public constructor for BackendException. + * + * @param reason The {@link Reason} which caused this exception to be thrown + * @param format Format string values used when converting exceptions to user-facing strings. + */ + public BackendException(final Reason reason, final Object... format) { + this.reason = reason; + this.format = format; + } + + /** + * Get the format string values associated with the instance. + * + * @return Array of {@link Object} for string formatting purposes + */ + public Object[] getFormat() { + return format; + } + + /** + * Get the reason for this exception. + * + * @return Associated {@link Reason} for this exception. + */ + public Reason getReason() { + return reason; + } + + /** + * Enum class containing all known reasons for why a {@link BackendException} might be thrown. + */ + public enum Reason { + UNKNOWN_KERNEL_MODULE_NAME, + WG_QUICK_CONFIG_ERROR_CODE, + TUNNEL_MISSING_CONFIG, + VPN_NOT_AUTHORIZED, + UNABLE_TO_START_VPN, + TUN_CREATION_ERROR, + GO_ACTIVATION_ERROR_CODE, + DNS_RESOLUTION_FAILURE, + } +} diff --git a/tunnel/src/main/java/com/wireguard/android/backend/GoBackend.java b/tunnel/src/main/java/com/wireguard/android/backend/GoBackend.java new file mode 100644 index 0000000..196836d --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/backend/GoBackend.java @@ -0,0 +1,429 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.backend; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import android.system.OsConstants; +import android.util.Log; + +import com.wireguard.android.backend.BackendException.Reason; +import com.wireguard.android.backend.Tunnel.State; +import com.wireguard.android.util.SharedLibraryLoader; +import com.wireguard.config.Config; +import com.wireguard.config.InetEndpoint; +import com.wireguard.config.InetNetwork; +import com.wireguard.config.Peer; +import com.wireguard.crypto.Key; +import com.wireguard.crypto.KeyFormatException; +import com.wireguard.util.NonNullForAll; + +import java.net.InetAddress; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import androidx.annotation.Nullable; +import androidx.collection.ArraySet; + +/** + * Implementation of {@link Backend} that uses the wireguard-go userspace implementation to provide + * WireGuard tunnels. + */ +@NonNullForAll +public final class GoBackend implements Backend { + private static final int DNS_RESOLUTION_RETRIES = 10; + private static final String TAG = "WireGuard/GoBackend"; + @Nullable private static AlwaysOnCallback alwaysOnCallback; + private static CompletableFuture vpnService = new CompletableFuture<>(); + private final Context context; + @Nullable private Config currentConfig; + @Nullable private Tunnel currentTunnel; + private int currentTunnelHandle = -1; + + /** + * Public constructor for GoBackend. + * + * @param context An Android {@link Context} + */ + public GoBackend(final Context context) { + SharedLibraryLoader.loadSharedLibrary(context, "wg-go"); + this.context = context; + } + + /** + * Set a {@link AlwaysOnCallback} to be invoked when {@link VpnService} is started by the + * system's Always-On VPN mode. + * + * @param cb Callback to be invoked + */ + public static void setAlwaysOnCallback(final AlwaysOnCallback cb) { + alwaysOnCallback = cb; + } + + @Nullable private static native String wgGetConfig(int handle); + + private static native int wgGetSocketV4(int handle); + + private static native int wgGetSocketV6(int handle); + + private static native void wgTurnOff(int handle); + + private static native int wgTurnOn(String ifName, int tunFd, String settings); + + private static native String wgVersion(); + + /** + * Method to get the names of running tunnels. + * + * @return A set of string values denoting names of running tunnels. + */ + @Override + public Set getRunningTunnelNames() { + if (currentTunnel != null) { + final Set runningTunnels = new ArraySet<>(); + runningTunnels.add(currentTunnel.getName()); + return runningTunnels; + } + return Collections.emptySet(); + } + + /** + * Get the associated {@link State} for a given {@link Tunnel}. + * + * @param tunnel The tunnel to examine the state of. + * @return {@link State} associated with the given tunnel. + */ + @Override + public State getState(final Tunnel tunnel) { + return currentTunnel == tunnel ? State.UP : State.DOWN; + } + + /** + * Get the associated {@link Statistics} for a given {@link Tunnel}. + * + * @param tunnel The tunnel to retrieve statistics for. + * @return {@link Statistics} associated with the given tunnel. + */ + @Override + public Statistics getStatistics(final Tunnel tunnel) { + final Statistics stats = new Statistics(); + if (tunnel != currentTunnel || currentTunnelHandle == -1) + return stats; + final String config = wgGetConfig(currentTunnelHandle); + if (config == null) + return stats; + Key key = null; + long rx = 0; + long tx = 0; + long latestHandshakeMSec = 0; + for (final String line : config.split("\\n")) { + if (line.startsWith("public_key=")) { + if (key != null) + stats.add(key, rx, tx, latestHandshakeMSec); + rx = 0; + tx = 0; + latestHandshakeMSec = 0; + try { + key = Key.fromHex(line.substring(11)); + } catch (final KeyFormatException ignored) { + key = null; + } + } else if (line.startsWith("rx_bytes=")) { + if (key == null) + continue; + try { + rx = Long.parseLong(line.substring(9)); + } catch (final NumberFormatException ignored) { + rx = 0; + } + } else if (line.startsWith("tx_bytes=")) { + if (key == null) + continue; + try { + tx = Long.parseLong(line.substring(9)); + } catch (final NumberFormatException ignored) { + tx = 0; + } + } else if (line.startsWith("last_handshake_time_sec=")) { + if (key == null) + continue; + try { + latestHandshakeMSec += Long.parseLong(line.substring(24)) * 1000; + } catch (final NumberFormatException ignored) { + latestHandshakeMSec = 0; + } + } else if (line.startsWith("last_handshake_time_nsec=")) { + if (key == null) + continue; + try { + latestHandshakeMSec += Long.parseLong(line.substring(25)) / 1000000; + } catch (final NumberFormatException ignored) { + latestHandshakeMSec = 0; + } + } + } + if (key != null) + stats.add(key, rx, tx, latestHandshakeMSec); + return stats; + } + + /** + * Get the version of the underlying wireguard-go library. + * + * @return {@link String} value of the version of the wireguard-go library. + */ + @Override + public String getVersion() { + return wgVersion(); + } + + /** + * Determines if the service is running in always-on VPN mode. + * @return {@link boolean} whether the service is running in always-on VPN mode. + */ + @Override + public boolean isAlwaysOn() throws ExecutionException, InterruptedException, TimeoutException { + return vpnService.get(0, TimeUnit.NANOSECONDS).isAlwaysOn(); + } + + /** + * Determines if the service is running in always-on VPN lockdown mode. + * @return {@link boolean} whether the service is running in always-on VPN lockdown mode. + */ + @Override + public boolean isLockdownEnabled() throws ExecutionException, InterruptedException, TimeoutException { + return vpnService.get(0, TimeUnit.NANOSECONDS).isLockdownEnabled(); + } + + /** + * Change the state of a given {@link Tunnel}, optionally applying a given {@link Config}. + * + * @param tunnel The tunnel to control the state of. + * @param state The new state for this tunnel. Must be {@code UP}, {@code DOWN}, or + * {@code TOGGLE}. + * @param config The configuration for this tunnel, may be null if state is {@code DOWN}. + * @return {@link State} of the tunnel after state changes are applied. + * @throws Exception Exception raised while changing tunnel state. + */ + @Override + public State setState(final Tunnel tunnel, State state, @Nullable final Config config) throws Exception { + final State originalState = getState(tunnel); + + if (state == State.TOGGLE) + state = originalState == State.UP ? State.DOWN : State.UP; + if (state == originalState && tunnel == currentTunnel && config == currentConfig) + return originalState; + if (state == State.UP) { + final Config originalConfig = currentConfig; + final Tunnel originalTunnel = currentTunnel; + if (currentTunnel != null) + setStateInternal(currentTunnel, null, State.DOWN); + try { + setStateInternal(tunnel, config, state); + } catch (final Exception e) { + if (originalTunnel != null) + setStateInternal(originalTunnel, originalConfig, State.UP); + throw e; + } + } else if (state == State.DOWN && tunnel == currentTunnel) { + setStateInternal(tunnel, null, State.DOWN); + } + return getState(tunnel); + } + + private void setStateInternal(final Tunnel tunnel, @Nullable final Config config, final State state) + throws Exception { + Log.i(TAG, "Bringing tunnel " + tunnel.getName() + ' ' + state); + + if (state == State.UP) { + if (config == null) + throw new BackendException(Reason.TUNNEL_MISSING_CONFIG); + + if (VpnService.prepare(context) != null) + throw new BackendException(Reason.VPN_NOT_AUTHORIZED); + + final VpnService service; + if (!vpnService.isDone()) { + Log.d(TAG, "Requesting to start VpnService"); + context.startService(new Intent(context, VpnService.class)); + } + + try { + service = vpnService.get(2, TimeUnit.SECONDS); + } catch (final TimeoutException e) { + final Exception be = new BackendException(Reason.UNABLE_TO_START_VPN); + be.initCause(e); + throw be; + } + service.setOwner(this); + + if (currentTunnelHandle != -1) { + Log.w(TAG, "Tunnel already up"); + return; + } + + + dnsRetry: for (int i = 0; i < DNS_RESOLUTION_RETRIES; ++i) { + // Pre-resolve IPs so they're cached when building the userspace string + for (final Peer peer : config.getPeers()) { + final InetEndpoint ep = peer.getEndpoint().orElse(null); + if (ep == null) + continue; + if (ep.getResolved().orElse(null) == null) { + if (i < DNS_RESOLUTION_RETRIES - 1) { + Log.w(TAG, "DNS host \"" + ep.getHost() + "\" failed to resolve; trying again"); + Thread.sleep(1000); + continue dnsRetry; + } else + throw new BackendException(Reason.DNS_RESOLUTION_FAILURE, ep.getHost()); + } + } + break; + } + + // Build config + final String goConfig = config.toWgUserspaceString(); + + // Create the vpn tunnel with android API + final VpnService.Builder builder = service.getBuilder(); + builder.setSession(tunnel.getName()); + + for (final String excludedApplication : config.getInterface().getExcludedApplications()) + builder.addDisallowedApplication(excludedApplication); + + for (final String includedApplication : config.getInterface().getIncludedApplications()) + builder.addAllowedApplication(includedApplication); + + for (final InetNetwork addr : config.getInterface().getAddresses()) + builder.addAddress(addr.getAddress(), addr.getMask()); + + for (final InetAddress addr : config.getInterface().getDnsServers()) + builder.addDnsServer(addr.getHostAddress()); + + for (final String dnsSearchDomain : config.getInterface().getDnsSearchDomains()) + builder.addSearchDomain(dnsSearchDomain); + + boolean sawDefaultRoute = false; + for (final Peer peer : config.getPeers()) { + for (final InetNetwork addr : peer.getAllowedIps()) { + if (addr.getMask() == 0) + sawDefaultRoute = true; + builder.addRoute(addr.getAddress(), addr.getMask()); + } + } + + // "Kill-switch" semantics + if (!(sawDefaultRoute && config.getPeers().size() == 1)) { + builder.allowFamily(OsConstants.AF_INET); + builder.allowFamily(OsConstants.AF_INET6); + } + + builder.setMtu(config.getInterface().getMtu().orElse(1280)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + builder.setMetered(false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + service.setUnderlyingNetworks(null); + + builder.setBlocking(true); + try (final ParcelFileDescriptor tun = builder.establish()) { + if (tun == null) + throw new BackendException(Reason.TUN_CREATION_ERROR); + Log.d(TAG, "Go backend " + wgVersion()); + currentTunnelHandle = wgTurnOn(tunnel.getName(), tun.detachFd(), goConfig); + } + if (currentTunnelHandle < 0) + throw new BackendException(Reason.GO_ACTIVATION_ERROR_CODE, currentTunnelHandle); + + currentTunnel = tunnel; + currentConfig = config; + + service.protect(wgGetSocketV4(currentTunnelHandle)); + service.protect(wgGetSocketV6(currentTunnelHandle)); + } else { + if (currentTunnelHandle == -1) { + Log.w(TAG, "Tunnel already down"); + return; + } + int handleToClose = currentTunnelHandle; + currentTunnel = null; + currentTunnelHandle = -1; + currentConfig = null; + wgTurnOff(handleToClose); + try { + vpnService.get(0, TimeUnit.NANOSECONDS).stopSelf(); + } catch (final TimeoutException ignored) { } + } + + tunnel.onStateChange(state); + } + + /** + * Callback for {@link GoBackend} that is invoked when {@link VpnService} is started by the + * system's Always-On VPN mode. + */ + public interface AlwaysOnCallback { + void alwaysOnTriggered(); + } + + /** + * {@link android.net.VpnService} implementation for {@link GoBackend} + */ + public static class VpnService extends android.net.VpnService { + @Nullable private GoBackend owner; + + public Builder getBuilder() { + return new Builder(); + } + + @Override + public void onCreate() { + vpnService.complete(this); + super.onCreate(); + } + + @Override + public void onDestroy() { + if (owner != null) { + final Tunnel tunnel = owner.currentTunnel; + if (tunnel != null) { + if (owner.currentTunnelHandle != -1) + wgTurnOff(owner.currentTunnelHandle); + owner.currentTunnel = null; + owner.currentTunnelHandle = -1; + owner.currentConfig = null; + tunnel.onStateChange(State.DOWN); + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + vpnService = vpnService.newIncompleteFuture(); + else + vpnService = new CompletableFuture<>(); + super.onDestroy(); + } + + @Override + public int onStartCommand(@Nullable final Intent intent, final int flags, final int startId) { + vpnService.complete(this); + if (intent == null || intent.getComponent() == null || !intent.getComponent().getPackageName().equals(getPackageName())) { + Log.d(TAG, "Service started by Always-on VPN feature"); + if (alwaysOnCallback != null) + alwaysOnCallback.alwaysOnTriggered(); + } + return super.onStartCommand(intent, flags, startId); + } + + public void setOwner(final GoBackend owner) { + this.owner = owner; + } + } +} diff --git a/tunnel/src/main/java/com/wireguard/android/backend/Statistics.java b/tunnel/src/main/java/com/wireguard/android/backend/Statistics.java new file mode 100644 index 0000000..d5d41c5 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/backend/Statistics.java @@ -0,0 +1,102 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.backend; + +import android.os.SystemClock; + +import com.wireguard.crypto.Key; +import com.wireguard.util.NonNullForAll; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import androidx.annotation.Nullable; + +/** + * Class representing transfer statistics for a {@link Tunnel} instance. + */ +@NonNullForAll +public class Statistics { + public record PeerStats(long rxBytes, long txBytes, long latestHandshakeEpochMillis) { } + private final Map stats = new HashMap<>(); + private long lastTouched = SystemClock.elapsedRealtime(); + + Statistics() { + } + + /** + * Add a peer and its current stats to the internal map. + * + * @param key A WireGuard public key bound to a particular peer + * @param rxBytes The received traffic for the {@link com.wireguard.config.Peer} referenced by + * the provided {@link Key}. This value is in bytes + * @param txBytes The transmitted traffic for the {@link com.wireguard.config.Peer} referenced by + * the provided {@link Key}. This value is in bytes. + * @param latestHandshake The timestamp of the latest handshake for the {@link com.wireguard.config.Peer} + * referenced by the provided {@link Key}. The value is in epoch milliseconds. + */ + void add(final Key key, final long rxBytes, final long txBytes, final long latestHandshake) { + stats.put(key, new PeerStats(rxBytes, txBytes, latestHandshake)); + lastTouched = SystemClock.elapsedRealtime(); + } + + /** + * Check if the statistics are stale, indicating the need for the {@link Backend} to update them. + * + * @return boolean indicating if the current statistics instance has stale values. + */ + public boolean isStale() { + return SystemClock.elapsedRealtime() - lastTouched > 900; + } + + /** + * Get the statistics for the {@link com.wireguard.config.Peer} referenced by the provided {@link Key} + * + * @param peer A {@link Key} representing a {@link com.wireguard.config.Peer}. + * @return a {@link PeerStats} representing various statistics about this peer. + */ + @Nullable + public PeerStats peer(final Key peer) { + return stats.get(peer); + } + + /** + * Get the list of peers being tracked by this instance. + * + * @return An array of {@link Key} instances representing WireGuard + * {@link com.wireguard.config.Peer}s + */ + public Key[] peers() { + return stats.keySet().toArray(new Key[0]); + } + + /** + * Get the total received traffic by all the peers being tracked by this instance + * + * @return a long representing the number of bytes received by the peers being tracked. + */ + public long totalRx() { + long rx = 0; + for (final PeerStats val : stats.values()) { + rx += val.rxBytes; + } + return rx; + } + + /** + * Get the total transmitted traffic by all the peers being tracked by this instance + * + * @return a long representing the number of bytes transmitted by the peers being tracked. + */ + public long totalTx() { + long tx = 0; + for (final PeerStats val : stats.values()) { + tx += val.txBytes; + } + return tx; + } +} diff --git a/tunnel/src/main/java/com/wireguard/android/backend/Tunnel.java b/tunnel/src/main/java/com/wireguard/android/backend/Tunnel.java new file mode 100644 index 0000000..dbc91c2 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/backend/Tunnel.java @@ -0,0 +1,57 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.backend; + +import com.wireguard.util.NonNullForAll; + +import java.util.regex.Pattern; + +/** + * Represents a WireGuard tunnel. + */ + +@NonNullForAll +public interface Tunnel { + int NAME_MAX_LENGTH = 15; + Pattern NAME_PATTERN = Pattern.compile("[a-zA-Z0-9_=+.-]{1,15}"); + + static boolean isNameInvalid(final CharSequence name) { + return !NAME_PATTERN.matcher(name).matches(); + } + + /** + * Get the name of the tunnel, which should always pass the !isNameInvalid test. + * + * @return The name of the tunnel. + */ + String getName(); + + /** + * React to a change in state of the tunnel. Should only be directly called by Backend. + * + * @param newState The new state of the tunnel. + */ + void onStateChange(State newState); + + /** + * Enum class to represent all possible states of a {@link Tunnel}. + */ + enum State { + DOWN, + TOGGLE, + UP; + + /** + * Get the state of a {@link Tunnel} + * + * @param running boolean indicating if the tunnel is running. + * @return State of the tunnel based on whether or not it is running. + */ + public static State of(final boolean running) { + return running ? UP : DOWN; + } + } +} diff --git a/tunnel/src/main/java/com/wireguard/android/backend/WgQuickBackend.java b/tunnel/src/main/java/com/wireguard/android/backend/WgQuickBackend.java new file mode 100644 index 0000000..af96dda --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/backend/WgQuickBackend.java @@ -0,0 +1,206 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.backend; + +import android.content.Context; +import android.util.Log; +import android.util.Pair; + +import com.wireguard.android.backend.BackendException.Reason; +import com.wireguard.android.backend.Tunnel.State; +import com.wireguard.android.util.RootShell; +import com.wireguard.android.util.ToolsInstaller; +import com.wireguard.config.Config; +import com.wireguard.crypto.Key; +import com.wireguard.util.NonNullForAll; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import androidx.annotation.Nullable; + +/** + * Implementation of {@link Backend} that uses the kernel module and {@code wg-quick} to provide + * WireGuard tunnels. + */ + +@NonNullForAll +public final class WgQuickBackend implements Backend { + private static final String TAG = "WireGuard/WgQuickBackend"; + private final File localTemporaryDir; + private final RootShell rootShell; + private final Map runningConfigs = new HashMap<>(); + private final ToolsInstaller toolsInstaller; + private boolean multipleTunnels; + + public WgQuickBackend(final Context context, final RootShell rootShell, final ToolsInstaller toolsInstaller) { + localTemporaryDir = new File(context.getCacheDir(), "tmp"); + this.rootShell = rootShell; + this.toolsInstaller = toolsInstaller; + } + + public static boolean hasKernelSupport() { + return new File("/sys/module/wireguard").exists(); + } + + @Override + public Set getRunningTunnelNames() { + final List output = new ArrayList<>(); + // Don't throw an exception here or nothing will show up in the UI. + try { + toolsInstaller.ensureToolsAvailable(); + if (rootShell.run(output, "wg show interfaces") != 0 || output.isEmpty()) + return Collections.emptySet(); + } catch (final Exception e) { + Log.w(TAG, "Unable to enumerate running tunnels", e); + return Collections.emptySet(); + } + // wg puts all interface names on the same line. Split them into separate elements. + return Set.of(output.get(0).split(" ")); + } + + @Override + public State getState(final Tunnel tunnel) { + return getRunningTunnelNames().contains(tunnel.getName()) ? State.UP : State.DOWN; + } + + @Override + public Statistics getStatistics(final Tunnel tunnel) { + final Statistics stats = new Statistics(); + final Collection output = new ArrayList<>(); + try { + if (rootShell.run(output, String.format("wg show '%s' dump", tunnel.getName())) != 0) + return stats; + } catch (final Exception ignored) { + return stats; + } + for (final String line : output) { + final String[] parts = line.split("\\t"); + if (parts.length != 8) + continue; + try { + stats.add(Key.fromBase64(parts[0]), Long.parseLong(parts[5]), Long.parseLong(parts[6]), Long.parseLong(parts[4]) * 1000); + } catch (final Exception ignored) { + } + } + return stats; + } + + @Override + public String getVersion() throws Exception { + final List output = new ArrayList<>(); + if (rootShell.run(output, "cat /sys/module/wireguard/version") != 0 || output.isEmpty()) + throw new BackendException(Reason.UNKNOWN_KERNEL_MODULE_NAME); + return output.get(0); + } + + @Override + public boolean isAlwaysOn() { + return false; + } + + @Override + public boolean isLockdownEnabled() { + return false; + } + + public void setMultipleTunnels(final boolean on) { + multipleTunnels = on; + } + + @Override + public State setState(final Tunnel tunnel, State state, @Nullable final Config config) throws Exception { + final State originalState = getState(tunnel); + final Config originalConfig = runningConfigs.get(tunnel); + final Map runningConfigsSnapshot = new HashMap<>(runningConfigs); + + if (state == State.TOGGLE) + state = originalState == State.UP ? State.DOWN : State.UP; + if ((state == State.UP && originalState == State.UP && originalConfig != null && originalConfig == config) || + (state == State.DOWN && originalState == State.DOWN)) + return originalState; + if (state == State.UP) { + toolsInstaller.ensureToolsAvailable(); + if (!multipleTunnels && originalState == State.DOWN) { + final List> rewind = new LinkedList<>(); + try { + for (final Map.Entry entry : runningConfigsSnapshot.entrySet()) { + setStateInternal(entry.getKey(), entry.getValue(), State.DOWN); + rewind.add(Pair.create(entry.getKey(), entry.getValue())); + } + } catch (final Exception e) { + try { + for (final Pair entry : rewind) { + setStateInternal(entry.first, entry.second, State.UP); + } + } catch (final Exception ignored) { + } + throw e; + } + } + if (originalState == State.UP) + setStateInternal(tunnel, originalConfig == null ? config : originalConfig, State.DOWN); + try { + setStateInternal(tunnel, config, State.UP); + } catch (final Exception e) { + try { + if (originalState == State.UP && originalConfig != null) { + setStateInternal(tunnel, originalConfig, State.UP); + } + if (!multipleTunnels && originalState == State.DOWN) { + for (final Map.Entry entry : runningConfigsSnapshot.entrySet()) { + setStateInternal(entry.getKey(), entry.getValue(), State.UP); + } + } + } catch (final Exception ignored) { + } + throw e; + } + } else if (state == State.DOWN) { + setStateInternal(tunnel, originalConfig == null ? config : originalConfig, State.DOWN); + } + return state; + } + + private void setStateInternal(final Tunnel tunnel, @Nullable final Config config, final State state) throws Exception { + Log.i(TAG, "Bringing tunnel " + tunnel.getName() + ' ' + state); + + Objects.requireNonNull(config, "Trying to set state up with a null config"); + + final File tempFile = new File(localTemporaryDir, tunnel.getName() + ".conf"); + try (final FileOutputStream stream = new FileOutputStream(tempFile, false)) { + stream.write(config.toWgQuickString().getBytes(StandardCharsets.UTF_8)); + } + String command = String.format("wg-quick %s '%s'", + state.toString().toLowerCase(Locale.ENGLISH), tempFile.getAbsolutePath()); + if (state == State.UP) + command = "cat /sys/module/wireguard/version && " + command; + final int result = rootShell.run(null, command); + // noinspection ResultOfMethodCallIgnored + tempFile.delete(); + if (result != 0) + throw new BackendException(Reason.WG_QUICK_CONFIG_ERROR_CODE, result); + + if (state == State.UP) + runningConfigs.put(tunnel, config); + else + runningConfigs.remove(tunnel); + + tunnel.onStateChange(state); + } +} diff --git a/tunnel/src/main/java/com/wireguard/android/util/RootShell.java b/tunnel/src/main/java/com/wireguard/android/util/RootShell.java new file mode 100644 index 0000000..5839ec6 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/util/RootShell.java @@ -0,0 +1,222 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import android.content.Context; +import android.util.Log; + +import com.wireguard.android.util.RootShell.RootShellException.Reason; +import com.wireguard.util.NonNullForAll; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.UUID; + +import androidx.annotation.Nullable; + +/** + * Helper class for running commands as root. + */ + +@NonNullForAll +public class RootShell { + private static final String SU = "su"; + private static final String TAG = "WireGuard/RootShell"; + + private final File localBinaryDir; + private final File localTemporaryDir; + private final Object lock = new Object(); + private final String preamble; + @Nullable private Process process; + @Nullable private BufferedReader stderr; + @Nullable private OutputStreamWriter stdin; + @Nullable private BufferedReader stdout; + + public RootShell(final Context context) { + localBinaryDir = new File(context.getCodeCacheDir(), "bin"); + localTemporaryDir = new File(context.getCacheDir(), "tmp"); + final String packageName = context.getPackageName(); + if (packageName.contains("'")) + throw new RuntimeException("Impossibly invalid package name contains a single quote"); + preamble = String.format("export CALLING_PACKAGE='%s' PATH=\"%s:$PATH\" TMPDIR='%s'; magisk --sqlite \"UPDATE policies SET notification=0, logging=0 WHERE uid=%d\" >/dev/null 2>&1; id -u\n", + packageName, localBinaryDir, localTemporaryDir, android.os.Process.myUid()); + } + + private static boolean isExecutableInPath(final String name) { + final String path = System.getenv("PATH"); + if (path == null) + return false; + for (final String dir : path.split(":")) + if (new File(dir, name).canExecute()) + return true; + return false; + } + + private boolean isRunning() { + synchronized (lock) { + try { + // Throws an exception if the process hasn't finished yet. + if (process != null) + process.exitValue(); + return false; + } catch (final IllegalThreadStateException ignored) { + // The existing process is still running. + return true; + } + } + } + + /** + * Run a command in a root shell. + * + * @param output Lines read from stdout are appended to this list. Pass null if the + * output from the shell is not important. + * @param command Command to run as root. + * @return The exit value of the command. + */ + public int run(@Nullable final Collection output, final String command) + throws IOException, RootShellException { + synchronized (lock) { + /* Start inside synchronized block to prevent a concurrent call to stop(). */ + start(); + final String marker = UUID.randomUUID().toString(); + final String script = "echo " + marker + "; echo " + marker + " >&2; (" + command + + "); ret=$?; echo " + marker + " $ret; echo " + marker + " $ret >&2\n"; + Log.v(TAG, "executing: " + command); + stdin.write(script); + stdin.flush(); + String line; + int errnoStdout = Integer.MIN_VALUE; + int errnoStderr = Integer.MAX_VALUE; + int markersSeen = 0; + while ((line = stdout.readLine()) != null) { + if (line.startsWith(marker)) { + ++markersSeen; + if (line.length() > marker.length() + 1) { + errnoStdout = Integer.valueOf(line.substring(marker.length() + 1)); + break; + } + } else if (markersSeen > 0) { + if (output != null) + output.add(line); + Log.v(TAG, "stdout: " + line); + } + } + while ((line = stderr.readLine()) != null) { + if (line.startsWith(marker)) { + ++markersSeen; + if (line.length() > marker.length() + 1) { + errnoStderr = Integer.valueOf(line.substring(marker.length() + 1)); + break; + } + } else if (markersSeen > 2) { + Log.v(TAG, "stderr: " + line); + } + } + if (markersSeen != 4) + throw new RootShellException(Reason.SHELL_MARKER_COUNT_ERROR, markersSeen); + if (errnoStdout != errnoStderr) + throw new RootShellException(Reason.SHELL_EXIT_STATUS_READ_ERROR); + Log.v(TAG, "exit: " + errnoStdout); + return errnoStdout; + } + } + + public void start() throws IOException, RootShellException { + if (!isExecutableInPath(SU)) + throw new RootShellException(Reason.NO_ROOT_ACCESS); + synchronized (lock) { + if (isRunning()) + return; + if (!localBinaryDir.isDirectory() && !localBinaryDir.mkdirs()) + throw new RootShellException(Reason.CREATE_BIN_DIR_ERROR); + if (!localTemporaryDir.isDirectory() && !localTemporaryDir.mkdirs()) + throw new RootShellException(Reason.CREATE_TEMP_DIR_ERROR); + try { + final ProcessBuilder builder = new ProcessBuilder().command(SU); + builder.environment().put("LC_ALL", "C"); + try { + process = builder.start(); + } catch (final IOException e) { + // A failure at this stage means the device isn't rooted. + final RootShellException rse = new RootShellException(Reason.NO_ROOT_ACCESS); + rse.initCause(e); + throw rse; + } + stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8); + stdout = new BufferedReader(new InputStreamReader(process.getInputStream(), + StandardCharsets.UTF_8)); + stderr = new BufferedReader(new InputStreamReader(process.getErrorStream(), + StandardCharsets.UTF_8)); + stdin.write(preamble); + stdin.flush(); + // Check that the shell started successfully. + final String uid = stdout.readLine(); + if (!"0".equals(uid)) { + Log.w(TAG, "Root check did not return correct UID: " + uid); + throw new RootShellException(Reason.NO_ROOT_ACCESS); + } + if (!isRunning()) { + String line; + while ((line = stderr.readLine()) != null) { + Log.w(TAG, "Root check returned an error: " + line); + if (line.contains("Permission denied")) + throw new RootShellException(Reason.NO_ROOT_ACCESS); + } + throw new RootShellException(Reason.SHELL_START_ERROR, process.exitValue()); + } + } catch (final IOException | RootShellException e) { + stop(); + throw e; + } + } + } + + public void stop() { + synchronized (lock) { + if (process != null) { + process.destroy(); + process = null; + } + } + } + + public static class RootShellException extends Exception { + private final Object[] format; + private final Reason reason; + + public RootShellException(final Reason reason, final Object... format) { + this.reason = reason; + this.format = format; + } + + public Object[] getFormat() { + return format; + } + + public Reason getReason() { + return reason; + } + + public boolean isIORelated() { + return reason != Reason.NO_ROOT_ACCESS; + } + + public enum Reason { + NO_ROOT_ACCESS, + SHELL_MARKER_COUNT_ERROR, + SHELL_EXIT_STATUS_READ_ERROR, + SHELL_START_ERROR, + CREATE_BIN_DIR_ERROR, + CREATE_TEMP_DIR_ERROR + } + } +} diff --git a/tunnel/src/main/java/com/wireguard/android/util/SharedLibraryLoader.java b/tunnel/src/main/java/com/wireguard/android/util/SharedLibraryLoader.java new file mode 100644 index 0000000..98e3e63 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/util/SharedLibraryLoader.java @@ -0,0 +1,95 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import com.wireguard.util.NonNullForAll; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import androidx.annotation.RestrictTo; +import androidx.annotation.RestrictTo.Scope; + +@NonNullForAll +@RestrictTo(Scope.LIBRARY_GROUP) +public final class SharedLibraryLoader { + private static final String TAG = "WireGuard/SharedLibraryLoader"; + + private SharedLibraryLoader() { + } + + public static boolean extractLibrary(final Context context, final String libName, final File destination) throws IOException { + final Collection apks = new HashSet<>(); + if (context.getApplicationInfo().sourceDir != null) + apks.add(context.getApplicationInfo().sourceDir); + if (context.getApplicationInfo().splitSourceDirs != null) + apks.addAll(Arrays.asList(context.getApplicationInfo().splitSourceDirs)); + + for (final String abi : Build.SUPPORTED_ABIS) { + for (final String apk : apks) { + try (final ZipFile zipFile = new ZipFile(new File(apk), ZipFile.OPEN_READ)) { + final String mappedLibName = System.mapLibraryName(libName); + final String libZipPath = "lib" + File.separatorChar + abi + File.separatorChar + mappedLibName; + final ZipEntry zipEntry = zipFile.getEntry(libZipPath); + if (zipEntry == null) + continue; + Log.d(TAG, "Extracting apk:/" + libZipPath + " to " + destination.getAbsolutePath()); + try (final FileOutputStream out = new FileOutputStream(destination); + final InputStream in = zipFile.getInputStream(zipEntry)) { + int len; + final byte[] buffer = new byte[1024 * 32]; + while ((len = in.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + out.getFD().sync(); + } + } + return true; + } + } + return false; + } + + public static void loadSharedLibrary(final Context context, final String libName) { + Throwable noAbiException; + try { + System.loadLibrary(libName); + return; + } catch (final UnsatisfiedLinkError e) { + Log.d(TAG, "Failed to load library normally, so attempting to extract from apk", e); + noAbiException = e; + } + File f = null; + try { + f = File.createTempFile("lib", ".so", context.getCodeCacheDir()); + if (extractLibrary(context, libName, f)) { + System.load(f.getAbsolutePath()); + return; + } + } catch (final Exception e) { + Log.d(TAG, "Failed to load library apk:/" + libName, e); + noAbiException = e; + } finally { + if (f != null) + // noinspection ResultOfMethodCallIgnored + f.delete(); + } + if (noAbiException instanceof RuntimeException) + throw (RuntimeException) noAbiException; + throw new RuntimeException(noAbiException); + } +} diff --git a/tunnel/src/main/java/com/wireguard/android/util/ToolsInstaller.java b/tunnel/src/main/java/com/wireguard/android/util/ToolsInstaller.java new file mode 100644 index 0000000..d388249 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/util/ToolsInstaller.java @@ -0,0 +1,202 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import android.content.Context; +import android.system.OsConstants; +import android.util.Log; + +import com.wireguard.android.util.RootShell.RootShellException; +import com.wireguard.util.NonNullForAll; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.annotation.RestrictTo.Scope; + +/** + * Helper to install WireGuard tools to the system partition. + */ + +@NonNullForAll +public final class ToolsInstaller { + public static final int ERROR = 0x0; + public static final int MAGISK = 0x4; + public static final int NO = 0x2; + public static final int SYSTEM = 0x8; + public static final int YES = 0x1; + private static final String[] EXECUTABLES = {"wg", "wg-quick"}; + private static final File[] INSTALL_DIRS = { + new File("/system/xbin"), + new File("/system/bin"), + }; + @Nullable private static final File INSTALL_DIR = getInstallDir(); + private static final String TAG = "WireGuard/ToolsInstaller"; + + private final Context context; + private final File localBinaryDir; + private final Object lock = new Object(); + private final RootShell rootShell; + @Nullable private Boolean areToolsAvailable; + @Nullable private Boolean installAsMagiskModule; + + public ToolsInstaller(final Context context, final RootShell rootShell) { + localBinaryDir = new File(context.getCodeCacheDir(), "bin"); + this.context = context; + this.rootShell = rootShell; + } + + @Nullable + private static File getInstallDir() { + final String path = System.getenv("PATH"); + if (path == null) + return INSTALL_DIRS[0]; + final List paths = Arrays.asList(path.split(":")); + for (final File dir : INSTALL_DIRS) { + if (paths.contains(dir.getPath()) && dir.isDirectory()) + return dir; + } + return null; + } + + public int areInstalled() throws RootShellException { + if (INSTALL_DIR == null) + return ERROR; + final StringBuilder script = new StringBuilder(); + for (final String name : EXECUTABLES) { + script.append(String.format("cmp -s '%s' '%s' && ", + new File(localBinaryDir, name).getAbsolutePath(), + new File(INSTALL_DIR, name).getAbsolutePath())); + } + script.append("exit ").append(OsConstants.EALREADY).append(';'); + try { + final int ret = rootShell.run(null, script.toString()); + if (ret == OsConstants.EALREADY) + return willInstallAsMagiskModule() ? YES | MAGISK : YES | SYSTEM; + else + return willInstallAsMagiskModule() ? NO | MAGISK : NO | SYSTEM; + } catch (final IOException ignored) { + return ERROR; + } catch (final RootShellException e) { + if (e.isIORelated()) + return ERROR; + throw e; + } + } + + public void ensureToolsAvailable() throws FileNotFoundException { + synchronized (lock) { + if (areToolsAvailable == null) { + try { + Log.d(TAG, extract() ? "Tools are now extracted into our private binary dir" : + "Tools were already extracted into our private binary dir"); + areToolsAvailable = true; + } catch (final IOException e) { + Log.e(TAG, "The wg and wg-quick tools are not available", e); + areToolsAvailable = false; + } + } + if (!areToolsAvailable) + throw new FileNotFoundException("Required tools unavailable"); + } + } + + public boolean extract() throws IOException { + localBinaryDir.mkdirs(); + final File[] files = new File[EXECUTABLES.length]; + final File[] tempFiles = new File[EXECUTABLES.length]; + boolean allExist = true; + for (int i = 0; i < files.length; ++i) { + files[i] = new File(localBinaryDir, EXECUTABLES[i]); + tempFiles[i] = new File(localBinaryDir, EXECUTABLES[i] + ".tmp"); + allExist &= files[i].exists(); + } + if (allExist) + return false; + for (int i = 0; i < files.length; ++i) { + if (!SharedLibraryLoader.extractLibrary(context, EXECUTABLES[i], tempFiles[i])) + throw new FileNotFoundException("Unable to find " + EXECUTABLES[i]); + if (!tempFiles[i].setExecutable(true, false)) + throw new IOException("Unable to mark " + tempFiles[i].getAbsolutePath() + " as executable"); + if (!tempFiles[i].renameTo(files[i])) + throw new IOException("Unable to rename " + tempFiles[i].getAbsolutePath() + " to " + files[i].getAbsolutePath()); + } + return true; + } + + @RestrictTo(Scope.LIBRARY_GROUP) + public int install() throws RootShellException, IOException { + if (!context.getPackageName().startsWith("com.wireguard.")) + throw new SecurityException("The tools may only be installed system-wide from the main WireGuard app."); + return willInstallAsMagiskModule() ? installMagisk() : installSystem(); + } + + private int installMagisk() throws RootShellException, IOException { + extract(); + final StringBuilder script = new StringBuilder("set -ex; "); + + script.append("trap 'rm -rf /data/adb/modules/wireguard' INT TERM EXIT; "); + script.append(String.format("rm -rf /data/adb/modules/wireguard/; mkdir -p /data/adb/modules/wireguard%s; ", INSTALL_DIR)); + script.append("printf 'id=wireguard\nname=WireGuard Command Line Tools\nversion=1.0\nversionCode=1\nauthor=zx2c4\ndescription=Command line tools for WireGuard\nminMagisk=1500\n' > /data/adb/modules/wireguard/module.prop; "); + script.append("touch /data/adb/modules/wireguard/auto_mount; "); + for (final String name : EXECUTABLES) { + final File destination = new File("/data/adb/modules/wireguard" + INSTALL_DIR, name); + script.append(String.format("cp '%s' '%s'; chmod 755 '%s'; chcon 'u:object_r:system_file:s0' '%s' || true; ", + new File(localBinaryDir, name), destination, destination, destination)); + } + script.append("trap - INT TERM EXIT;"); + + try { + return rootShell.run(null, script.toString()) == 0 ? YES | MAGISK : ERROR; + } catch (final IOException ignored) { + return ERROR; + } catch (final RootShellException e) { + if (e.isIORelated()) + return ERROR; + throw e; + } + } + + private int installSystem() throws RootShellException, IOException { + if (INSTALL_DIR == null) + return OsConstants.ENOENT; + extract(); + final StringBuilder script = new StringBuilder("set -ex; "); + script.append("trap 'mount -o ro,remount /system' EXIT; mount -o rw,remount /system; "); + for (final String name : EXECUTABLES) { + final File destination = new File(INSTALL_DIR, name); + script.append(String.format("cp '%s' '%s'; chmod 755 '%s'; restorecon '%s' || true; ", + new File(localBinaryDir, name), destination, destination, destination)); + } + try { + return rootShell.run(null, script.toString()) == 0 ? YES | SYSTEM : ERROR; + } catch (final IOException ignored) { + return ERROR; + } catch (final RootShellException e) { + if (e.isIORelated()) + return ERROR; + throw e; + } + } + + private boolean willInstallAsMagiskModule() { + synchronized (lock) { + if (installAsMagiskModule == null) { + try { + installAsMagiskModule = rootShell.run(null, "[ -d /data/adb/modules -a ! -f /cache/.disable_magisk ]") == OsConstants.EXIT_SUCCESS; + } catch (final Exception ignored) { + installAsMagiskModule = false; + } + } + return installAsMagiskModule; + } + } +} diff --git a/tunnel/src/main/java/com/wireguard/config/Attribute.java b/tunnel/src/main/java/com/wireguard/config/Attribute.java new file mode 100644 index 0000000..c43750d --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/config/Attribute.java @@ -0,0 +1,60 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.util.NonNullForAll; + +import java.util.Iterator; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@NonNullForAll +public final class Attribute { + private static final Pattern LINE_PATTERN = Pattern.compile("(\\w+)\\s*=\\s*([^\\s#][^#]*)"); + private static final Pattern LIST_SEPARATOR = Pattern.compile("\\s*,\\s*"); + + private final String key; + private final String value; + + private Attribute(final String key, final String value) { + this.key = key; + this.value = value; + } + + public static String join(final Iterable values) { + final Iterator it = values.iterator(); + if (!it.hasNext()) { + return ""; + } + final StringBuilder sb = new StringBuilder(); + sb.append(it.next()); + while (it.hasNext()) { + sb.append(", "); + sb.append(it.next()); + } + return sb.toString(); + } + + public static Optional parse(final CharSequence line) { + final Matcher matcher = LINE_PATTERN.matcher(line); + if (!matcher.matches()) + return Optional.empty(); + return Optional.of(new Attribute(matcher.group(1), matcher.group(2))); + } + + public static String[] split(final CharSequence value) { + return LIST_SEPARATOR.split(value); + } + + public String getKey() { + return key; + } + + public String getValue() { + return value; + } +} diff --git a/tunnel/src/main/java/com/wireguard/config/BadConfigException.java b/tunnel/src/main/java/com/wireguard/config/BadConfigException.java new file mode 100644 index 0000000..e70418c --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/config/BadConfigException.java @@ -0,0 +1,120 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.crypto.KeyFormatException; +import com.wireguard.util.NonNullForAll; + +import androidx.annotation.Nullable; + +@NonNullForAll +public class BadConfigException extends Exception { + private final Location location; + private final Reason reason; + private final Section section; + @Nullable private final CharSequence text; + + private BadConfigException(final Section section, final Location location, + final Reason reason, @Nullable final CharSequence text, + @Nullable final Throwable cause) { + super(cause); + this.section = section; + this.location = location; + this.reason = reason; + this.text = text; + } + + public BadConfigException(final Section section, final Location location, + final Reason reason, @Nullable final CharSequence text) { + this(section, location, reason, text, null); + } + + public BadConfigException(final Section section, final Location location, + final KeyFormatException cause) { + this(section, location, Reason.INVALID_KEY, null, cause); + } + + public BadConfigException(final Section section, final Location location, + @Nullable final CharSequence text, + final NumberFormatException cause) { + this(section, location, Reason.INVALID_NUMBER, text, cause); + } + + public BadConfigException(final Section section, final Location location, + final ParseException cause) { + this(section, location, Reason.INVALID_VALUE, cause.getText(), cause); + } + + public Location getLocation() { + return location; + } + + public Reason getReason() { + return reason; + } + + public Section getSection() { + return section; + } + + @Nullable + public CharSequence getText() { + return text; + } + + public enum Location { + TOP_LEVEL(""), + ADDRESS("Address"), + ALLOWED_IPS("AllowedIPs"), + DNS("DNS"), + ENDPOINT("Endpoint"), + EXCLUDED_APPLICATIONS("ExcludedApplications"), + INCLUDED_APPLICATIONS("IncludedApplications"), + LISTEN_PORT("ListenPort"), + MTU("MTU"), + PERSISTENT_KEEPALIVE("PersistentKeepalive"), + PRE_SHARED_KEY("PresharedKey"), + PRIVATE_KEY("PrivateKey"), + PUBLIC_KEY("PublicKey"); + + private final String name; + + Location(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + public enum Reason { + INVALID_KEY, + INVALID_NUMBER, + INVALID_VALUE, + MISSING_ATTRIBUTE, + MISSING_SECTION, + SYNTAX_ERROR, + UNKNOWN_ATTRIBUTE, + UNKNOWN_SECTION + } + + public enum Section { + CONFIG("Config"), + INTERFACE("Interface"), + PEER("Peer"); + + private final String name; + + Section(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + } +} diff --git a/tunnel/src/main/java/com/wireguard/config/Config.java b/tunnel/src/main/java/com/wireguard/config/Config.java new file mode 100644 index 0000000..21e45f5 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/config/Config.java @@ -0,0 +1,223 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.config.BadConfigException.Location; +import com.wireguard.config.BadConfigException.Reason; +import com.wireguard.config.BadConfigException.Section; +import com.wireguard.util.NonNullForAll; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import androidx.annotation.Nullable; + +/** + * Represents the contents of a wg-quick configuration file, made up of one or more "Interface" + * sections (combined together), and zero or more "Peer" sections (treated individually). + *

+ * Instances of this class are immutable. + */ +@NonNullForAll +public final class Config { + private final Interface interfaze; + private final List peers; + + private Config(final Builder builder) { + interfaze = Objects.requireNonNull(builder.interfaze, "An [Interface] section is required"); + // Defensively copy to ensure immutability even if the Builder is reused. + peers = Collections.unmodifiableList(new ArrayList<>(builder.peers)); + } + + /** + * Parses an series of "Interface" and "Peer" sections into a {@code Config}. Throws + * {@link BadConfigException} if the input is not well-formed or contains data that cannot + * be parsed. + * + * @param stream a stream of UTF-8 text that is interpreted as a WireGuard configuration + * @return a {@code Config} instance representing the supplied configuration + */ + public static Config parse(final InputStream stream) + throws IOException, BadConfigException { + return parse(new BufferedReader(new InputStreamReader(stream))); + } + + /** + * Parses an series of "Interface" and "Peer" sections into a {@code Config}. Throws + * {@link BadConfigException} if the input is not well-formed or contains data that cannot + * be parsed. + * + * @param reader a BufferedReader of UTF-8 text that is interpreted as a WireGuard configuration + * @return a {@code Config} instance representing the supplied configuration + */ + public static Config parse(final BufferedReader reader) + throws IOException, BadConfigException { + final Builder builder = new Builder(); + final Collection interfaceLines = new ArrayList<>(); + final Collection peerLines = new ArrayList<>(); + boolean inInterfaceSection = false; + boolean inPeerSection = false; + boolean seenInterfaceSection = false; + @Nullable String line; + while ((line = reader.readLine()) != null) { + final int commentIndex = line.indexOf('#'); + if (commentIndex != -1) + line = line.substring(0, commentIndex); + line = line.trim(); + if (line.isEmpty()) + continue; + if (line.startsWith("[")) { + // Consume all [Peer] lines read so far. + if (inPeerSection) { + builder.parsePeer(peerLines); + peerLines.clear(); + } + if ("[Interface]".equalsIgnoreCase(line)) { + inInterfaceSection = true; + inPeerSection = false; + seenInterfaceSection = true; + } else if ("[Peer]".equalsIgnoreCase(line)) { + inInterfaceSection = false; + inPeerSection = true; + } else { + throw new BadConfigException(Section.CONFIG, Location.TOP_LEVEL, + Reason.UNKNOWN_SECTION, line); + } + } else if (inInterfaceSection) { + interfaceLines.add(line); + } else if (inPeerSection) { + peerLines.add(line); + } else { + throw new BadConfigException(Section.CONFIG, Location.TOP_LEVEL, + Reason.UNKNOWN_SECTION, line); + } + } + if (inPeerSection) + builder.parsePeer(peerLines); + if (!seenInterfaceSection) + throw new BadConfigException(Section.CONFIG, Location.TOP_LEVEL, + Reason.MISSING_SECTION, null); + // Combine all [Interface] sections in the file. + builder.parseInterface(interfaceLines); + return builder.build(); + } + + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof Config)) + return false; + final Config other = (Config) obj; + return interfaze.equals(other.interfaze) && peers.equals(other.peers); + } + + /** + * Returns the interface section of the configuration. + * + * @return the interface configuration + */ + public Interface getInterface() { + return interfaze; + } + + /** + * Returns a list of the configuration's peer sections. + * + * @return a list of {@link Peer}s + */ + public List getPeers() { + return peers; + } + + @Override + public int hashCode() { + return 31 * interfaze.hashCode() + peers.hashCode(); + } + + /** + * Converts the {@code Config} into a string suitable for debugging purposes. The {@code Config} + * is identified by its interface's public key and the number of peers it has. + * + * @return a concise single-line identifier for the {@code Config} + */ + @Override + public String toString() { + return "(Config " + interfaze + " (" + peers.size() + " peers))"; + } + + /** + * Converts the {@code Config} into a string suitable for use as a {@code wg-quick} + * configuration file. + * + * @return the {@code Config} represented as one [Interface] and zero or more [Peer] sections + */ + public String toWgQuickString() { + final StringBuilder sb = new StringBuilder(); + sb.append("[Interface]\n").append(interfaze.toWgQuickString()); + for (final Peer peer : peers) + sb.append("\n[Peer]\n").append(peer.toWgQuickString()); + return sb.toString(); + } + + /** + * Serializes the {@code Config} for use with the WireGuard cross-platform userspace API. + * + * @return the {@code Config} represented as a series of "key=value" lines + */ + public String toWgUserspaceString() { + final StringBuilder sb = new StringBuilder(); + sb.append(interfaze.toWgUserspaceString()); + sb.append("replace_peers=true\n"); + for (final Peer peer : peers) + sb.append(peer.toWgUserspaceString()); + return sb.toString(); + } + + @SuppressWarnings("UnusedReturnValue") + public static final class Builder { + // Defaults to an empty set. + private final ArrayList peers = new ArrayList<>(); + // No default; must be provided before building. + @Nullable private Interface interfaze; + + public Builder addPeer(final Peer peer) { + peers.add(peer); + return this; + } + + public Builder addPeers(final Collection peers) { + this.peers.addAll(peers); + return this; + } + + public Config build() { + if (interfaze == null) + throw new IllegalArgumentException("An [Interface] section is required"); + return new Config(this); + } + + public Builder parseInterface(final Iterable lines) + throws BadConfigException { + return setInterface(Interface.parse(lines)); + } + + public Builder parsePeer(final Iterable lines) + throws BadConfigException { + return addPeer(Peer.parse(lines)); + } + + public Builder setInterface(final Interface interfaze) { + this.interfaze = interfaze; + return this; + } + } +} diff --git a/tunnel/src/main/java/com/wireguard/config/InetAddresses.java b/tunnel/src/main/java/com/wireguard/config/InetAddresses.java new file mode 100644 index 0000000..165a702 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/config/InetAddresses.java @@ -0,0 +1,86 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.util.NonNullForAll; + +import java.lang.reflect.Method; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.regex.Pattern; + +import androidx.annotation.Nullable; + +/** + * Utility methods for creating instances of {@link InetAddress}. + */ +@NonNullForAll +public final class InetAddresses { + @Nullable private static final Method PARSER_METHOD; + private static final Pattern WONT_TOUCH_RESOLVER = Pattern.compile("^(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?)|((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))$"); + private static final Pattern VALID_HOSTNAME = Pattern.compile("^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\\.?$"); + + static { + Method m = null; + try { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) + // noinspection JavaReflectionMemberAccess + m = InetAddress.class.getMethod("parseNumericAddress", String.class); + } catch (final Exception ignored) { + } + PARSER_METHOD = m; + } + + private InetAddresses() { + } + + /** + * Determines whether input is a valid DNS hostname. + * + * @param maybeHostname a string that is possibly a DNS hostname + * @return whether or not maybeHostname is a valid DNS hostname + */ + public static boolean isHostname(final CharSequence maybeHostname) { + return VALID_HOSTNAME.matcher(maybeHostname).matches(); + } + + /** + * Parses a numeric IPv4 or IPv6 address without performing any DNS lookups. + * + * @param address a string representing the IP address + * @return an instance of {@link Inet4Address} or {@link Inet6Address}, as appropriate + */ + public static InetAddress parse(final String address) throws ParseException { + if (address.isEmpty()) + throw new ParseException(InetAddress.class, address, "Empty address"); + try { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) + return android.net.InetAddresses.parseNumericAddress(address); + else if (PARSER_METHOD != null) + return (InetAddress) PARSER_METHOD.invoke(null, address); + else + throw new NoSuchMethodException("parseNumericAddress"); + } catch (final IllegalArgumentException e) { + throw new ParseException(InetAddress.class, address, e); + } catch (final Exception e) { + final Throwable cause = e.getCause(); + // Re-throw parsing exceptions with the original type, as callers might try to catch + // them. On the other hand, callers cannot be expected to handle reflection failures. + if (cause instanceof IllegalArgumentException) + throw new ParseException(InetAddress.class, address, cause); + try { + if (WONT_TOUCH_RESOLVER.matcher(address).matches()) + return InetAddress.getByName(address); + else + throw new ParseException(InetAddress.class, address, "Not an IP address"); + } catch (final UnknownHostException f) { + throw new ParseException(InetAddress.class, address, f); + } + } + } +} diff --git a/tunnel/src/main/java/com/wireguard/config/InetEndpoint.java b/tunnel/src/main/java/com/wireguard/config/InetEndpoint.java new file mode 100644 index 0000000..c0ef433 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/config/InetEndpoint.java @@ -0,0 +1,126 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.util.NonNullForAll; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.regex.Pattern; + +import androidx.annotation.Nullable; + + +/** + * An external endpoint (host and port) used to connect to a WireGuard {@link Peer}. + *

+ * Instances of this class are externally immutable. + */ +@NonNullForAll +public final class InetEndpoint { + private static final Pattern BARE_IPV6 = Pattern.compile("^[^\\[\\]]*:[^\\[\\]]*"); + private static final Pattern FORBIDDEN_CHARACTERS = Pattern.compile("[/?#]"); + + private final String host; + private final boolean isResolved; + private final Object lock = new Object(); + private final int port; + private Instant lastResolution = Instant.EPOCH; + @Nullable private InetEndpoint resolved; + + private InetEndpoint(final String host, final boolean isResolved, final int port) { + this.host = host; + this.isResolved = isResolved; + this.port = port; + } + + public static InetEndpoint parse(final String endpoint) throws ParseException { + if (FORBIDDEN_CHARACTERS.matcher(endpoint).find()) + throw new ParseException(InetEndpoint.class, endpoint, "Forbidden characters"); + final URI uri; + try { + uri = new URI("wg://" + endpoint); + } catch (final URISyntaxException e) { + throw new ParseException(InetEndpoint.class, endpoint, e); + } + if (uri.getPort() < 0 || uri.getPort() > 65535) + throw new ParseException(InetEndpoint.class, endpoint, "Missing/invalid port number"); + try { + InetAddresses.parse(uri.getHost()); + // Parsing ths host as a numeric address worked, so we don't need to do DNS lookups. + return new InetEndpoint(uri.getHost(), true, uri.getPort()); + } catch (final ParseException ignored) { + // Failed to parse the host as a numeric address, so it must be a DNS hostname/FQDN. + return new InetEndpoint(uri.getHost(), false, uri.getPort()); + } + } + + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof InetEndpoint)) + return false; + final InetEndpoint other = (InetEndpoint) obj; + return host.equals(other.host) && port == other.port; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + /** + * Generate an {@code InetEndpoint} instance with the same port and the host resolved using DNS + * to a numeric address. If the host is already numeric, the existing instance may be returned. + * Because this function may perform network I/O, it must not be called from the main thread. + * + * @return the resolved endpoint, or {@link Optional#empty()} + */ + public Optional getResolved() { + if (isResolved) + return Optional.of(this); + synchronized (lock) { + //TODO(zx2c4): Implement a real timeout mechanism using DNS TTL + if (Duration.between(lastResolution, Instant.now()).toMinutes() > 1) { + try { + // Prefer v4 endpoints over v6 to work around DNS64 and IPv6 NAT issues. + final InetAddress[] candidates = InetAddress.getAllByName(host); + InetAddress address = candidates[0]; + for (final InetAddress candidate : candidates) { + if (candidate instanceof Inet4Address) { + address = candidate; + break; + } + } + resolved = new InetEndpoint(address.getHostAddress(), true, port); + lastResolution = Instant.now(); + } catch (final UnknownHostException e) { + resolved = null; + } + } + return Optional.ofNullable(resolved); + } + } + + @Override + public int hashCode() { + return host.hashCode() ^ port; + } + + @Override + public String toString() { + final boolean isBareIpv6 = isResolved && BARE_IPV6.matcher(host).matches(); + return (isBareIpv6 ? '[' + host + ']' : host) + ':' + port; + } +} diff --git a/tunnel/src/main/java/com/wireguard/config/InetNetwork.java b/tunnel/src/main/java/com/wireguard/config/InetNetwork.java new file mode 100644 index 0000000..84aea82 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/config/InetNetwork.java @@ -0,0 +1,79 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.util.NonNullForAll; + +import java.net.Inet4Address; +import java.net.InetAddress; + +/** + * An Internet network, denoted by its address and netmask + *

+ * Instances of this class are immutable. + */ +@NonNullForAll +public final class InetNetwork { + private final InetAddress address; + private final int mask; + + private InetNetwork(final InetAddress address, final int mask) { + this.address = address; + this.mask = mask; + } + + public static InetNetwork parse(final String network) throws ParseException { + final int slash = network.lastIndexOf('/'); + final String maskString; + final int rawMask; + final String rawAddress; + if (slash >= 0) { + maskString = network.substring(slash + 1); + try { + rawMask = Integer.parseInt(maskString, 10); + } catch (final NumberFormatException ignored) { + throw new ParseException(Integer.class, maskString); + } + rawAddress = network.substring(0, slash); + } else { + maskString = ""; + rawMask = -1; + rawAddress = network; + } + final InetAddress address = InetAddresses.parse(rawAddress); + final int maxMask = (address instanceof Inet4Address) ? 32 : 128; + if (rawMask > maxMask) + throw new ParseException(InetNetwork.class, maskString, "Invalid network mask"); + final int mask = rawMask >= 0 ? rawMask : maxMask; + return new InetNetwork(address, mask); + } + + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof InetNetwork)) + return false; + final InetNetwork other = (InetNetwork) obj; + return address.equals(other.address) && mask == other.mask; + } + + public InetAddress getAddress() { + return address; + } + + public int getMask() { + return mask; + } + + @Override + public int hashCode() { + return address.hashCode() ^ mask; + } + + @Override + public String toString() { + return address.getHostAddress() + '/' + mask; + } +} diff --git a/tunnel/src/main/java/com/wireguard/config/Interface.java b/tunnel/src/main/java/com/wireguard/config/Interface.java new file mode 100644 index 0000000..53ca911 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/config/Interface.java @@ -0,0 +1,423 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.config.BadConfigException.Location; +import com.wireguard.config.BadConfigException.Reason; +import com.wireguard.config.BadConfigException.Section; +import com.wireguard.crypto.Key; +import com.wireguard.crypto.KeyFormatException; +import com.wireguard.crypto.KeyPair; +import com.wireguard.util.NonNullForAll; + +import java.net.InetAddress; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import androidx.annotation.Nullable; + +/** + * Represents the configuration for a WireGuard interface (an [Interface] block). Interfaces must + * have a private key (used to initialize a {@code KeyPair}), and may optionally have several other + * attributes. + *

+ * Instances of this class are immutable. + */ +@NonNullForAll +public final class Interface { + private static final int MAX_UDP_PORT = 65535; + private static final int MIN_UDP_PORT = 0; + + private final Set addresses; + private final Set dnsServers; + private final Set dnsSearchDomains; + private final Set excludedApplications; + private final Set includedApplications; + private final KeyPair keyPair; + private final Optional listenPort; + private final Optional mtu; + + private Interface(final Builder builder) { + // Defensively copy to ensure immutability even if the Builder is reused. + addresses = Collections.unmodifiableSet(new LinkedHashSet<>(builder.addresses)); + dnsServers = Collections.unmodifiableSet(new LinkedHashSet<>(builder.dnsServers)); + dnsSearchDomains = Collections.unmodifiableSet(new LinkedHashSet<>(builder.dnsSearchDomains)); + excludedApplications = Collections.unmodifiableSet(new LinkedHashSet<>(builder.excludedApplications)); + includedApplications = Collections.unmodifiableSet(new LinkedHashSet<>(builder.includedApplications)); + keyPair = Objects.requireNonNull(builder.keyPair, "Interfaces must have a private key"); + listenPort = builder.listenPort; + mtu = builder.mtu; + } + + /** + * Parses an series of "KEY = VALUE" lines into an {@code Interface}. Throws + * {@link ParseException} if the input is not well-formed or contains unknown attributes. + * + * @param lines An iterable sequence of lines, containing at least a private key attribute + * @return An {@code Interface} with all of the attributes from {@code lines} set + */ + public static Interface parse(final Iterable lines) + throws BadConfigException { + final Builder builder = new Builder(); + for (final CharSequence line : lines) { + final Attribute attribute = Attribute.parse(line).orElseThrow(() -> + new BadConfigException(Section.INTERFACE, Location.TOP_LEVEL, + Reason.SYNTAX_ERROR, line)); + switch (attribute.getKey().toLowerCase(Locale.ENGLISH)) { + case "address": + builder.parseAddresses(attribute.getValue()); + break; + case "dns": + builder.parseDnsServers(attribute.getValue()); + break; + case "excludedapplications": + builder.parseExcludedApplications(attribute.getValue()); + break; + case "includedapplications": + builder.parseIncludedApplications(attribute.getValue()); + break; + case "listenport": + builder.parseListenPort(attribute.getValue()); + break; + case "mtu": + builder.parseMtu(attribute.getValue()); + break; + case "privatekey": + builder.parsePrivateKey(attribute.getValue()); + break; + default: + throw new BadConfigException(Section.INTERFACE, Location.TOP_LEVEL, + Reason.UNKNOWN_ATTRIBUTE, attribute.getKey()); + } + } + return builder.build(); + } + + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof Interface)) + return false; + final Interface other = (Interface) obj; + return addresses.equals(other.addresses) + && dnsServers.equals(other.dnsServers) + && dnsSearchDomains.equals(other.dnsSearchDomains) + && excludedApplications.equals(other.excludedApplications) + && includedApplications.equals(other.includedApplications) + && keyPair.equals(other.keyPair) + && listenPort.equals(other.listenPort) + && mtu.equals(other.mtu); + } + + /** + * Returns the set of IP addresses assigned to the interface. + * + * @return a set of {@link InetNetwork}s + */ + public Set getAddresses() { + // The collection is already immutable. + return addresses; + } + + /** + * Returns the set of DNS servers associated with the interface. + * + * @return a set of {@link InetAddress}es + */ + public Set getDnsServers() { + // The collection is already immutable. + return dnsServers; + } + + /** + * Returns the set of DNS search domains associated with the interface. + * + * @return a set of strings + */ + public Set getDnsSearchDomains() { + // The collection is already immutable. + return dnsSearchDomains; + } + + /** + * Returns the set of applications excluded from using the interface. + * + * @return a set of package names + */ + public Set getExcludedApplications() { + // The collection is already immutable. + return excludedApplications; + } + + /** + * Returns the set of applications included exclusively for using the interface. + * + * @return a set of package names + */ + public Set getIncludedApplications() { + // The collection is already immutable. + return includedApplications; + } + + /** + * Returns the public/private key pair used by the interface. + * + * @return a key pair + */ + public KeyPair getKeyPair() { + return keyPair; + } + + /** + * Returns the UDP port number that the WireGuard interface will listen on. + * + * @return a UDP port number, or {@code Optional.empty()} if none is configured + */ + public Optional getListenPort() { + return listenPort; + } + + /** + * Returns the MTU used for the WireGuard interface. + * + * @return the MTU, or {@code Optional.empty()} if none is configured + */ + public Optional getMtu() { + return mtu; + } + + @Override + public int hashCode() { + int hash = 1; + hash = 31 * hash + addresses.hashCode(); + hash = 31 * hash + dnsServers.hashCode(); + hash = 31 * hash + excludedApplications.hashCode(); + hash = 31 * hash + includedApplications.hashCode(); + hash = 31 * hash + keyPair.hashCode(); + hash = 31 * hash + listenPort.hashCode(); + hash = 31 * hash + mtu.hashCode(); + return hash; + } + + /** + * Converts the {@code Interface} into a string suitable for debugging purposes. The {@code + * Interface} is identified by its public key and (if set) the port used for its UDP socket. + * + * @return A concise single-line identifier for the {@code Interface} + */ + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("(Interface "); + sb.append(keyPair.getPublicKey().toBase64()); + listenPort.ifPresent(lp -> sb.append(" @").append(lp)); + sb.append(')'); + return sb.toString(); + } + + /** + * Converts the {@code Interface} into a string suitable for inclusion in a {@code wg-quick} + * configuration file. + * + * @return The {@code Interface} represented as a series of "Key = Value" lines + */ + public String toWgQuickString() { + final StringBuilder sb = new StringBuilder(); + if (!addresses.isEmpty()) + sb.append("Address = ").append(Attribute.join(addresses)).append('\n'); + if (!dnsServers.isEmpty()) { + final List dnsServerStrings = dnsServers.stream().map(InetAddress::getHostAddress).collect(Collectors.toList()); + dnsServerStrings.addAll(dnsSearchDomains); + sb.append("DNS = ").append(Attribute.join(dnsServerStrings)).append('\n'); + } + if (!excludedApplications.isEmpty()) + sb.append("ExcludedApplications = ").append(Attribute.join(excludedApplications)).append('\n'); + if (!includedApplications.isEmpty()) + sb.append("IncludedApplications = ").append(Attribute.join(includedApplications)).append('\n'); + listenPort.ifPresent(lp -> sb.append("ListenPort = ").append(lp).append('\n')); + mtu.ifPresent(m -> sb.append("MTU = ").append(m).append('\n')); + sb.append("PrivateKey = ").append(keyPair.getPrivateKey().toBase64()).append('\n'); + return sb.toString(); + } + + /** + * Serializes the {@code Interface} for use with the WireGuard cross-platform userspace API. + * Note that not all attributes are included in this representation. + * + * @return the {@code Interface} represented as a series of "KEY=VALUE" lines + */ + public String toWgUserspaceString() { + final StringBuilder sb = new StringBuilder(); + sb.append("private_key=").append(keyPair.getPrivateKey().toHex()).append('\n'); + listenPort.ifPresent(lp -> sb.append("listen_port=").append(lp).append('\n')); + return sb.toString(); + } + + @SuppressWarnings("UnusedReturnValue") + public static final class Builder { + // Defaults to an empty set. + private final Set addresses = new LinkedHashSet<>(); + // Defaults to an empty set. + private final Set dnsServers = new LinkedHashSet<>(); + // Defaults to an empty set. + private final Set dnsSearchDomains = new LinkedHashSet<>(); + // Defaults to an empty set. + private final Set excludedApplications = new LinkedHashSet<>(); + // Defaults to an empty set. + private final Set includedApplications = new LinkedHashSet<>(); + // No default; must be provided before building. + @Nullable private KeyPair keyPair; + // Defaults to not present. + private Optional listenPort = Optional.empty(); + // Defaults to not present. + private Optional mtu = Optional.empty(); + + public Builder addAddress(final InetNetwork address) { + addresses.add(address); + return this; + } + + public Builder addAddresses(final Collection addresses) { + this.addresses.addAll(addresses); + return this; + } + + public Builder addDnsServer(final InetAddress dnsServer) { + dnsServers.add(dnsServer); + return this; + } + + public Builder addDnsServers(final Collection dnsServers) { + this.dnsServers.addAll(dnsServers); + return this; + } + + public Builder addDnsSearchDomain(final String dnsSearchDomain) { + dnsSearchDomains.add(dnsSearchDomain); + return this; + } + + public Builder addDnsSearchDomains(final Collection dnsSearchDomains) { + this.dnsSearchDomains.addAll(dnsSearchDomains); + return this; + } + + public Interface build() throws BadConfigException { + if (keyPair == null) + throw new BadConfigException(Section.INTERFACE, Location.PRIVATE_KEY, + Reason.MISSING_ATTRIBUTE, null); + if (!includedApplications.isEmpty() && !excludedApplications.isEmpty()) + throw new BadConfigException(Section.INTERFACE, Location.INCLUDED_APPLICATIONS, + Reason.INVALID_KEY, null); + return new Interface(this); + } + + public Builder excludeApplication(final String application) { + excludedApplications.add(application); + return this; + } + + public Builder excludeApplications(final Collection applications) { + excludedApplications.addAll(applications); + return this; + } + + public Builder includeApplication(final String application) { + includedApplications.add(application); + return this; + } + + public Builder includeApplications(final Collection applications) { + includedApplications.addAll(applications); + return this; + } + + public Builder parseAddresses(final CharSequence addresses) throws BadConfigException { + try { + for (final String address : Attribute.split(addresses)) + addAddress(InetNetwork.parse(address)); + return this; + } catch (final ParseException e) { + throw new BadConfigException(Section.INTERFACE, Location.ADDRESS, e); + } + } + + public Builder parseDnsServers(final CharSequence dnsServers) throws BadConfigException { + try { + for (final String dnsServer : Attribute.split(dnsServers)) { + try { + addDnsServer(InetAddresses.parse(dnsServer)); + } catch (final ParseException e) { + if (e.getParsingClass() != InetAddress.class || !InetAddresses.isHostname(dnsServer)) + throw e; + addDnsSearchDomain(dnsServer); + } + } + return this; + } catch (final ParseException e) { + throw new BadConfigException(Section.INTERFACE, Location.DNS, e); + } + } + + public Builder parseExcludedApplications(final CharSequence apps) { + return excludeApplications(List.of(Attribute.split(apps))); + } + + public Builder parseIncludedApplications(final CharSequence apps) { + return includeApplications(List.of(Attribute.split(apps))); + } + + public Builder parseListenPort(final String listenPort) throws BadConfigException { + try { + return setListenPort(Integer.parseInt(listenPort)); + } catch (final NumberFormatException e) { + throw new BadConfigException(Section.INTERFACE, Location.LISTEN_PORT, listenPort, e); + } + } + + public Builder parseMtu(final String mtu) throws BadConfigException { + try { + return setMtu(Integer.parseInt(mtu)); + } catch (final NumberFormatException e) { + throw new BadConfigException(Section.INTERFACE, Location.MTU, mtu, e); + } + } + + public Builder parsePrivateKey(final String privateKey) throws BadConfigException { + try { + return setKeyPair(new KeyPair(Key.fromBase64(privateKey))); + } catch (final KeyFormatException e) { + throw new BadConfigException(Section.INTERFACE, Location.PRIVATE_KEY, e); + } + } + + public Builder setKeyPair(final KeyPair keyPair) { + this.keyPair = keyPair; + return this; + } + + public Builder setListenPort(final int listenPort) throws BadConfigException { + if (listenPort < MIN_UDP_PORT || listenPort > MAX_UDP_PORT) + throw new BadConfigException(Section.INTERFACE, Location.LISTEN_PORT, + Reason.INVALID_VALUE, String.valueOf(listenPort)); + this.listenPort = listenPort == 0 ? Optional.empty() : Optional.of(listenPort); + return this; + } + + public Builder setMtu(final int mtu) throws BadConfigException { + if (mtu < 0) + throw new BadConfigException(Section.INTERFACE, Location.LISTEN_PORT, + Reason.INVALID_VALUE, String.valueOf(mtu)); + this.mtu = mtu == 0 ? Optional.empty() : Optional.of(mtu); + return this; + } + } +} diff --git a/tunnel/src/main/java/com/wireguard/config/ParseException.java b/tunnel/src/main/java/com/wireguard/config/ParseException.java new file mode 100644 index 0000000..ff430e6 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/config/ParseException.java @@ -0,0 +1,48 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.util.NonNullForAll; + +import androidx.annotation.Nullable; + +/** + * + */ +@NonNullForAll +public class ParseException extends Exception { + private final Class parsingClass; + private final CharSequence text; + + public ParseException(final Class parsingClass, final CharSequence text, + @Nullable final String message, @Nullable final Throwable cause) { + super(message, cause); + this.parsingClass = parsingClass; + this.text = text; + } + + public ParseException(final Class parsingClass, final CharSequence text, + @Nullable final String message) { + this(parsingClass, text, message, null); + } + + public ParseException(final Class parsingClass, final CharSequence text, + @Nullable final Throwable cause) { + this(parsingClass, text, null, cause); + } + + public ParseException(final Class parsingClass, final CharSequence text) { + this(parsingClass, text, null, null); + } + + public Class getParsingClass() { + return parsingClass; + } + + public CharSequence getText() { + return text; + } +} diff --git a/tunnel/src/main/java/com/wireguard/config/Peer.java b/tunnel/src/main/java/com/wireguard/config/Peer.java new file mode 100644 index 0000000..b308a93 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/config/Peer.java @@ -0,0 +1,307 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.config.BadConfigException.Location; +import com.wireguard.config.BadConfigException.Reason; +import com.wireguard.config.BadConfigException.Section; +import com.wireguard.crypto.Key; +import com.wireguard.crypto.KeyFormatException; +import com.wireguard.util.NonNullForAll; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import androidx.annotation.Nullable; + +/** + * Represents the configuration for a WireGuard peer (a [Peer] block). Peers must have a public key, + * and may optionally have several other attributes. + *

+ * Instances of this class are immutable. + */ +@NonNullForAll +public final class Peer { + private final Set allowedIps; + private final Optional endpoint; + private final Optional persistentKeepalive; + private final Optional preSharedKey; + private final Key publicKey; + + private Peer(final Builder builder) { + // Defensively copy to ensure immutability even if the Builder is reused. + allowedIps = Collections.unmodifiableSet(new LinkedHashSet<>(builder.allowedIps)); + endpoint = builder.endpoint; + persistentKeepalive = builder.persistentKeepalive; + preSharedKey = builder.preSharedKey; + publicKey = Objects.requireNonNull(builder.publicKey, "Peers must have a public key"); + } + + /** + * Parses an series of "KEY = VALUE" lines into a {@code Peer}. Throws {@link ParseException} if + * the input is not well-formed or contains unknown attributes. + * + * @param lines an iterable sequence of lines, containing at least a public key attribute + * @return a {@code Peer} with all of its attributes set from {@code lines} + */ + public static Peer parse(final Iterable lines) + throws BadConfigException { + final Builder builder = new Builder(); + for (final CharSequence line : lines) { + final Attribute attribute = Attribute.parse(line).orElseThrow(() -> + new BadConfigException(Section.PEER, Location.TOP_LEVEL, + Reason.SYNTAX_ERROR, line)); + switch (attribute.getKey().toLowerCase(Locale.ENGLISH)) { + case "allowedips": + builder.parseAllowedIPs(attribute.getValue()); + break; + case "endpoint": + builder.parseEndpoint(attribute.getValue()); + break; + case "persistentkeepalive": + builder.parsePersistentKeepalive(attribute.getValue()); + break; + case "presharedkey": + builder.parsePreSharedKey(attribute.getValue()); + break; + case "publickey": + builder.parsePublicKey(attribute.getValue()); + break; + default: + throw new BadConfigException(Section.PEER, Location.TOP_LEVEL, + Reason.UNKNOWN_ATTRIBUTE, attribute.getKey()); + } + } + return builder.build(); + } + + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof Peer)) + return false; + final Peer other = (Peer) obj; + return allowedIps.equals(other.allowedIps) + && endpoint.equals(other.endpoint) + && persistentKeepalive.equals(other.persistentKeepalive) + && preSharedKey.equals(other.preSharedKey) + && publicKey.equals(other.publicKey); + } + + /** + * Returns the peer's set of allowed IPs. + * + * @return the set of allowed IPs + */ + public Set getAllowedIps() { + // The collection is already immutable. + return allowedIps; + } + + /** + * Returns the peer's endpoint. + * + * @return the endpoint, or {@code Optional.empty()} if none is configured + */ + public Optional getEndpoint() { + return endpoint; + } + + /** + * Returns the peer's persistent keepalive. + * + * @return the persistent keepalive, or {@code Optional.empty()} if none is configured + */ + public Optional getPersistentKeepalive() { + return persistentKeepalive; + } + + /** + * Returns the peer's pre-shared key. + * + * @return the pre-shared key, or {@code Optional.empty()} if none is configured + */ + public Optional getPreSharedKey() { + return preSharedKey; + } + + /** + * Returns the peer's public key. + * + * @return the public key + */ + public Key getPublicKey() { + return publicKey; + } + + @Override + public int hashCode() { + int hash = 1; + hash = 31 * hash + allowedIps.hashCode(); + hash = 31 * hash + endpoint.hashCode(); + hash = 31 * hash + persistentKeepalive.hashCode(); + hash = 31 * hash + preSharedKey.hashCode(); + hash = 31 * hash + publicKey.hashCode(); + return hash; + } + + /** + * Converts the {@code Peer} into a string suitable for debugging purposes. The {@code Peer} is + * identified by its public key and (if known) its endpoint. + * + * @return a concise single-line identifier for the {@code Peer} + */ + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("(Peer "); + sb.append(publicKey.toBase64()); + endpoint.ifPresent(ep -> sb.append(" @").append(ep)); + sb.append(')'); + return sb.toString(); + } + + /** + * Converts the {@code Peer} into a string suitable for inclusion in a {@code wg-quick} + * configuration file. + * + * @return the {@code Peer} represented as a series of "Key = Value" lines + */ + public String toWgQuickString() { + final StringBuilder sb = new StringBuilder(); + if (!allowedIps.isEmpty()) + sb.append("AllowedIPs = ").append(Attribute.join(allowedIps)).append('\n'); + endpoint.ifPresent(ep -> sb.append("Endpoint = ").append(ep).append('\n')); + persistentKeepalive.ifPresent(pk -> sb.append("PersistentKeepalive = ").append(pk).append('\n')); + preSharedKey.ifPresent(psk -> sb.append("PreSharedKey = ").append(psk.toBase64()).append('\n')); + sb.append("PublicKey = ").append(publicKey.toBase64()).append('\n'); + return sb.toString(); + } + + /** + * Serializes the {@code Peer} for use with the WireGuard cross-platform userspace API. Note + * that not all attributes are included in this representation. + * + * @return the {@code Peer} represented as a series of "key=value" lines + */ + public String toWgUserspaceString() { + final StringBuilder sb = new StringBuilder(); + // The order here is important: public_key signifies the beginning of a new peer. + sb.append("public_key=").append(publicKey.toHex()).append('\n'); + for (final InetNetwork allowedIp : allowedIps) + sb.append("allowed_ip=").append(allowedIp).append('\n'); + endpoint.flatMap(InetEndpoint::getResolved).ifPresent(ep -> sb.append("endpoint=").append(ep).append('\n')); + persistentKeepalive.ifPresent(pk -> sb.append("persistent_keepalive_interval=").append(pk).append('\n')); + preSharedKey.ifPresent(psk -> sb.append("preshared_key=").append(psk.toHex()).append('\n')); + return sb.toString(); + } + + @SuppressWarnings("UnusedReturnValue") + public static final class Builder { + // See wg(8) + private static final int MAX_PERSISTENT_KEEPALIVE = 65535; + + // Defaults to an empty set. + private final Set allowedIps = new LinkedHashSet<>(); + // Defaults to not present. + private Optional endpoint = Optional.empty(); + // Defaults to not present. + private Optional persistentKeepalive = Optional.empty(); + // Defaults to not present. + private Optional preSharedKey = Optional.empty(); + // No default; must be provided before building. + @Nullable private Key publicKey; + + public Builder addAllowedIp(final InetNetwork allowedIp) { + allowedIps.add(allowedIp); + return this; + } + + public Builder addAllowedIps(final Collection allowedIps) { + this.allowedIps.addAll(allowedIps); + return this; + } + + public Peer build() throws BadConfigException { + if (publicKey == null) + throw new BadConfigException(Section.PEER, Location.PUBLIC_KEY, + Reason.MISSING_ATTRIBUTE, null); + return new Peer(this); + } + + public Builder parseAllowedIPs(final CharSequence allowedIps) throws BadConfigException { + try { + for (final String allowedIp : Attribute.split(allowedIps)) + addAllowedIp(InetNetwork.parse(allowedIp)); + return this; + } catch (final ParseException e) { + throw new BadConfigException(Section.PEER, Location.ALLOWED_IPS, e); + } + } + + public Builder parseEndpoint(final String endpoint) throws BadConfigException { + try { + return setEndpoint(InetEndpoint.parse(endpoint)); + } catch (final ParseException e) { + throw new BadConfigException(Section.PEER, Location.ENDPOINT, e); + } + } + + public Builder parsePersistentKeepalive(final String persistentKeepalive) + throws BadConfigException { + try { + return setPersistentKeepalive(Integer.parseInt(persistentKeepalive)); + } catch (final NumberFormatException e) { + throw new BadConfigException(Section.PEER, Location.PERSISTENT_KEEPALIVE, + persistentKeepalive, e); + } + } + + public Builder parsePreSharedKey(final String preSharedKey) throws BadConfigException { + try { + return setPreSharedKey(Key.fromBase64(preSharedKey)); + } catch (final KeyFormatException e) { + throw new BadConfigException(Section.PEER, Location.PRE_SHARED_KEY, e); + } + } + + public Builder parsePublicKey(final String publicKey) throws BadConfigException { + try { + return setPublicKey(Key.fromBase64(publicKey)); + } catch (final KeyFormatException e) { + throw new BadConfigException(Section.PEER, Location.PUBLIC_KEY, e); + } + } + + public Builder setEndpoint(final InetEndpoint endpoint) { + this.endpoint = Optional.of(endpoint); + return this; + } + + public Builder setPersistentKeepalive(final int persistentKeepalive) + throws BadConfigException { + if (persistentKeepalive < 0 || persistentKeepalive > MAX_PERSISTENT_KEEPALIVE) + throw new BadConfigException(Section.PEER, Location.PERSISTENT_KEEPALIVE, + Reason.INVALID_VALUE, String.valueOf(persistentKeepalive)); + this.persistentKeepalive = persistentKeepalive == 0 ? + Optional.empty() : Optional.of(persistentKeepalive); + return this; + } + + public Builder setPreSharedKey(final Key preSharedKey) { + this.preSharedKey = Optional.of(preSharedKey); + return this; + } + + public Builder setPublicKey(final Key publicKey) { + this.publicKey = publicKey; + return this; + } + } +} diff --git a/tunnel/src/main/java/com/wireguard/crypto/Curve25519.java b/tunnel/src/main/java/com/wireguard/crypto/Curve25519.java new file mode 100644 index 0000000..c9a592f --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/crypto/Curve25519.java @@ -0,0 +1,500 @@ +/* + * Copyright © 2016 Southern Storm Software, Pty Ltd. + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.crypto; + +import com.wireguard.util.NonNullForAll; + +import java.util.Arrays; + +import androidx.annotation.Nullable; + +/** + * Implementation of Curve25519 ECDH. + *

+ * This implementation was imported to WireGuard from noise-java: + * https://github.com/rweather/noise-java + *

+ * This implementation is based on that from arduinolibs: + * https://github.com/rweather/arduinolibs + *

+ * Differences in this version are due to using 26-bit limbs for the + * representation instead of the 8/16/32-bit limbs in the original. + *

+ * References: http://cr.yp.to/ecdh.html, RFC 7748 + */ +@SuppressWarnings({"MagicNumber", "NonConstantFieldWithUpperCaseName", "SuspiciousNameCombination"}) +@NonNullForAll +public final class Curve25519 { + // Numbers modulo 2^255 - 19 are broken up into ten 26-bit words. + private static final int NUM_LIMBS_255BIT = 10; + private static final int NUM_LIMBS_510BIT = 20; + + private final int[] A; + private final int[] AA; + private final int[] B; + private final int[] BB; + private final int[] C; + private final int[] CB; + private final int[] D; + private final int[] DA; + private final int[] E; + private final long[] t1; + private final int[] t2; + private final int[] x_1; + private final int[] x_2; + private final int[] x_3; + private final int[] z_2; + private final int[] z_3; + + /** + * Constructs the temporary state holder for Curve25519 evaluation. + */ + private Curve25519() { + // Allocate memory for all of the temporary variables we will need. + x_1 = new int[NUM_LIMBS_255BIT]; + x_2 = new int[NUM_LIMBS_255BIT]; + x_3 = new int[NUM_LIMBS_255BIT]; + z_2 = new int[NUM_LIMBS_255BIT]; + z_3 = new int[NUM_LIMBS_255BIT]; + A = new int[NUM_LIMBS_255BIT]; + B = new int[NUM_LIMBS_255BIT]; + C = new int[NUM_LIMBS_255BIT]; + D = new int[NUM_LIMBS_255BIT]; + E = new int[NUM_LIMBS_255BIT]; + AA = new int[NUM_LIMBS_255BIT]; + BB = new int[NUM_LIMBS_255BIT]; + DA = new int[NUM_LIMBS_255BIT]; + CB = new int[NUM_LIMBS_255BIT]; + t1 = new long[NUM_LIMBS_510BIT]; + t2 = new int[NUM_LIMBS_510BIT]; + } + + /** + * Conditional swap of two values. + * + * @param select Set to 1 to swap, 0 to leave as-is. + * @param x The first value. + * @param y The second value. + */ + private static void cswap(int select, final int[] x, final int[] y) { + select = -select; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) { + final int dummy = select & (x[index] ^ y[index]); + x[index] ^= dummy; + y[index] ^= dummy; + } + } + + /** + * Evaluates the Curve25519 curve. + * + * @param result Buffer to place the result of the evaluation into. + * @param offset Offset into the result buffer. + * @param privateKey The private key to use in the evaluation. + * @param publicKey The public key to use in the evaluation, or null + * if the base point of the curve should be used. + */ + public static void eval(final byte[] result, final int offset, + final byte[] privateKey, @Nullable final byte[] publicKey) { + final Curve25519 state = new Curve25519(); + try { + // Unpack the public key value. If null, use 9 as the base point. + Arrays.fill(state.x_1, 0); + if (publicKey != null) { + // Convert the input value from little-endian into 26-bit limbs. + for (int index = 0; index < 32; ++index) { + final int bit = (index * 8) % 26; + final int word = (index * 8) / 26; + final int value = publicKey[index] & 0xFF; + if (bit <= (26 - 8)) { + state.x_1[word] |= value << bit; + } else { + state.x_1[word] |= value << bit; + state.x_1[word] &= 0x03FFFFFF; + state.x_1[word + 1] |= value >> (26 - bit); + } + } + + // Just in case, we reduce the number modulo 2^255 - 19 to + // make sure that it is in range of the field before we start. + // This eliminates values between 2^255 - 19 and 2^256 - 1. + state.reduceQuick(state.x_1); + state.reduceQuick(state.x_1); + } else { + state.x_1[0] = 9; + } + + // Initialize the other temporary variables. + Arrays.fill(state.x_2, 0); // x_2 = 1 + state.x_2[0] = 1; + Arrays.fill(state.z_2, 0); // z_2 = 0 + System.arraycopy(state.x_1, 0, state.x_3, 0, state.x_1.length); // x_3 = x_1 + Arrays.fill(state.z_3, 0); // z_3 = 1 + state.z_3[0] = 1; + + // Evaluate the curve for every bit of the private key. + state.evalCurve(privateKey); + + // Compute x_2 * (z_2 ^ (p - 2)) where p = 2^255 - 19. + state.recip(state.z_3, state.z_2); + state.mul(state.x_2, state.x_2, state.z_3); + + // Convert x_2 into little-endian in the result buffer. + for (int index = 0; index < 32; ++index) { + final int bit = (index * 8) % 26; + final int word = (index * 8) / 26; + if (bit <= (26 - 8)) + result[offset + index] = (byte) (state.x_2[word] >> bit); + else + result[offset + index] = (byte) ((state.x_2[word] >> bit) | (state.x_2[word + 1] << (26 - bit))); + } + } finally { + // Clean up all temporary state before we exit. + state.destroy(); + } + } + + /** + * Subtracts two numbers modulo 2^255 - 19. + * + * @param result The result. + * @param x The first number to subtract. + * @param y The second number to subtract. + */ + private static void sub(final int[] result, final int[] x, final int[] y) { + int index; + int borrow; + + // Subtract y from x to generate the intermediate result. + borrow = 0; + for (index = 0; index < NUM_LIMBS_255BIT; ++index) { + borrow = x[index] - y[index] - ((borrow >> 26) & 0x01); + result[index] = borrow & 0x03FFFFFF; + } + + // If we had a borrow, then the result has gone negative and we + // have to add 2^255 - 19 to the result to make it positive again. + // The top bits of "borrow" will be all 1's if there is a borrow + // or it will be all 0's if there was no borrow. Easiest is to + // conditionally subtract 19 and then mask off the high bits. + borrow = result[0] - ((-((borrow >> 26) & 0x01)) & 19); + result[0] = borrow & 0x03FFFFFF; + for (index = 1; index < NUM_LIMBS_255BIT; ++index) { + borrow = result[index] - ((borrow >> 26) & 0x01); + result[index] = borrow & 0x03FFFFFF; + } + result[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF; + } + + /** + * Adds two numbers modulo 2^255 - 19. + * + * @param result The result. + * @param x The first number to add. + * @param y The second number to add. + */ + private void add(final int[] result, final int[] x, final int[] y) { + int carry = x[0] + y[0]; + result[0] = carry & 0x03FFFFFF; + for (int index = 1; index < NUM_LIMBS_255BIT; ++index) { + carry = (carry >> 26) + x[index] + y[index]; + result[index] = carry & 0x03FFFFFF; + } + reduceQuick(result); + } + + /** + * Destroy all sensitive data in this object. + */ + private void destroy() { + // Destroy all temporary variables. + Arrays.fill(x_1, 0); + Arrays.fill(x_2, 0); + Arrays.fill(x_3, 0); + Arrays.fill(z_2, 0); + Arrays.fill(z_3, 0); + Arrays.fill(A, 0); + Arrays.fill(B, 0); + Arrays.fill(C, 0); + Arrays.fill(D, 0); + Arrays.fill(E, 0); + Arrays.fill(AA, 0); + Arrays.fill(BB, 0); + Arrays.fill(DA, 0); + Arrays.fill(CB, 0); + Arrays.fill(t1, 0L); + Arrays.fill(t2, 0); + } + + /** + * Evaluates the curve for every bit in a secret key. + * + * @param s The 32-byte secret key. + */ + private void evalCurve(final byte[] s) { + int sposn = 31; + int sbit = 6; + int svalue = s[sposn] | 0x40; + int swap = 0; + + // Iterate over all 255 bits of "s" from the highest to the lowest. + // We ignore the high bit of the 256-bit representation of "s". + while (true) { + // Conditional swaps on entry to this bit but only if we + // didn't swap on the previous bit. + final int select = (svalue >> sbit) & 0x01; + swap ^= select; + cswap(swap, x_2, x_3); + cswap(swap, z_2, z_3); + swap = select; + + // Evaluate the curve. + add(A, x_2, z_2); // A = x_2 + z_2 + square(AA, A); // AA = A^2 + sub(B, x_2, z_2); // B = x_2 - z_2 + square(BB, B); // BB = B^2 + sub(E, AA, BB); // E = AA - BB + add(C, x_3, z_3); // C = x_3 + z_3 + sub(D, x_3, z_3); // D = x_3 - z_3 + mul(DA, D, A); // DA = D * A + mul(CB, C, B); // CB = C * B + add(x_3, DA, CB); // x_3 = (DA + CB)^2 + square(x_3, x_3); + sub(z_3, DA, CB); // z_3 = x_1 * (DA - CB)^2 + square(z_3, z_3); + mul(z_3, z_3, x_1); + mul(x_2, AA, BB); // x_2 = AA * BB + mulA24(z_2, E); // z_2 = E * (AA + a24 * E) + add(z_2, z_2, AA); + mul(z_2, z_2, E); + + // Move onto the next lower bit of "s". + if (sbit > 0) { + --sbit; + } else if (sposn == 0) { + break; + } else if (sposn == 1) { + --sposn; + svalue = s[sposn] & 0xF8; + sbit = 7; + } else { + --sposn; + svalue = s[sposn]; + sbit = 7; + } + } + + // Final conditional swaps. + cswap(swap, x_2, x_3); + cswap(swap, z_2, z_3); + } + + /** + * Multiplies two numbers modulo 2^255 - 19. + * + * @param result The result. + * @param x The first number to multiply. + * @param y The second number to multiply. + */ + private void mul(final int[] result, final int[] x, final int[] y) { + // Multiply the two numbers to create the intermediate result. + long v = x[0]; + for (int i = 0; i < NUM_LIMBS_255BIT; ++i) { + t1[i] = v * y[i]; + } + for (int i = 1; i < NUM_LIMBS_255BIT; ++i) { + v = x[i]; + for (int j = 0; j < (NUM_LIMBS_255BIT - 1); ++j) { + t1[i + j] += v * y[j]; + } + t1[i + NUM_LIMBS_255BIT - 1] = v * y[NUM_LIMBS_255BIT - 1]; + } + + // Propagate carries and convert back into 26-bit words. + v = t1[0]; + t2[0] = ((int) v) & 0x03FFFFFF; + for (int i = 1; i < NUM_LIMBS_510BIT; ++i) { + v = (v >> 26) + t1[i]; + t2[i] = ((int) v) & 0x03FFFFFF; + } + + // Reduce the result modulo 2^255 - 19. + reduce(result, t2, NUM_LIMBS_255BIT); + } + + /** + * Multiplies a number by the a24 constant, modulo 2^255 - 19. + * + * @param result The result. + * @param x The number to multiply by a24. + */ + private void mulA24(final int[] result, final int[] x) { + final long a24 = 121665; + long carry = 0; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) { + carry += a24 * x[index]; + t2[index] = ((int) carry) & 0x03FFFFFF; + carry >>= 26; + } + t2[NUM_LIMBS_255BIT] = ((int) carry) & 0x03FFFFFF; + reduce(result, t2, 1); + } + + /** + * Raise x to the power of (2^250 - 1). + * + * @param result The result. Must not overlap with x. + * @param x The argument. + */ + private void pow250(final int[] result, final int[] x) { + // The big-endian hexadecimal expansion of (2^250 - 1) is: + // 03FFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF + // + // The naive implementation needs to do 2 multiplications per 1 bit and + // 1 multiplication per 0 bit. We can improve upon this by creating a + // pattern 0000000001 ... 0000000001. If we square and multiply the + // pattern by itself we can turn the pattern into the partial results + // 0000000011 ... 0000000011, 0000000111 ... 0000000111, etc. + // This averages out to about 1.1 multiplications per 1 bit instead of 2. + + // Build a pattern of 250 bits in length of repeated copies of 0000000001. + square(A, x); + for (int j = 0; j < 9; ++j) + square(A, A); + mul(result, A, x); + for (int i = 0; i < 23; ++i) { + for (int j = 0; j < 10; ++j) + square(A, A); + mul(result, result, A); + } + + // Multiply bit-shifted versions of the 0000000001 pattern into + // the result to "fill in" the gaps in the pattern. + square(A, result); + mul(result, result, A); + for (int j = 0; j < 8; ++j) { + square(A, A); + mul(result, result, A); + } + } + + /** + * Computes the reciprocal of a number modulo 2^255 - 19. + * + * @param result The result. Must not overlap with x. + * @param x The argument. + */ + private void recip(final int[] result, final int[] x) { + // The reciprocal is the same as x ^ (p - 2) where p = 2^255 - 19. + // The big-endian hexadecimal expansion of (p - 2) is: + // 7FFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFEB + // Start with the 250 upper bits of the expansion of (p - 2). + pow250(result, x); + + // Deal with the 5 lowest bits of (p - 2), 01011, from highest to lowest. + square(result, result); + square(result, result); + mul(result, result, x); + square(result, result); + square(result, result); + mul(result, result, x); + square(result, result); + mul(result, result, x); + } + + /** + * Reduce a number modulo 2^255 - 19. + * + * @param result The result. + * @param x The value to be reduced. This array will be + * modified during the reduction. + * @param size The number of limbs in the high order half of x. + */ + private void reduce(final int[] result, final int[] x, final int size) { + // Calculate (x mod 2^255) + ((x / 2^255) * 19) which will + // either produce the answer we want or it will produce a + // value of the form "answer + j * (2^255 - 19)". There are + // 5 left-over bits in the top-most limb of the bottom half. + int carry = 0; + int limb = x[NUM_LIMBS_255BIT - 1] >> 21; + x[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF; + for (int index = 0; index < size; ++index) { + limb += x[NUM_LIMBS_255BIT + index] << 5; + carry += (limb & 0x03FFFFFF) * 19 + x[index]; + x[index] = carry & 0x03FFFFFF; + limb >>= 26; + carry >>= 26; + } + if (size < NUM_LIMBS_255BIT) { + // The high order half of the number is short; e.g. for mulA24(). + // Propagate the carry through the rest of the low order part. + for (int index = size; index < NUM_LIMBS_255BIT; ++index) { + carry += x[index]; + x[index] = carry & 0x03FFFFFF; + carry >>= 26; + } + } + + // The "j" value may still be too large due to the final carry-out. + // We must repeat the reduction. If we already have the answer, + // then this won't do any harm but we must still do the calculation + // to preserve the overall timing. The "j" value will be between + // 0 and 19, which means that the carry we care about is in the + // top 5 bits of the highest limb of the bottom half. + carry = (x[NUM_LIMBS_255BIT - 1] >> 21) * 19; + x[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) { + carry += x[index]; + result[index] = carry & 0x03FFFFFF; + carry >>= 26; + } + + // At this point "x" will either be the answer or it will be the + // answer plus (2^255 - 19). Perform a trial subtraction to + // complete the reduction process. + reduceQuick(result); + } + + /** + * Reduces a number modulo 2^255 - 19 where it is known that the + * number can be reduced with only 1 trial subtraction. + * + * @param x The number to reduce, and the result. + */ + private void reduceQuick(final int[] x) { + // Perform a trial subtraction of (2^255 - 19) from "x" which is + // equivalent to adding 19 and subtracting 2^255. We add 19 here; + // the subtraction of 2^255 occurs in the next step. + int carry = 19; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) { + carry += x[index]; + t2[index] = carry & 0x03FFFFFF; + carry >>= 26; + } + + // If there was a borrow, then the original "x" is the correct answer. + // If there was no borrow, then "t2" is the correct answer. Select the + // correct answer but do it in a way that instruction timing will not + // reveal which value was selected. Borrow will occur if bit 21 of + // "t2" is zero. Turn the bit into a selection mask. + final int mask = -((t2[NUM_LIMBS_255BIT - 1] >> 21) & 0x01); + final int nmask = ~mask; + t2[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) + x[index] = (x[index] & nmask) | (t2[index] & mask); + } + + /** + * Squares a number modulo 2^255 - 19. + * + * @param result The result. + * @param x The number to square. + */ + private void square(final int[] result, final int[] x) { + mul(result, x, x); + } +} diff --git a/tunnel/src/main/java/com/wireguard/crypto/Key.java b/tunnel/src/main/java/com/wireguard/crypto/Key.java new file mode 100644 index 0000000..1aff670 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/crypto/Key.java @@ -0,0 +1,290 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.crypto; + +import com.wireguard.crypto.KeyFormatException.Type; +import com.wireguard.util.NonNullForAll; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; + +/** + * Represents a WireGuard public or private key. This class uses specialized constant-time base64 + * and hexadecimal codec implementations that resist side-channel attacks. + *

+ * Instances of this class are immutable. + */ +@SuppressWarnings("MagicNumber") +@NonNullForAll +public final class Key { + private final byte[] key; + + /** + * Constructs an object encapsulating the supplied key. + * + * @param key an array of bytes containing a binary key. Callers of this constructor are + * responsible for ensuring that the array is of the correct length. + */ + private Key(final byte[] key) { + // Defensively copy to ensure immutability. + this.key = Arrays.copyOf(key, key.length); + } + + /** + * Decodes a single 4-character base64 chunk to an integer in constant time. + * + * @param src an array of at least 4 characters in base64 format + * @param srcOffset the offset of the beginning of the chunk in {@code src} + * @return the decoded 3-byte integer, or some arbitrary integer value if the input was not + * valid base64 + */ + private static int decodeBase64(final char[] src, final int srcOffset) { + int val = 0; + for (int i = 0; i < 4; ++i) { + final char c = src[i + srcOffset]; + val |= (-1 + + ((((('A' - 1) - c) & (c - ('Z' + 1))) >>> 8) & (c - 64)) + + ((((('a' - 1) - c) & (c - ('z' + 1))) >>> 8) & (c - 70)) + + ((((('0' - 1) - c) & (c - ('9' + 1))) >>> 8) & (c + 5)) + + ((((('+' - 1) - c) & (c - ('+' + 1))) >>> 8) & 63) + + ((((('/' - 1) - c) & (c - ('/' + 1))) >>> 8) & 64) + ) << (18 - 6 * i); + } + return val; + } + + /** + * Encodes a single 4-character base64 chunk from 3 consecutive bytes in constant time. + * + * @param src an array of at least 3 bytes + * @param srcOffset the offset of the beginning of the chunk in {@code src} + * @param dest an array of at least 4 characters + * @param destOffset the offset of the beginning of the chunk in {@code dest} + */ + private static void encodeBase64(final byte[] src, final int srcOffset, + final char[] dest, final int destOffset) { + final byte[] input = { + (byte) ((src[srcOffset] >>> 2) & 63), + (byte) ((src[srcOffset] << 4 | ((src[1 + srcOffset] & 0xff) >>> 4)) & 63), + (byte) ((src[1 + srcOffset] << 2 | ((src[2 + srcOffset] & 0xff) >>> 6)) & 63), + (byte) ((src[2 + srcOffset]) & 63), + }; + for (int i = 0; i < 4; ++i) { + dest[i + destOffset] = (char) (input[i] + 'A' + + (((25 - input[i]) >>> 8) & 6) + - (((51 - input[i]) >>> 8) & 75) + - (((61 - input[i]) >>> 8) & 15) + + (((62 - input[i]) >>> 8) & 3)); + } + } + + /** + * Decodes a WireGuard public or private key from its base64 string representation. This + * function throws a {@link KeyFormatException} if the source string is not well-formed. + * + * @param str the base64 string representation of a WireGuard key + * @return the decoded key encapsulated in an immutable container + */ + public static Key fromBase64(final String str) throws KeyFormatException { + final char[] input = str.toCharArray(); + if (input.length != Format.BASE64.length || input[Format.BASE64.length - 1] != '=') + throw new KeyFormatException(Format.BASE64, Type.LENGTH); + final byte[] key = new byte[Format.BINARY.length]; + int i; + int ret = 0; + for (i = 0; i < key.length / 3; ++i) { + final int val = decodeBase64(input, i * 4); + ret |= val >>> 31; + key[i * 3] = (byte) ((val >>> 16) & 0xff); + key[i * 3 + 1] = (byte) ((val >>> 8) & 0xff); + key[i * 3 + 2] = (byte) (val & 0xff); + } + final char[] endSegment = { + input[i * 4], + input[i * 4 + 1], + input[i * 4 + 2], + 'A', + }; + final int val = decodeBase64(endSegment, 0); + ret |= (val >>> 31) | (val & 0xff); + key[i * 3] = (byte) ((val >>> 16) & 0xff); + key[i * 3 + 1] = (byte) ((val >>> 8) & 0xff); + + if (ret != 0) + throw new KeyFormatException(Format.BASE64, Type.CONTENTS); + return new Key(key); + } + + /** + * Wraps a WireGuard public or private key in an immutable container. This function throws a + * {@link KeyFormatException} if the source data is not the correct length. + * + * @param bytes an array of bytes containing a WireGuard key in binary format + * @return the key encapsulated in an immutable container + */ + public static Key fromBytes(final byte[] bytes) throws KeyFormatException { + if (bytes.length != Format.BINARY.length) + throw new KeyFormatException(Format.BINARY, Type.LENGTH); + return new Key(bytes); + } + + /** + * Decodes a WireGuard public or private key from its hexadecimal string representation. This + * function throws a {@link KeyFormatException} if the source string is not well-formed. + * + * @param str the hexadecimal string representation of a WireGuard key + * @return the decoded key encapsulated in an immutable container + */ + public static Key fromHex(final String str) throws KeyFormatException { + final char[] input = str.toCharArray(); + if (input.length != Format.HEX.length) + throw new KeyFormatException(Format.HEX, Type.LENGTH); + final byte[] key = new byte[Format.BINARY.length]; + int ret = 0; + for (int i = 0; i < key.length; ++i) { + int c; + int cNum; + int cNum0; + int cAlpha; + int cAlpha0; + int cVal; + final int cAcc; + + c = input[i * 2]; + cNum = c ^ 48; + cNum0 = ((cNum - 10) >>> 8) & 0xff; + cAlpha = (c & ~32) - 55; + cAlpha0 = (((cAlpha - 10) ^ (cAlpha - 16)) >>> 8) & 0xff; + ret |= ((cNum0 | cAlpha0) - 1) >>> 8; + cVal = (cNum0 & cNum) | (cAlpha0 & cAlpha); + cAcc = cVal * 16; + + c = input[i * 2 + 1]; + cNum = c ^ 48; + cNum0 = ((cNum - 10) >>> 8) & 0xff; + cAlpha = (c & ~32) - 55; + cAlpha0 = (((cAlpha - 10) ^ (cAlpha - 16)) >>> 8) & 0xff; + ret |= ((cNum0 | cAlpha0) - 1) >>> 8; + cVal = (cNum0 & cNum) | (cAlpha0 & cAlpha); + key[i] = (byte) (cAcc | cVal); + } + if (ret != 0) + throw new KeyFormatException(Format.HEX, Type.CONTENTS); + return new Key(key); + } + + /** + * Generates a private key using the system's {@link SecureRandom} number generator. + * + * @return a well-formed random private key + */ + static Key generatePrivateKey() { + final SecureRandom secureRandom = new SecureRandom(); + final byte[] privateKey = new byte[Format.BINARY.getLength()]; + secureRandom.nextBytes(privateKey); + privateKey[0] &= 248; + privateKey[31] &= 127; + privateKey[31] |= 64; + return new Key(privateKey); + } + + /** + * Generates a public key from an existing private key. + * + * @param privateKey a private key + * @return a well-formed public key that corresponds to the supplied private key + */ + static Key generatePublicKey(final Key privateKey) { + final byte[] publicKey = new byte[Format.BINARY.getLength()]; + Curve25519.eval(publicKey, 0, privateKey.getBytes(), null); + return new Key(publicKey); + } + + @Override + public boolean equals(final Object obj) { + if (obj == this) + return true; + if (obj == null || obj.getClass() != getClass()) + return false; + final Key other = (Key) obj; + return MessageDigest.isEqual(key, other.key); + } + + /** + * Returns the key as an array of bytes. + * + * @return an array of bytes containing the raw binary key + */ + public byte[] getBytes() { + // Defensively copy to ensure immutability. + return Arrays.copyOf(key, key.length); + } + + @Override + public int hashCode() { + int ret = 0; + for (int i = 0; i < key.length / 4; ++i) + ret ^= (key[i * 4 + 0] >> 0) + (key[i * 4 + 1] >> 8) + (key[i * 4 + 2] >> 16) + (key[i * 4 + 3] >> 24); + return ret; + } + + /** + * Encodes the key to base64. + * + * @return a string containing the encoded key + */ + public String toBase64() { + final char[] output = new char[Format.BASE64.length]; + int i; + for (i = 0; i < key.length / 3; ++i) + encodeBase64(key, i * 3, output, i * 4); + final byte[] endSegment = { + key[i * 3], + key[i * 3 + 1], + 0, + }; + encodeBase64(endSegment, 0, output, i * 4); + output[Format.BASE64.length - 1] = '='; + return new String(output); + } + + /** + * Encodes the key to hexadecimal ASCII characters. + * + * @return a string containing the encoded key + */ + public String toHex() { + final char[] output = new char[Format.HEX.length]; + for (int i = 0; i < key.length; ++i) { + output[i * 2] = (char) (87 + (key[i] >> 4 & 0xf) + + ((((key[i] >> 4 & 0xf) - 10) >> 8) & ~38)); + output[i * 2 + 1] = (char) (87 + (key[i] & 0xf) + + ((((key[i] & 0xf) - 10) >> 8) & ~38)); + } + return new String(output); + } + + /** + * The supported formats for encoding a WireGuard key. + */ + public enum Format { + BASE64(44), + BINARY(32), + HEX(64); + + private final int length; + + Format(final int length) { + this.length = length; + } + + public int getLength() { + return length; + } + } + +} diff --git a/tunnel/src/main/java/com/wireguard/crypto/KeyFormatException.java b/tunnel/src/main/java/com/wireguard/crypto/KeyFormatException.java new file mode 100644 index 0000000..b1503f2 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/crypto/KeyFormatException.java @@ -0,0 +1,37 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.crypto; + +import com.wireguard.util.NonNullForAll; + +/** + * An exception thrown when attempting to parse an invalid key (too short, too long, or byte + * data inappropriate for the format). The format being parsed can be accessed with the + * {@link #getFormat} method. + */ +@NonNullForAll +public final class KeyFormatException extends Exception { + private final Key.Format format; + private final Type type; + + KeyFormatException(final Key.Format format, final Type type) { + this.format = format; + this.type = type; + } + + public Key.Format getFormat() { + return format; + } + + public Type getType() { + return type; + } + + public enum Type { + CONTENTS, + LENGTH + } +} diff --git a/tunnel/src/main/java/com/wireguard/crypto/KeyPair.java b/tunnel/src/main/java/com/wireguard/crypto/KeyPair.java new file mode 100644 index 0000000..85f94ca --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/crypto/KeyPair.java @@ -0,0 +1,54 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.crypto; + +import com.wireguard.util.NonNullForAll; + +/** + * Represents a Curve25519 key pair as used by WireGuard. + *

+ * Instances of this class are immutable. + */ +@NonNullForAll +public class KeyPair { + private final Key privateKey; + private final Key publicKey; + + /** + * Creates a key pair using a newly-generated private key. + */ + public KeyPair() { + this(Key.generatePrivateKey()); + } + + /** + * Creates a key pair using an existing private key. + * + * @param privateKey a private key, used to derive the public key + */ + public KeyPair(final Key privateKey) { + this.privateKey = privateKey; + publicKey = Key.generatePublicKey(privateKey); + } + + /** + * Returns the private key from the key pair. + * + * @return the private key + */ + public Key getPrivateKey() { + return privateKey; + } + + /** + * Returns the public key from the key pair. + * + * @return the public key + */ + public Key getPublicKey() { + return publicKey; + } +} diff --git a/tunnel/src/main/java/com/wireguard/util/NonNullForAll.java b/tunnel/src/main/java/com/wireguard/util/NonNullForAll.java new file mode 100644 index 0000000..a6598fd --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/util/NonNullForAll.java @@ -0,0 +1,29 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import javax.annotation.Nonnull; +import javax.annotation.meta.TypeQualifierDefault; + +import androidx.annotation.RestrictTo; +import androidx.annotation.RestrictTo.Scope; + +/** + * This annotation can be applied to a package, class or method to indicate that all + * class fields and method parameters and return values in that element are nonnull + * by default unless overridden. + */ +@RestrictTo(Scope.LIBRARY_GROUP) +@Nonnull +@TypeQualifierDefault({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) + +public @interface NonNullForAll { +} diff --git a/tunnel/src/test/java/com/wireguard/config/BadConfigExceptionTest.java b/tunnel/src/test/java/com/wireguard/config/BadConfigExceptionTest.java new file mode 100644 index 0000000..59badad --- /dev/null +++ b/tunnel/src/test/java/com/wireguard/config/BadConfigExceptionTest.java @@ -0,0 +1,173 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import com.wireguard.config.BadConfigException.Location; +import com.wireguard.config.BadConfigException.Reason; +import com.wireguard.config.BadConfigException.Section; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class BadConfigExceptionTest { + private static final Map CONFIG_MAP = new HashMap<>(); + private static final String[] CONFIG_NAMES = { + "invalid-key", + "invalid-number", + "invalid-value", + "missing-attribute", + "missing-section", + "syntax-error", + "unknown-attribute", + "unknown-section" + }; + + @AfterClass + public static void closeStreams() { + for (final InputStream inputStream : CONFIG_MAP.values()) { + try { + inputStream.close(); + } catch (final IOException ignored) { + } + } + } + + @BeforeClass + public static void readConfigs() { + for (final String config : CONFIG_NAMES) { + CONFIG_MAP.put(config, BadConfigExceptionTest.class.getClassLoader().getResourceAsStream(config + ".conf")); + } + } + + @Test + public void throws_correctly_with_INVALID_KEY_reason() { + try { + Config.parse(CONFIG_MAP.get("invalid-key")); + fail("Config parsing must fail in this test"); + } catch (final BadConfigException e) { + assertEquals(e.getReason(), Reason.INVALID_KEY); + assertEquals(e.getLocation(), Location.PUBLIC_KEY); + assertEquals(e.getSection(), Section.PEER); + } catch (final IOException e) { + e.printStackTrace(); + fail("IOException thrown during test"); + } + } + + @Test + public void throws_correctly_with_INVALID_NUMBER_reason() { + try { + Config.parse(CONFIG_MAP.get("invalid-number")); + fail("Config parsing must fail in this test"); + } catch (final BadConfigException e) { + assertEquals(e.getReason(), Reason.INVALID_NUMBER); + assertEquals(e.getLocation(), Location.PERSISTENT_KEEPALIVE); + assertEquals(e.getSection(), Section.PEER); + } catch (final IOException e) { + e.printStackTrace(); + fail("IOException thrown during test"); + } + } + + @Test + public void throws_correctly_with_INVALID_VALUE_reason() { + try { + Config.parse(CONFIG_MAP.get("invalid-value")); + fail("Config parsing must fail in this test"); + } catch (final BadConfigException e) { + assertEquals(e.getReason(), Reason.INVALID_VALUE); + assertEquals(e.getLocation(), Location.DNS); + assertEquals(e.getSection(), Section.INTERFACE); + } catch (final IOException e) { + e.printStackTrace(); + fail("IOException throwing during test"); + } + } + + @Test + public void throws_correctly_with_MISSING_ATTRIBUTE_reason() { + try { + Config.parse(CONFIG_MAP.get("missing-attribute")); + fail("Config parsing must fail in this test"); + } catch (final BadConfigException e) { + assertEquals(e.getReason(), Reason.MISSING_ATTRIBUTE); + assertEquals(e.getLocation(), Location.PUBLIC_KEY); + assertEquals(e.getSection(), Section.PEER); + } catch (final IOException e) { + e.printStackTrace(); + fail("IOException throwing during test"); + } + } + + @Test + public void throws_correctly_with_MISSING_SECTION_reason() { + try { + Config.parse(CONFIG_MAP.get("missing-section")); + fail("Config parsing must fail in this test"); + } catch (final BadConfigException e) { + assertEquals(e.getReason(), Reason.MISSING_SECTION); + assertEquals(e.getLocation(), Location.TOP_LEVEL); + assertEquals(e.getSection(), Section.CONFIG); + } catch (final IOException e) { + e.printStackTrace(); + fail("IOException throwing during test"); + } + } + + @Test + public void throws_correctly_with_SYNTAX_ERROR_reason() { + try { + Config.parse(CONFIG_MAP.get("syntax-error")); + fail("Config parsing must fail in this test"); + } catch (final BadConfigException e) { + assertEquals(e.getReason(), Reason.SYNTAX_ERROR); + assertEquals(e.getLocation(), Location.TOP_LEVEL); + assertEquals(e.getSection(), Section.PEER); + } catch (final IOException e) { + e.printStackTrace(); + fail("IOException throwing during test"); + } + } + + @Test + public void throws_correctly_with_UNKNOWN_ATTRIBUTE_reason() { + try { + Config.parse(CONFIG_MAP.get("unknown-attribute")); + fail("Config parsing must fail in this test"); + } catch (final BadConfigException e) { + assertEquals(e.getReason(), Reason.UNKNOWN_ATTRIBUTE); + assertEquals(e.getLocation(), Location.TOP_LEVEL); + assertEquals(e.getSection(), Section.PEER); + } catch (final IOException e) { + e.printStackTrace(); + fail("IOException throwing during test"); + } + } + + @Test + public void throws_correctly_with_UNKNOWN_SECTION_reason() { + try { + Config.parse(CONFIG_MAP.get("unknown-section")); + fail("Config parsing must fail in this test"); + } catch (final BadConfigException e) { + assertEquals(e.getReason(), Reason.UNKNOWN_SECTION); + assertEquals(e.getLocation(), Location.TOP_LEVEL); + assertEquals(e.getSection(), Section.CONFIG); + } catch (final IOException e) { + e.printStackTrace(); + fail("IOException throwing during test"); + } + } +} diff --git a/tunnel/src/test/java/com/wireguard/config/ConfigTest.java b/tunnel/src/test/java/com/wireguard/config/ConfigTest.java new file mode 100644 index 0000000..582c7ac --- /dev/null +++ b/tunnel/src/test/java/com/wireguard/config/ConfigTest.java @@ -0,0 +1,49 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Objects; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ConfigTest { + + @Test(expected = BadConfigException.class) + public void invalid_config_throws() throws IOException, BadConfigException { + try (final InputStream is = Objects.requireNonNull(getClass().getClassLoader()).getResourceAsStream("broken.conf")) { + Config.parse(is); + } + } + + @Test + public void valid_config_parses_correctly() throws IOException, ParseException { + Config config = null; + final Collection expectedAllowedIps = new HashSet<>(Arrays.asList(InetNetwork.parse("0.0.0.0/0"), InetNetwork.parse("::0/0"))); + try (final InputStream is = Objects.requireNonNull(getClass().getClassLoader()).getResourceAsStream("working.conf")) { + config = Config.parse(is); + } catch (final BadConfigException e) { + fail("'working.conf' should never fail to parse"); + } + assertNotNull("config cannot be null after parsing", config); + assertTrue( + "No applications should be excluded by default", + config.getInterface().getExcludedApplications().isEmpty() + ); + assertEquals("Test config has exactly one peer", 1, config.getPeers().size()); + assertEquals("Test config's allowed IPs are 0.0.0.0/0 and ::0/0", config.getPeers().get(0).getAllowedIps(), expectedAllowedIps); + assertEquals("Test config has one DNS server", 1, config.getInterface().getDnsServers().size()); + } +} diff --git a/tunnel/src/test/resources/broken.conf b/tunnel/src/test/resources/broken.conf new file mode 100644 index 0000000..753c971 --- /dev/null +++ b/tunnel/src/test/resources/broken.conf @@ -0,0 +1,9 @@ +[Interface] +PrivateKey = l0lth1s1sd3f1n1t3lybr0k3n= +Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128 +DNS = 192.0.2.0 + +[Peer] +PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg= +AllowedIPs = 0.0.0.0/0,::0/0 +Endpoint = 192.0.2.1:51820 diff --git a/tunnel/src/test/resources/invalid-key.conf b/tunnel/src/test/resources/invalid-key.conf new file mode 100644 index 0000000..215bec3 --- /dev/null +++ b/tunnel/src/test/resources/invalid-key.conf @@ -0,0 +1,9 @@ +[Interface] +Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128 +DNS = 192.0.2.0 +PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo= +[Peer] +AllowedIPs = 0.0.0.0/0, ::0/0 +Endpoint = 192.0.2.1:51820 +PersistentKeepalive = 0 +PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6Og= diff --git a/tunnel/src/test/resources/invalid-number.conf b/tunnel/src/test/resources/invalid-number.conf new file mode 100644 index 0000000..f05fe32 --- /dev/null +++ b/tunnel/src/test/resources/invalid-number.conf @@ -0,0 +1,9 @@ +[Interface] +Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128 +DNS = 192.0.2.0 +PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo= +[Peer] +AllowedIPs = 0.0.0.0/0, ::0/0 +Endpoint = 192.0.2.1:51820 +PersistentKeepalive = 0L +PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg= diff --git a/tunnel/src/test/resources/invalid-value.conf b/tunnel/src/test/resources/invalid-value.conf new file mode 100644 index 0000000..6a1e3b6 --- /dev/null +++ b/tunnel/src/test/resources/invalid-value.conf @@ -0,0 +1,9 @@ +[Interface] +Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128 +DNS = 192.0.2.0,invalid_value +PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo= +[Peer] +AllowedIPs = 0.0.0.0/0, ::0/0 +Endpoint = 192.0.2.1:51820 +PersistentKeepalive = 0 +PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg= diff --git a/tunnel/src/test/resources/missing-attribute.conf b/tunnel/src/test/resources/missing-attribute.conf new file mode 100644 index 0000000..ddf8cbb --- /dev/null +++ b/tunnel/src/test/resources/missing-attribute.conf @@ -0,0 +1,8 @@ +[Interface] +Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128 +DNS = 192.0.2.0 +PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo= +[Peer] +AllowedIPs = 0.0.0.0/0, ::0/0 +Endpoint = 192.0.2.1:51820 +PersistentKeepalive = 0 diff --git a/tunnel/src/test/resources/missing-section.conf b/tunnel/src/test/resources/missing-section.conf new file mode 100644 index 0000000..676199a --- /dev/null +++ b/tunnel/src/test/resources/missing-section.conf @@ -0,0 +1,5 @@ +[Peer] +AllowedIPs = 0.0.0.0/0, ::0/0 +Endpoint = 192.0.2.1:51820 +PersistentKeepalive = 0 +PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg= diff --git a/tunnel/src/test/resources/syntax-error.conf b/tunnel/src/test/resources/syntax-error.conf new file mode 100644 index 0000000..38b8ec9 --- /dev/null +++ b/tunnel/src/test/resources/syntax-error.conf @@ -0,0 +1,9 @@ +[Interface] +Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128 +DNS = 192.0.2.0 +PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo= +[Peer] +AllowedIPs = 0.0.0.0/0, ::0/0 +Endpoint = +PersistentKeepalive = 0 +PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg= diff --git a/tunnel/src/test/resources/unknown-attribute.conf b/tunnel/src/test/resources/unknown-attribute.conf new file mode 100644 index 0000000..f311161 --- /dev/null +++ b/tunnel/src/test/resources/unknown-attribute.conf @@ -0,0 +1,9 @@ +[Interface] +Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128 +DNS = 192.0.2.0 +PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo= +[Peer] +AllowedIPs = 0.0.0.0/0, ::0/0 +Endpoint = 192.0.2.1:51820 +DontLetTheFeelingFade = 1 +PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg= diff --git a/tunnel/src/test/resources/unknown-section.conf b/tunnel/src/test/resources/unknown-section.conf new file mode 100644 index 0000000..579d971 --- /dev/null +++ b/tunnel/src/test/resources/unknown-section.conf @@ -0,0 +1,9 @@ +[Interface] +Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128 +DNS = 192.0.2.0 +PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo= +[Peers] +AllowedIPs = 0.0.0.0/0, ::0/0 +Endpoint = 192.0.2.1:51820 +PersistentKeepalive = 0 +PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg= diff --git a/tunnel/src/test/resources/working.conf b/tunnel/src/test/resources/working.conf new file mode 100644 index 0000000..3f9665c --- /dev/null +++ b/tunnel/src/test/resources/working.conf @@ -0,0 +1,9 @@ +[Interface] +Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128 +DNS = 192.0.2.0 +PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo= +[Peer] +AllowedIPs = 0.0.0.0/0, ::0/0 +Endpoint = 192.0.2.1:51820 +PersistentKeepalive = 0 +PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg= diff --git a/tunnel/tools/CMakeLists.txt b/tunnel/tools/CMakeLists.txt new file mode 100644 index 0000000..b62a163 --- /dev/null +++ b/tunnel/tools/CMakeLists.txt @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + +cmake_minimum_required(VERSION 3.4.1) +project("WireGuard") +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") +add_link_options(LINKER:--build-id=none) +add_compile_options(-Wall -Werror) + +add_executable(libwg-quick.so wireguard-tools/src/wg-quick/android.c ndk-compat/compat.c) +target_compile_options(libwg-quick.so PUBLIC -std=gnu11 -include ${CMAKE_CURRENT_SOURCE_DIR}/ndk-compat/compat.h -DWG_PACKAGE_NAME=\"${ANDROID_PACKAGE_NAME}\") +target_link_libraries(libwg-quick.so -ldl) + +file(GLOB WG_SOURCES wireguard-tools/src/*.c ndk-compat/compat.c) +add_executable(libwg.so ${WG_SOURCES}) +target_include_directories(libwg.so PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/wireguard-tools/src/uapi/linux/" "${CMAKE_CURRENT_SOURCE_DIR}/wireguard-tools/src/") +target_compile_options(libwg.so PUBLIC -std=gnu11 -include ${CMAKE_CURRENT_SOURCE_DIR}/ndk-compat/compat.h -DRUNSTATEDIR=\"/data/data/${ANDROID_PACKAGE_NAME}/cache\") + +add_custom_target(libwg-go.so WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/libwg-go" COMMENT "Building wireguard-go" VERBATIM COMMAND "${ANDROID_HOST_PREBUILTS}/bin/make" + ANDROID_ARCH_NAME=${ANDROID_ARCH_NAME} + ANDROID_PACKAGE_NAME=${ANDROID_PACKAGE_NAME} + GRADLE_USER_HOME=${GRADLE_USER_HOME} + CC=${CMAKE_C_COMPILER} + CFLAGS=${CMAKE_C_FLAGS} + LDFLAGS=${CMAKE_SHARED_LINKER_FLAGS} + SYSROOT=${CMAKE_SYSROOT} + TARGET=${CMAKE_C_COMPILER_TARGET} + DESTDIR=${CMAKE_LIBRARY_OUTPUT_DIRECTORY} + BUILDDIR=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/../generated-src +) + +# Strip unwanted ELF sections to prevent DT_FLAGS_1 warnings on old Android versions +file(GLOB ELF_CLEANER_SOURCES elf-cleaner/*.c elf-cleaner/*.cpp) +add_custom_target(elf-cleaner COMMENT "Building elf-cleaner" VERBATIM COMMAND cc + -O2 -DPACKAGE_NAME="elf-cleaner" -DPACKAGE_VERSION="" -DCOPYRIGHT="" + -o "${CMAKE_CURRENT_BINARY_DIR}/elf-cleaner" ${ELF_CLEANER_SOURCES} +) +add_custom_command(TARGET libwg.so POST_BUILD VERBATIM COMMAND "${CMAKE_CURRENT_BINARY_DIR}/elf-cleaner" + --api-level "${ANDROID_NATIVE_API_LEVEL}" "$") +add_dependencies(libwg.so elf-cleaner) +add_custom_command(TARGET libwg-quick.so POST_BUILD VERBATIM COMMAND "${CMAKE_CURRENT_BINARY_DIR}/elf-cleaner" + --api-level "${ANDROID_NATIVE_API_LEVEL}" "$") +add_dependencies(libwg-quick.so elf-cleaner) diff --git a/tunnel/tools/libwg-go/.gitignore b/tunnel/tools/libwg-go/.gitignore new file mode 100644 index 0000000..d163863 --- /dev/null +++ b/tunnel/tools/libwg-go/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/tunnel/tools/libwg-go/Makefile b/tunnel/tools/libwg-go/Makefile new file mode 100644 index 0000000..5b34355 --- /dev/null +++ b/tunnel/tools/libwg-go/Makefile @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + +BUILDDIR ?= $(CURDIR)/build +DESTDIR ?= $(CURDIR)/out + +NDK_GO_ARCH_MAP_x86 := 386 +NDK_GO_ARCH_MAP_x86_64 := amd64 +NDK_GO_ARCH_MAP_arm := arm +NDK_GO_ARCH_MAP_arm64 := arm64 +NDK_GO_ARCH_MAP_mips := mipsx +NDK_GO_ARCH_MAP_mips64 := mips64x + +comma := , +CLANG_FLAGS := --target=$(TARGET) --sysroot=$(SYSROOT) +export CGO_CFLAGS := $(CLANG_FLAGS) $(subst -mthumb,-marm,$(CFLAGS)) +export CGO_LDFLAGS := $(CLANG_FLAGS) $(patsubst -Wl$(comma)--build-id=%,-Wl$(comma)--build-id=none,$(LDFLAGS)) -Wl,-soname=libwg-go.so +export GOARCH := $(NDK_GO_ARCH_MAP_$(ANDROID_ARCH_NAME)) +export GOOS := android +export CGO_ENABLED := 1 + +GO_VERSION := 1.24.3 +GO_PLATFORM := $(shell uname -s | tr '[:upper:]' '[:lower:]')-$(NDK_GO_ARCH_MAP_$(shell uname -m)) +GO_TARBALL := go$(GO_VERSION).$(GO_PLATFORM).tar.gz +GO_HASH_darwin-amd64 := 13e6fe3fcf65689d77d40e633de1e31c6febbdbcb846eb05fc2434ed2213e92b +GO_HASH_darwin-arm64 := 64a3fa22142f627e78fac3018ce3d4aeace68b743eff0afda8aae0411df5e4fb +GO_HASH_linux-amd64 := 3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8 + +default: $(DESTDIR)/libwg-go.so + +$(GRADLE_USER_HOME)/caches/golang/$(GO_TARBALL): + mkdir -p "$(dir $@)" + flock "$@.lock" -c ' \ + [ -f "$@" ] && exit 0; \ + curl -o "$@.tmp" "https://dl.google.com/go/$(GO_TARBALL)" && \ + echo "$(GO_HASH_$(GO_PLATFORM)) $@.tmp" | sha256sum -c && \ + mv "$@.tmp" "$@"' + +$(BUILDDIR)/go-$(GO_VERSION)/.prepared: $(GRADLE_USER_HOME)/caches/golang/$(GO_TARBALL) + mkdir -p "$(dir $@)" + flock "$@.lock" -c ' \ + [ -f "$@" ] && exit 0; \ + tar -C "$(dir $@)" --strip-components=1 -xzf "$^" && \ + patch -p1 -f -N -r- -d "$(dir $@)" < goruntime-boottime-over-monotonic.diff && \ + touch "$@"' + +$(DESTDIR)/libwg-go.so: export PATH := $(BUILDDIR)/go-$(GO_VERSION)/bin/:$(PATH) +$(DESTDIR)/libwg-go.so: $(BUILDDIR)/go-$(GO_VERSION)/.prepared go.mod + go build -tags linux -ldflags="-X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/$(ANDROID_PACKAGE_NAME)/cache/wireguard -buildid=" -v -trimpath -buildvcs=false -o "$@" -buildmode c-shared + +.DELETE_ON_ERROR: diff --git a/tunnel/tools/libwg-go/api-android.go b/tunnel/tools/libwg-go/api-android.go new file mode 100644 index 0000000..d47c5d7 --- /dev/null +++ b/tunnel/tools/libwg-go/api-android.go @@ -0,0 +1,227 @@ +/* SPDX-License-Identifier: Apache-2.0 + * + * Copyright © 2017-2022 Jason A. Donenfeld . All Rights Reserved. + */ + +package main + +// #cgo LDFLAGS: -llog +// #include +import "C" + +import ( + "fmt" + "math" + "net" + "os" + "os/signal" + "runtime" + "runtime/debug" + "strings" + "unsafe" + + "golang.org/x/sys/unix" + "golang.zx2c4.com/wireguard/conn" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/ipc" + "golang.zx2c4.com/wireguard/tun" +) + +type AndroidLogger struct { + level C.int + tag *C.char +} + +func cstring(s string) *C.char { + b, err := unix.BytePtrFromString(s) + if err != nil { + b := [1]C.char{} + return &b[0] + } + return (*C.char)(unsafe.Pointer(b)) +} + +func (l AndroidLogger) Printf(format string, args ...interface{}) { + C.__android_log_write(l.level, l.tag, cstring(fmt.Sprintf(format, args...))) +} + +type TunnelHandle struct { + device *device.Device + uapi net.Listener +} + +var tunnelHandles map[int32]TunnelHandle + +func init() { + tunnelHandles = make(map[int32]TunnelHandle) + signals := make(chan os.Signal) + signal.Notify(signals, unix.SIGUSR2) + go func() { + buf := make([]byte, os.Getpagesize()) + for { + select { + case <-signals: + n := runtime.Stack(buf, true) + if n == len(buf) { + n-- + } + buf[n] = 0 + C.__android_log_write(C.ANDROID_LOG_ERROR, cstring("WireGuard/GoBackend/Stacktrace"), (*C.char)(unsafe.Pointer(&buf[0]))) + } + } + }() +} + +//export wgTurnOn +func wgTurnOn(interfaceName string, tunFd int32, settings string) int32 { + tag := cstring("WireGuard/GoBackend/" + interfaceName) + logger := &device.Logger{ + Verbosef: AndroidLogger{level: C.ANDROID_LOG_DEBUG, tag: tag}.Printf, + Errorf: AndroidLogger{level: C.ANDROID_LOG_ERROR, tag: tag}.Printf, + } + + tun, name, err := tun.CreateUnmonitoredTUNFromFD(int(tunFd)) + if err != nil { + unix.Close(int(tunFd)) + logger.Errorf("CreateUnmonitoredTUNFromFD: %v", err) + return -1 + } + + logger.Verbosef("Attaching to interface %v", name) + device := device.NewDevice(tun, conn.NewStdNetBind(), logger) + + err = device.IpcSet(settings) + if err != nil { + unix.Close(int(tunFd)) + logger.Errorf("IpcSet: %v", err) + return -1 + } + device.DisableSomeRoamingForBrokenMobileSemantics() + + var uapi net.Listener + + uapiFile, err := ipc.UAPIOpen(name) + if err != nil { + logger.Errorf("UAPIOpen: %v", err) + } else { + uapi, err = ipc.UAPIListen(name, uapiFile) + if err != nil { + uapiFile.Close() + logger.Errorf("UAPIListen: %v", err) + } else { + go func() { + for { + conn, err := uapi.Accept() + if err != nil { + return + } + go device.IpcHandle(conn) + } + }() + } + } + + err = device.Up() + if err != nil { + logger.Errorf("Unable to bring up device: %v", err) + uapiFile.Close() + device.Close() + return -1 + } + logger.Verbosef("Device started") + + var i int32 + for i = 0; i < math.MaxInt32; i++ { + if _, exists := tunnelHandles[i]; !exists { + break + } + } + if i == math.MaxInt32 { + logger.Errorf("Unable to find empty handle") + uapiFile.Close() + device.Close() + return -1 + } + tunnelHandles[i] = TunnelHandle{device: device, uapi: uapi} + return i +} + +//export wgTurnOff +func wgTurnOff(tunnelHandle int32) { + handle, ok := tunnelHandles[tunnelHandle] + if !ok { + return + } + delete(tunnelHandles, tunnelHandle) + if handle.uapi != nil { + handle.uapi.Close() + } + handle.device.Close() +} + +//export wgGetSocketV4 +func wgGetSocketV4(tunnelHandle int32) int32 { + handle, ok := tunnelHandles[tunnelHandle] + if !ok { + return -1 + } + bind, _ := handle.device.Bind().(conn.PeekLookAtSocketFd) + if bind == nil { + return -1 + } + fd, err := bind.PeekLookAtSocketFd4() + if err != nil { + return -1 + } + return int32(fd) +} + +//export wgGetSocketV6 +func wgGetSocketV6(tunnelHandle int32) int32 { + handle, ok := tunnelHandles[tunnelHandle] + if !ok { + return -1 + } + bind, _ := handle.device.Bind().(conn.PeekLookAtSocketFd) + if bind == nil { + return -1 + } + fd, err := bind.PeekLookAtSocketFd6() + if err != nil { + return -1 + } + return int32(fd) +} + +//export wgGetConfig +func wgGetConfig(tunnelHandle int32) *C.char { + handle, ok := tunnelHandles[tunnelHandle] + if !ok { + return nil + } + settings, err := handle.device.IpcGet() + if err != nil { + return nil + } + return C.CString(settings) +} + +//export wgVersion +func wgVersion() *C.char { + info, ok := debug.ReadBuildInfo() + if !ok { + return C.CString("unknown") + } + for _, dep := range info.Deps { + if dep.Path == "golang.zx2c4.com/wireguard" { + parts := strings.Split(dep.Version, "-") + if len(parts) == 3 && len(parts[2]) == 12 { + return C.CString(parts[2][:7]) + } + return C.CString(dep.Version) + } + } + return C.CString("unknown") +} + +func main() {} diff --git a/tunnel/tools/libwg-go/go.mod b/tunnel/tools/libwg-go/go.mod new file mode 100644 index 0000000..f6de8e1 --- /dev/null +++ b/tunnel/tools/libwg-go/go.mod @@ -0,0 +1,14 @@ +module golang.zx2c4.com/wireguard/android + +go 1.23.1 + +require ( + golang.org/x/sys v0.33.0 + golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb +) + +require ( + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect +) diff --git a/tunnel/tools/libwg-go/go.sum b/tunnel/tools/libwg-go/go.sum new file mode 100644 index 0000000..416d266 --- /dev/null +++ b/tunnel/tools/libwg-go/go.sum @@ -0,0 +1,16 @@ +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= +golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= diff --git a/tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff b/tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff new file mode 100644 index 0000000..5d78242 --- /dev/null +++ b/tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff @@ -0,0 +1,171 @@ +From 61f3ae8298d1c503cbc31539e0f3a73446c7db9d Mon Sep 17 00:00:00 2001 +From: "Jason A. Donenfeld" +Date: Tue, 21 Mar 2023 15:33:56 +0100 +Subject: [PATCH] [release-branch.go1.20] runtime: use CLOCK_BOOTTIME in + nanotime on Linux + +This makes timers account for having expired while a computer was +asleep, which is quite common on mobile devices. Note that BOOTTIME is +identical to MONOTONIC, except that it takes into account time spent +in suspend. In Linux 4.17, the kernel will actually make MONOTONIC act +like BOOTTIME anyway, so this switch will additionally unify the +timer behavior across kernels. + +BOOTTIME was introduced into Linux 2.6.39-rc1 with 70a08cca1227d in +2011. + +Fixes #24595 + +Change-Id: I7b2a6ca0c5bc5fce57ec0eeafe7b68270b429321 +--- + src/runtime/sys_linux_386.s | 4 ++-- + src/runtime/sys_linux_amd64.s | 2 +- + src/runtime/sys_linux_arm.s | 4 ++-- + src/runtime/sys_linux_arm64.s | 4 ++-- + src/runtime/sys_linux_mips64x.s | 4 ++-- + src/runtime/sys_linux_mipsx.s | 2 +- + src/runtime/sys_linux_ppc64x.s | 2 +- + src/runtime/sys_linux_s390x.s | 2 +- + 8 files changed, 12 insertions(+), 12 deletions(-) + +diff --git a/src/runtime/sys_linux_386.s b/src/runtime/sys_linux_386.s +index 12a294153d..17e3524b40 100644 +--- a/src/runtime/sys_linux_386.s ++++ b/src/runtime/sys_linux_386.s +@@ -352,13 +352,13 @@ noswitch: + + LEAL 8(SP), BX // &ts (struct timespec) + MOVL BX, 4(SP) +- MOVL $1, 0(SP) // CLOCK_MONOTONIC ++ MOVL $7, 0(SP) // CLOCK_BOOTTIME + CALL AX + JMP finish + + fallback: + MOVL $SYS_clock_gettime, AX +- MOVL $1, BX // CLOCK_MONOTONIC ++ MOVL $7, BX // CLOCK_BOOTTIME + LEAL 8(SP), CX + INVOKE_SYSCALL + +diff --git a/src/runtime/sys_linux_amd64.s b/src/runtime/sys_linux_amd64.s +index c7a89ba536..01f0a6a26e 100644 +--- a/src/runtime/sys_linux_amd64.s ++++ b/src/runtime/sys_linux_amd64.s +@@ -255,7 +255,7 @@ noswitch: + SUBQ $16, SP // Space for results + ANDQ $~15, SP // Align for C code + +- MOVL $1, DI // CLOCK_MONOTONIC ++ MOVL $7, DI // CLOCK_BOOTTIME + LEAQ 0(SP), SI + MOVQ runtime·vdsoClockgettimeSym(SB), AX + CMPQ AX, $0 +diff --git a/src/runtime/sys_linux_arm.s b/src/runtime/sys_linux_arm.s +index 7b8c4f0e04..9798a1334e 100644 +--- a/src/runtime/sys_linux_arm.s ++++ b/src/runtime/sys_linux_arm.s +@@ -11,7 +11,7 @@ + #include "textflag.h" + + #define CLOCK_REALTIME 0 +-#define CLOCK_MONOTONIC 1 ++#define CLOCK_BOOTTIME 7 + + // for EABI, as we don't support OABI + #define SYS_BASE 0x0 +@@ -374,7 +374,7 @@ finish: + + // func nanotime1() int64 + TEXT runtime·nanotime1(SB),NOSPLIT,$12-8 +- MOVW $CLOCK_MONOTONIC, R0 ++ MOVW $CLOCK_BOOTTIME, R0 + MOVW $spec-12(SP), R1 // timespec + + MOVW runtime·vdsoClockgettimeSym(SB), R4 +diff --git a/src/runtime/sys_linux_arm64.s b/src/runtime/sys_linux_arm64.s +index 38ff6ac330..6b819c5441 100644 +--- a/src/runtime/sys_linux_arm64.s ++++ b/src/runtime/sys_linux_arm64.s +@@ -14,7 +14,7 @@ + #define AT_FDCWD -100 + + #define CLOCK_REALTIME 0 +-#define CLOCK_MONOTONIC 1 ++#define CLOCK_BOOTTIME 7 + + #define SYS_exit 93 + #define SYS_read 63 +@@ -338,7 +338,7 @@ noswitch: + BIC $15, R1 + MOVD R1, RSP + +- MOVW $CLOCK_MONOTONIC, R0 ++ MOVW $CLOCK_BOOTTIME, R0 + MOVD runtime·vdsoClockgettimeSym(SB), R2 + CBZ R2, fallback + +diff --git a/src/runtime/sys_linux_mips64x.s b/src/runtime/sys_linux_mips64x.s +index 47f2da524d..a8b387f193 100644 +--- a/src/runtime/sys_linux_mips64x.s ++++ b/src/runtime/sys_linux_mips64x.s +@@ -326,7 +326,7 @@ noswitch: + AND $~15, R1 // Align for C code + MOVV R1, R29 + +- MOVW $1, R4 // CLOCK_MONOTONIC ++ MOVW $7, R4 // CLOCK_BOOTTIME + MOVV $0(R29), R5 + + MOVV runtime·vdsoClockgettimeSym(SB), R25 +@@ -336,7 +336,7 @@ noswitch: + // see walltime for detail + BEQ R2, R0, finish + MOVV R0, runtime·vdsoClockgettimeSym(SB) +- MOVW $1, R4 // CLOCK_MONOTONIC ++ MOVW $7, R4 // CLOCK_BOOTTIME + MOVV $0(R29), R5 + JMP fallback + +diff --git a/src/runtime/sys_linux_mipsx.s b/src/runtime/sys_linux_mipsx.s +index 5e6b6c1504..7f5fd2a80e 100644 +--- a/src/runtime/sys_linux_mipsx.s ++++ b/src/runtime/sys_linux_mipsx.s +@@ -243,7 +243,7 @@ TEXT runtime·walltime(SB),NOSPLIT,$8-12 + RET + + TEXT runtime·nanotime1(SB),NOSPLIT,$8-8 +- MOVW $1, R4 // CLOCK_MONOTONIC ++ MOVW $7, R4 // CLOCK_BOOTTIME + MOVW $4(R29), R5 + MOVW $SYS_clock_gettime, R2 + SYSCALL +diff --git a/src/runtime/sys_linux_ppc64x.s b/src/runtime/sys_linux_ppc64x.s +index d0427a4807..05ee9fede9 100644 +--- a/src/runtime/sys_linux_ppc64x.s ++++ b/src/runtime/sys_linux_ppc64x.s +@@ -298,7 +298,7 @@ fallback: + JMP return + + TEXT runtime·nanotime1(SB),NOSPLIT,$16-8 +- MOVD $1, R3 // CLOCK_MONOTONIC ++ MOVD $7, R3 // CLOCK_BOOTTIME + + MOVD R1, R15 // R15 is unchanged by C code + MOVD g_m(g), R21 // R21 = m +diff --git a/src/runtime/sys_linux_s390x.s b/src/runtime/sys_linux_s390x.s +index 1448670b91..7d2ee3231c 100644 +--- a/src/runtime/sys_linux_s390x.s ++++ b/src/runtime/sys_linux_s390x.s +@@ -296,7 +296,7 @@ fallback: + RET + + TEXT runtime·nanotime1(SB),NOSPLIT,$32-8 +- MOVW $1, R2 // CLOCK_MONOTONIC ++ MOVW $7, R2 // CLOCK_BOOTTIME + + MOVD R15, R7 // Backup stack pointer + +-- +2.17.1 + diff --git a/tunnel/tools/libwg-go/jni.c b/tunnel/tools/libwg-go/jni.c new file mode 100644 index 0000000..7ad94d3 --- /dev/null +++ b/tunnel/tools/libwg-go/jni.c @@ -0,0 +1,71 @@ +/* SPDX-License-Identifier: Apache-2.0 + * + * Copyright © 2017-2021 Jason A. Donenfeld . All Rights Reserved. + */ + +#include +#include +#include + +struct go_string { const char *str; long n; }; +extern int wgTurnOn(struct go_string ifname, int tun_fd, struct go_string settings); +extern void wgTurnOff(int handle); +extern int wgGetSocketV4(int handle); +extern int wgGetSocketV6(int handle); +extern char *wgGetConfig(int handle); +extern char *wgVersion(); + +JNIEXPORT jint JNICALL Java_com_wireguard_android_backend_GoBackend_wgTurnOn(JNIEnv *env, jclass c, jstring ifname, jint tun_fd, jstring settings) +{ + const char *ifname_str = (*env)->GetStringUTFChars(env, ifname, 0); + size_t ifname_len = (*env)->GetStringUTFLength(env, ifname); + const char *settings_str = (*env)->GetStringUTFChars(env, settings, 0); + size_t settings_len = (*env)->GetStringUTFLength(env, settings); + int ret = wgTurnOn((struct go_string){ + .str = ifname_str, + .n = ifname_len + }, tun_fd, (struct go_string){ + .str = settings_str, + .n = settings_len + }); + (*env)->ReleaseStringUTFChars(env, ifname, ifname_str); + (*env)->ReleaseStringUTFChars(env, settings, settings_str); + return ret; +} + +JNIEXPORT void JNICALL Java_com_wireguard_android_backend_GoBackend_wgTurnOff(JNIEnv *env, jclass c, jint handle) +{ + wgTurnOff(handle); +} + +JNIEXPORT jint JNICALL Java_com_wireguard_android_backend_GoBackend_wgGetSocketV4(JNIEnv *env, jclass c, jint handle) +{ + return wgGetSocketV4(handle); +} + +JNIEXPORT jint JNICALL Java_com_wireguard_android_backend_GoBackend_wgGetSocketV6(JNIEnv *env, jclass c, jint handle) +{ + return wgGetSocketV6(handle); +} + +JNIEXPORT jstring JNICALL Java_com_wireguard_android_backend_GoBackend_wgGetConfig(JNIEnv *env, jclass c, jint handle) +{ + jstring ret; + char *config = wgGetConfig(handle); + if (!config) + return NULL; + ret = (*env)->NewStringUTF(env, config); + free(config); + return ret; +} + +JNIEXPORT jstring JNICALL Java_com_wireguard_android_backend_GoBackend_wgVersion(JNIEnv *env, jclass c) +{ + jstring ret; + char *version = wgVersion(); + if (!version) + return NULL; + ret = (*env)->NewStringUTF(env, version); + free(version); + return ret; +} diff --git a/tunnel/tools/ndk-compat/compat.c b/tunnel/tools/ndk-compat/compat.c new file mode 100644 index 0000000..3c293e7 --- /dev/null +++ b/tunnel/tools/ndk-compat/compat.c @@ -0,0 +1,25 @@ +/* SPDX-License-Identifier: BSD + * + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * + */ + +#define FILE_IS_EMPTY + +#if defined(__ANDROID_MIN_SDK_VERSION__) && __ANDROID_MIN_SDK_VERSION__ < 24 +#undef FILE_IS_EMPTY +#include + +char *strchrnul(const char *s, int c) +{ + char *x = strchr(s, c); + if (!x) + return (char *)s + strlen(s); + return x; +} +#endif + +#ifdef FILE_IS_EMPTY +#undef FILE_IS_EMPTY +static char ____x __attribute__((unused)); +#endif diff --git a/tunnel/tools/ndk-compat/compat.h b/tunnel/tools/ndk-compat/compat.h new file mode 100644 index 0000000..9931c70 --- /dev/null +++ b/tunnel/tools/ndk-compat/compat.h @@ -0,0 +1,10 @@ +/* SPDX-License-Identifier: BSD + * + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * + */ + +#if defined(__ANDROID_MIN_SDK_VERSION__) && __ANDROID_MIN_SDK_VERSION__ < 24 +char *strchrnul(const char *s, int c); +#endif + diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts new file mode 100644 index 0000000..39bc753 --- /dev/null +++ b/ui/build.gradle.kts @@ -0,0 +1,93 @@ +@file:Suppress("UnstableApiUsage") + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +val pkg: String = providers.gradleProperty("wireguardPackageName").get() + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) +} + +android { + compileSdk = 36 + buildFeatures { + buildConfig = true + dataBinding = true + viewBinding = true + } + namespace = pkg + defaultConfig { + applicationId = pkg + minSdk = 24 + targetSdk = 36 + versionCode = providers.gradleProperty("wireguardVersionCode").get().toInt() + versionName = providers.gradleProperty("wireguardVersionName").get() + buildConfigField("int", "MIN_SDK_VERSION", minSdk.toString()) + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles("proguard-android-optimize.txt") + packaging { + resources { + excludes += "DebugProbesKt.bin" + excludes += "kotlin-tooling-metadata.json" + excludes += "META-INF/*.version" + } + } + } + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + } + create("googleplay") { + initWith(getByName("release")) + matchingFallbacks += "release" + } + } + androidResources { + generateLocaleConfig = true + } + lint { + disable += "LongLogTag" + warning += "MissingTranslation" + warning += "ImpliedQuantity" + } +} + +dependencies { + implementation(project(":tunnel")) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.annotation) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.coordinatorlayout) + implementation(libs.androidx.biometric) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.preference.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.datastore.preferences) + implementation(libs.google.material) + implementation(libs.zxing.android.embedded) + implementation(libs.kotlinx.coroutines.android) + coreLibraryDesugaring(libs.desugarJdkLibs) +} + +tasks.withType().configureEach { + options.compilerArgs.add("-Xlint:unchecked") + options.isDeprecation = true +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = JvmTarget.JVM_17 +} diff --git a/ui/proguard-android-optimize.txt b/ui/proguard-android-optimize.txt new file mode 100644 index 0000000..7bbc2b8 --- /dev/null +++ b/ui/proguard-android-optimize.txt @@ -0,0 +1,35 @@ +-allowaccessmodification +-dontusemixedcaseclassnames +-dontobfuscate +-verbose + +-keepattributes *Annotation* + +-keepclasseswithmembernames class * { + native ; +} + +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +-keepclassmembers class * implements android.os.Parcelable { + public static final ** CREATOR; +} + +-keep class androidx.annotation.Keep + +-keep @androidx.annotation.Keep class * {*;} + +-keepclasseswithmembers class * { + @androidx.annotation.Keep ; +} + +-keepclasseswithmembers class * { + @androidx.annotation.Keep ; +} + +-keepclasseswithmembers class * { + @androidx.annotation.Keep (...); +} diff --git a/ui/sampledata/interface_names.json b/ui/sampledata/interface_names.json new file mode 100644 index 0000000..1c41cb2 --- /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 0000000..947b738 --- /dev/null +++ b/ui/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + WireGuard β + diff --git a/ui/src/googleplay/AndroidManifest.xml b/ui/src/googleplay/AndroidManifest.xml new file mode 100644 index 0000000..28372d5 --- /dev/null +++ b/ui/src/googleplay/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..86c989b --- /dev/null +++ b/ui/src/main/AndroidManifest.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/java/com/wireguard/android/Application.kt b/ui/src/main/java/com/wireguard/android/Application.kt new file mode 100644 index 0000000..74eaccf --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/Application.kt @@ -0,0 +1,157 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.os.StrictMode.VmPolicy +import android.util.Log +import androidx.appcompat.app.AppCompatDelegate +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.google.android.material.color.DynamicColors +import com.wireguard.android.backend.Backend +import com.wireguard.android.backend.GoBackend +import com.wireguard.android.backend.WgQuickBackend +import com.wireguard.android.configStore.FileConfigStore +import com.wireguard.android.model.TunnelManager +import com.wireguard.android.updater.Updater +import com.wireguard.android.util.RootShell +import com.wireguard.android.util.ToolsInstaller +import com.wireguard.android.util.UserKnobs +import com.wireguard.android.util.applicationScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.lang.ref.WeakReference +import java.util.Locale + +class Application : android.app.Application() { + private val futureBackend = CompletableDeferred() + private val coroutineScope = CoroutineScope(Job() + Dispatchers.Main.immediate) + private var backend: Backend? = null + private lateinit var rootShell: RootShell + private lateinit var preferencesDataStore: DataStore + private lateinit var toolsInstaller: ToolsInstaller + private lateinit var tunnelManager: TunnelManager + + override fun attachBaseContext(context: Context) { + super.attachBaseContext(context) + if (BuildConfig.MIN_SDK_VERSION > Build.VERSION.SDK_INT) { + val intent = Intent(Intent.ACTION_MAIN) + intent.addCategory(Intent.CATEGORY_HOME) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + System.exit(0) + } + } + + private suspend fun determineBackend(): Backend { + var backend: Backend? = null + if (UserKnobs.enableKernelModule.first() && WgQuickBackend.hasKernelSupport()) { + try { + rootShell.start() + val wgQuickBackend = WgQuickBackend(applicationContext, rootShell, toolsInstaller) + wgQuickBackend.setMultipleTunnels(UserKnobs.multipleTunnels.first()) + backend = wgQuickBackend + UserKnobs.multipleTunnels.onEach { + wgQuickBackend.setMultipleTunnels(it) + }.launchIn(coroutineScope) + } catch (ignored: Exception) { + } + } + if (backend == null) { + backend = GoBackend(applicationContext) + GoBackend.setAlwaysOnCallback { get().applicationScope.launch { get().tunnelManager.restoreState(true) } } + } + return backend + } + + override fun onCreate() { + Log.i(TAG, USER_AGENT) + super.onCreate() + DynamicColors.applyToActivitiesIfAvailable(this) + rootShell = RootShell(applicationContext) + toolsInstaller = ToolsInstaller(applicationContext, rootShell) + preferencesDataStore = PreferenceDataStoreFactory.create { applicationContext.preferencesDataStoreFile("settings") } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + runBlocking { + AppCompatDelegate.setDefaultNightMode(if (UserKnobs.darkTheme.first()) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO) + } + UserKnobs.darkTheme.onEach { + val newMode = if (it) { + AppCompatDelegate.MODE_NIGHT_YES + } else { + AppCompatDelegate.MODE_NIGHT_NO + } + if (AppCompatDelegate.getDefaultNightMode() != newMode) { + AppCompatDelegate.setDefaultNightMode(newMode) + } + }.launchIn(coroutineScope) + } else { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + tunnelManager = TunnelManager(FileConfigStore(applicationContext)) + tunnelManager.onCreate() + coroutineScope.launch(Dispatchers.IO) { + try { + backend = determineBackend() + futureBackend.complete(backend!!) + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + Updater.monitorForUpdates() + + if (BuildConfig.DEBUG) { + StrictMode.setVmPolicy(VmPolicy.Builder().detectAll().penaltyLog().build()) + StrictMode.setThreadPolicy(ThreadPolicy.Builder().detectAll().penaltyLog().build()) + } + } + + override fun onTerminate() { + coroutineScope.cancel() + super.onTerminate() + } + + companion object { + val USER_AGENT = String.format(Locale.ENGLISH, "WireGuard/%s (Android %d; %s; %s; %s %s; %s)", BuildConfig.VERSION_NAME, Build.VERSION.SDK_INT, if (Build.SUPPORTED_ABIS.isNotEmpty()) Build.SUPPORTED_ABIS[0] else "unknown ABI", Build.BOARD, Build.MANUFACTURER, Build.MODEL, Build.FINGERPRINT) + private const val TAG = "WireGuard/Application" + private lateinit var weakSelf: WeakReference + + fun get(): Application { + return weakSelf.get()!! + } + + suspend fun getBackend() = get().futureBackend.await() + + fun getRootShell() = get().rootShell + + fun getPreferencesDataStore() = get().preferencesDataStore + + fun getToolsInstaller() = get().toolsInstaller + + fun getTunnelManager() = get().tunnelManager + + fun getCoroutineScope() = get().coroutineScope + } + + init { + weakSelf = WeakReference(this) + } +} diff --git a/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt new file mode 100644 index 0000000..59769df --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt @@ -0,0 +1,34 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.wireguard.android.backend.WgQuickBackend +import com.wireguard.android.util.applicationScope +import kotlinx.coroutines.launch + +class BootShutdownReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action ?: return + applicationScope.launch { + if (Application.getBackend() !is WgQuickBackend) return@launch + val tunnelManager = Application.getTunnelManager() + if (Intent.ACTION_BOOT_COMPLETED == action) { + Log.i(TAG, "Broadcast receiver restoring state (boot)") + tunnelManager.restoreState(false) + } else if (Intent.ACTION_SHUTDOWN == action) { + Log.i(TAG, "Broadcast receiver saving state (shutdown)") + tunnelManager.saveState() + } + } + } + + companion object { + private const val TAG = "WireGuard/BootShutdownReceiver" + } +} diff --git a/ui/src/main/java/com/wireguard/android/QuickTileService.kt b/ui/src/main/java/com/wireguard/android/QuickTileService.kt new file mode 100644 index 0000000..a849c48 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/QuickTileService.kt @@ -0,0 +1,203 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android + +import android.app.PendingIntent +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Build +import android.os.IBinder +import android.provider.Settings +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.databinding.Observable +import androidx.databinding.Observable.OnPropertyChangedCallback +import com.wireguard.android.activity.MainActivity +import com.wireguard.android.activity.TunnelToggleActivity +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.util.applicationScope +import com.wireguard.android.widget.SlashDrawable +import kotlinx.coroutines.launch + +/** + * Service that maintains the application's custom Quick Settings tile. This service is bound by the + * system framework as necessary to update the appearance of the tile in the system UI, and to + * forward click events to the application. + */ +class QuickTileService : TileService() { + private val onStateChangedCallback = OnStateChangedCallback() + private val onTunnelChangedCallback = OnTunnelChangedCallback() + private var iconOff: Icon? = null + private var iconOn: Icon? = null + private var tunnel: ObservableTunnel? = null + + /* This works around an annoying unsolved frameworks bug some people are hitting. */ + override fun onBind(intent: Intent): IBinder? { + var ret: IBinder? = null + try { + ret = super.onBind(intent) + } catch (e: Throwable) { + Log.d(TAG, "Failed to bind to TileService", e) + } + return ret + } + + override fun onClick() { + applicationScope.launch { + if (tunnel == null) { + Application.getTunnelManager().getTunnels() + updateTile() + } + when (val tunnel = tunnel) { + null -> { + Log.d(TAG, "No tunnel set, so launching main activity") + val intent = Intent(this@QuickTileService, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse(PendingIntent.getActivity(this@QuickTileService, 0, intent, PendingIntent.FLAG_IMMUTABLE)) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } + + else -> { + unlockAndRun { + applicationScope.launch { + try { + tunnel.setStateAsync(Tunnel.State.TOGGLE) + updateTile() + } catch (e: Throwable) { + Log.d(TAG, "Failed to set state, so falling back", e) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !Settings.canDrawOverlays(this@QuickTileService)) { + Log.d(TAG, "Need overlay permissions") + val permissionIntent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName")) + permissionIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivityAndCollapse( + PendingIntent.getActivity( + this@QuickTileService, + 0, + permissionIntent, + PendingIntent.FLAG_IMMUTABLE + ) + ) + return@launch + } + val toggleIntent = Intent(this@QuickTileService, TunnelToggleActivity::class.java) + toggleIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(toggleIntent) + } + } + } + } + } + } + } + + override fun onCreate() { + isAdded = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + iconOn = Icon.createWithResource(this, R.drawable.ic_tile) + iconOff = iconOn + return + } + val icon = SlashDrawable(resources.getDrawable(R.drawable.ic_tile, Application.get().theme)) + icon.setAnimationEnabled(false) /* Unfortunately we can't have animations, since Icons are marshaled. */ + icon.setSlashed(false) + var b = Bitmap.createBitmap(icon.intrinsicWidth, icon.intrinsicHeight, Bitmap.Config.ARGB_8888) + var c = Canvas(b) + icon.setBounds(0, 0, c.width, c.height) + icon.draw(c) + iconOn = Icon.createWithBitmap(b) + icon.setSlashed(true) + b = Bitmap.createBitmap(icon.intrinsicWidth, icon.intrinsicHeight, Bitmap.Config.ARGB_8888) + c = Canvas(b) + icon.setBounds(0, 0, c.width, c.height) + icon.draw(c) + iconOff = Icon.createWithBitmap(b) + } + + override fun onDestroy() { + super.onDestroy() + isAdded = false + } + + override fun onStartListening() { + Application.getTunnelManager().addOnPropertyChangedCallback(onTunnelChangedCallback) + tunnel?.addOnPropertyChangedCallback(onStateChangedCallback) + updateTile() + } + + override fun onStopListening() { + tunnel?.removeOnPropertyChangedCallback(onStateChangedCallback) + Application.getTunnelManager().removeOnPropertyChangedCallback(onTunnelChangedCallback) + } + + override fun onTileAdded() { + isAdded = true + } + + override fun onTileRemoved() { + isAdded = false + } + + private fun updateTile() { + // Update the tunnel. + val newTunnel = Application.getTunnelManager().lastUsedTunnel + if (newTunnel != tunnel) { + tunnel?.removeOnPropertyChangedCallback(onStateChangedCallback) + tunnel = newTunnel + tunnel?.addOnPropertyChangedCallback(onStateChangedCallback) + } + // Update the tile contents. + val tile = qsTile ?: return + + when (val tunnel = tunnel) { + null -> { + tile.label = getString(R.string.app_name) + tile.state = Tile.STATE_INACTIVE + tile.icon = iconOff + } + else -> { + tile.label = tunnel.name + tile.state = if (tunnel.state == Tunnel.State.UP) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + tile.icon = if (tunnel.state == Tunnel.State.UP) iconOn else iconOff + } + } + tile.updateTile() + } + + private inner class OnStateChangedCallback : OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + if (sender != tunnel) { + sender.removeOnPropertyChangedCallback(this) + return + } + if (propertyId != 0 && propertyId != BR.state) + return + updateTile() + } + } + + private inner class OnTunnelChangedCallback : OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + if (propertyId != 0 && propertyId != BR.lastUsedTunnel) + return + updateTile() + } + } + + companion object { + private const val TAG = "WireGuard/QuickTileService" + var isAdded: Boolean = false + private set + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt new file mode 100644 index 0000000..5ff1106 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt @@ -0,0 +1,96 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.CallbackRegistry +import androidx.databinding.CallbackRegistry.NotifierCallback +import androidx.lifecycle.lifecycleScope +import com.wireguard.android.Application +import com.wireguard.android.model.ObservableTunnel +import kotlinx.coroutines.launch + +/** + * Base class for activities that need to remember the currently-selected tunnel. + */ +abstract class BaseActivity : AppCompatActivity() { + private val selectionChangeRegistry = SelectionChangeRegistry() + private var created = false + var selectedTunnel: ObservableTunnel? = null + set(value) { + val oldTunnel = field + if (oldTunnel == value) return + field = value + if (created) { + if (!onSelectedTunnelChanged(oldTunnel, value)) { + field = oldTunnel + } else { + selectionChangeRegistry.notifyCallbacks(oldTunnel, 0, value) + } + } + } + + fun addOnSelectedTunnelChangedListener(listener: OnSelectedTunnelChangedListener) { + selectionChangeRegistry.add(listener) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Restore the saved tunnel if there is one; otherwise grab it from the arguments. + val savedTunnelName = when { + savedInstanceState != null -> savedInstanceState.getString(KEY_SELECTED_TUNNEL) + intent != null -> intent.getStringExtra(KEY_SELECTED_TUNNEL) + else -> null + } + if (savedTunnelName != null) { + lifecycleScope.launch { + val tunnel = Application.getTunnelManager().getTunnels()[savedTunnelName] + if (tunnel == null) + created = true + selectedTunnel = tunnel + created = true + } + } else { + created = true + } + } + + override fun onSaveInstanceState(outState: Bundle) { + if (selectedTunnel != null) outState.putString(KEY_SELECTED_TUNNEL, selectedTunnel!!.name) + super.onSaveInstanceState(outState) + } + + protected abstract fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?): Boolean + + fun removeOnSelectedTunnelChangedListener( + listener: OnSelectedTunnelChangedListener + ) { + selectionChangeRegistry.remove(listener) + } + + interface OnSelectedTunnelChangedListener { + fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) + } + + private class SelectionChangeNotifier : NotifierCallback() { + override fun onNotifyCallback( + listener: OnSelectedTunnelChangedListener, + oldTunnel: ObservableTunnel?, + ignored: Int, + newTunnel: ObservableTunnel? + ) { + listener.onSelectedTunnelChanged(oldTunnel, newTunnel) + } + } + + private class SelectionChangeRegistry : + CallbackRegistry(SelectionChangeNotifier()) + + companion object { + private const val KEY_SELECTED_TUNNEL = "selected_tunnel" + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt new file mode 100644 index 0000000..fa16b3c --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt @@ -0,0 +1,382 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity + +import android.content.ClipDescription.compareMimeTypes +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Intent +import android.database.Cursor +import android.database.MatrixCursor +import android.graphics.Typeface.BOLD +import android.net.Uri +import android.os.Bundle +import android.os.ParcelFileDescriptor +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.collection.CircularArray +import androidx.core.app.ShareCompat +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textview.MaterialTextView +import com.wireguard.android.BuildConfig +import com.wireguard.android.R +import com.wireguard.android.databinding.LogViewerActivityBinding +import com.wireguard.android.util.DownloadsFileSaver +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.resolveAttribute +import com.wireguard.crypto.KeyPair +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets +import java.text.DateFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import java.util.regex.Matcher +import java.util.regex.Pattern + +class LogViewerActivity : AppCompatActivity() { + private lateinit var binding: LogViewerActivityBinding + private lateinit var logAdapter: LogEntryAdapter + private var logLines = CircularArray() + private var rawLogLines = CircularArray() + private var recyclerView: RecyclerView? = null + private var saveButton: MenuItem? = null + private val year by lazy { + val yearFormatter: DateFormat = SimpleDateFormat("yyyy", Locale.US) + yearFormatter.format(Date()) + } + + private val defaultColor by lazy { resolveAttribute(com.google.android.material.R.attr.colorOnSurface) } + + private val debugColor by lazy { ResourcesCompat.getColor(resources, R.color.debug_tag_color, theme) } + + private val errorColor by lazy { ResourcesCompat.getColor(resources, R.color.error_tag_color, theme) } + + private val infoColor by lazy { ResourcesCompat.getColor(resources, R.color.info_tag_color, theme) } + + private val warningColor by lazy { ResourcesCompat.getColor(resources, R.color.warning_tag_color, theme) } + + private var lastUri: Uri? = null + + private fun revokeLastUri() { + lastUri?.let { + LOGS.remove(it.pathSegments.lastOrNull()) + revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION) + lastUri = null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = LogViewerActivityBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + logAdapter = LogEntryAdapter() + binding.recyclerView.apply { + recyclerView = this + layoutManager = LinearLayoutManager(context) + adapter = logAdapter + addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) + } + + lifecycleScope.launch(Dispatchers.IO) { streamingLog() } + + val revokeLastActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + revokeLastUri() + } + + binding.shareFab.setOnClickListener { + lifecycleScope.launch { + revokeLastUri() + val key = KeyPair().privateKey.toHex() + LOGS[key] = rawLogBytes() + lastUri = Uri.parse("content://${BuildConfig.APPLICATION_ID}.exported-log/$key") + val shareIntent = ShareCompat.IntentBuilder(this@LogViewerActivity) + .setType("text/plain") + .setSubject(getString(R.string.log_export_subject)) + .setStream(lastUri) + .setChooserTitle(R.string.log_export_title) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + grantUriPermission("android", lastUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + revokeLastActivityResultLauncher.launch(shareIntent) + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.log_viewer, menu) + saveButton = menu.findItem(R.id.save_log) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + + R.id.save_log -> { + saveButton?.isEnabled = false + lifecycleScope.launch { saveLog() } + true + } + + else -> super.onOptionsItemSelected(item) + } + } + + private val downloadsFileSaver = DownloadsFileSaver(this) + + private suspend fun rawLogBytes(): ByteArray { + val builder = StringBuilder() + withContext(Dispatchers.IO) { + for (i in 0 until rawLogLines.size()) { + builder.append(rawLogLines[i]) + builder.append('\n') + } + } + return builder.toString().toByteArray(Charsets.UTF_8) + } + + private suspend fun saveLog() { + var exception: Throwable? = null + var outputFile: DownloadsFileSaver.DownloadsFile? = null + withContext(Dispatchers.IO) { + try { + outputFile = downloadsFileSaver.save("wireguard-log.txt", "text/plain", true) + outputFile?.outputStream?.write(rawLogBytes()) + } catch (e: Throwable) { + outputFile?.delete() + exception = e + } + } + saveButton?.isEnabled = true + if (outputFile == null) + return + Snackbar.make( + findViewById(android.R.id.content), + if (exception == null) getString(R.string.log_export_success, outputFile.fileName) + else getString(R.string.log_export_error, ErrorMessages[exception]), + if (exception == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG + ) + .setAnchorView(binding.shareFab) + .show() + } + + private suspend fun streamingLog() = withContext(Dispatchers.IO) { + val builder = ProcessBuilder().command("logcat", "-b", "all", "-v", "threadtime", "*:V") + builder.environment()["LC_ALL"] = "C" + var process: Process? = null + try { + process = try { + builder.start() + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + return@withContext + } + val stdout = BufferedReader(InputStreamReader(process!!.inputStream, StandardCharsets.UTF_8)) + + var posStart = 0 + var timeLastNotify = System.nanoTime() + var priorModified = false + val bufferedLogLines = arrayListOf() + var timeout = 1000000000L / 2 // The timeout is initially small so that the view gets populated immediately. + val MAX_LINES = (1 shl 16) - 1 + val MAX_BUFFERED_LINES = (1 shl 14) - 1 + + while (true) { + val line = stdout.readLine() ?: break + if (rawLogLines.size() >= MAX_LINES) + rawLogLines.popFirst() + rawLogLines.addLast(line) + val logLine = parseLine(line) + if (logLine != null) { + bufferedLogLines.add(logLine) + } else { + if (bufferedLogLines.isNotEmpty()) { + bufferedLogLines.last().msg += "\n$line" + } else if (!logLines.isEmpty()) { + logLines[logLines.size() - 1].msg += "\n$line" + priorModified = true + } + } + val timeNow = System.nanoTime() + if (bufferedLogLines.size < MAX_BUFFERED_LINES && (timeNow - timeLastNotify) < timeout && stdout.ready()) + continue + timeout = 1000000000L * 5 / 2 // Increase the timeout after the initial view has something in it. + timeLastNotify = timeNow + + withContext(Dispatchers.Main.immediate) { + val isScrolledToBottomAlready = recyclerView?.canScrollVertically(1) == false + if (priorModified) { + logAdapter.notifyItemChanged(posStart - 1) + priorModified = false + } + val fullLen = logLines.size() + bufferedLogLines.size + if (fullLen >= MAX_LINES) { + val numToRemove = fullLen - MAX_LINES + 1 + logLines.removeFromStart(numToRemove) + logAdapter.notifyItemRangeRemoved(0, numToRemove) + posStart -= numToRemove + + } + for (bufferedLine in bufferedLogLines) { + logLines.addLast(bufferedLine) + } + bufferedLogLines.clear() + logAdapter.notifyItemRangeInserted(posStart, logLines.size() - posStart) + posStart = logLines.size() + + if (isScrolledToBottomAlready) { + recyclerView?.scrollToPosition(logLines.size() - 1) + } + } + } + } finally { + process?.destroy() + } + } + + private fun parseTime(timeStr: String): Date? { + val formatter: DateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + return try { + formatter.parse("$year-$timeStr") + } catch (e: ParseException) { + null + } + } + + private fun parseLine(line: String): LogLine? { + val m: Matcher = THREADTIME_LINE.matcher(line) + return if (m.matches()) { + LogLine(m.group(2)!!.toInt(), m.group(3)!!.toInt(), parseTime(m.group(1)!!), m.group(4)!!, m.group(5)!!, m.group(6)!!) + } else { + null + } + } + + private data class LogLine(val pid: Int, val tid: Int, val time: Date?, val level: String, val tag: String, var msg: String) + + companion object { + /** + * Match a single line of `logcat -v threadtime`, such as: + * + *

05-26 11:02:36.886 5689 5689 D AndroidRuntime: CheckJNI is OFF.
+ */ + private val THREADTIME_LINE: Pattern = + Pattern.compile("^(\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3})(?:\\s+[0-9A-Za-z]+)?\\s+(\\d+)\\s+(\\d+)\\s+([A-Z])\\s+(.+?)\\s*: (.*)$") + private val LOGS: MutableMap = ConcurrentHashMap() + private const val TAG = "WireGuard/LogViewerActivity" + } + + private inner class LogEntryAdapter : RecyclerView.Adapter() { + + private inner class ViewHolder(val layout: View, var isSingleLine: Boolean = true) : RecyclerView.ViewHolder(layout) + + private fun levelToColor(level: String): Int { + return when (level) { + "V", "D" -> debugColor + "E" -> errorColor + "I" -> infoColor + "W" -> warningColor + else -> defaultColor + } + } + + override fun getItemCount() = logLines.size() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.log_viewer_entry, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val line = logLines[position] + val spannable = if (position > 0 && logLines[position - 1].tag == line.tag) + SpannableString(line.msg) + else + SpannableString("${line.tag}: ${line.msg}").apply { + setSpan(StyleSpan(BOLD), 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + ForegroundColorSpan(levelToColor(line.level)), + 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + holder.layout.apply { + findViewById(R.id.log_date).text = line.time.toString() + findViewById(R.id.log_msg).apply { + setSingleLine() + text = spannable + setOnClickListener { + isSingleLine = !holder.isSingleLine + holder.isSingleLine = !holder.isSingleLine + } + } + } + } + } + + class ExportedLogContentProvider : ContentProvider() { + private fun logForUri(uri: Uri): ByteArray? = LOGS[uri.pathSegments.lastOrNull()] + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = + logForUri(uri)?.let { + val m = MatrixCursor(arrayOf(android.provider.OpenableColumns.DISPLAY_NAME, android.provider.OpenableColumns.SIZE), 1) + m.addRow(arrayOf("wireguard-log.txt", it.size.toLong())) + m + } + + override fun onCreate(): Boolean = true + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun getType(uri: Uri): String? = logForUri(uri)?.let { "text/plain" } + + override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array? = + getType(uri)?.let { if (compareMimeTypes(it, mimeTypeFilter)) arrayOf(it) else null } + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + if (mode != "r") return null + val log = logForUri(uri) ?: return null + return openPipeHelper(uri, "text/plain", null, log) { output, _, _, _, l -> + try { + FileOutputStream(output.fileDescriptor).write(l!!) + } catch (_: Throwable) { + } + } + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt new file mode 100644 index 0000000..087ca08 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt @@ -0,0 +1,129 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.appcompat.app.ActionBar +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.commit +import com.wireguard.android.R +import com.wireguard.android.fragment.TunnelDetailFragment +import com.wireguard.android.fragment.TunnelEditorFragment +import com.wireguard.android.model.ObservableTunnel + +/** + * CRUD interface for WireGuard tunnels. This activity serves as the main entry point to the + * WireGuard application, and contains several fragments for listing, viewing details of, and + * editing the configuration and interface state of WireGuard tunnels. + */ +class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener { + private var actionBar: ActionBar? = null + private var isTwoPaneLayout = false + private var backPressedCallback: OnBackPressedCallback? = null + + private fun handleBackPressed() { + val backStackEntries = supportFragmentManager.backStackEntryCount + // If the two-pane layout does not have an editor open, going back should exit the app. + if (isTwoPaneLayout && backStackEntries <= 1) { + finish() + return + } + + if (backStackEntries >= 1) + supportFragmentManager.popBackStack() + + // Deselect the current tunnel on navigating back from the detail pane to the one-pane list. + if (backStackEntries == 1) + selectedTunnel = null + } + + override fun onBackStackChanged() { + val backStackEntries = supportFragmentManager.backStackEntryCount + backPressedCallback?.isEnabled = backStackEntries >= 1 + if (actionBar == null) return + // Do not show the home menu when the two-pane layout is at the detail view (see above). + val minBackStackEntries = if (isTwoPaneLayout) 2 else 1 + actionBar!!.setDisplayHomeAsUpEnabled(backStackEntries >= minBackStackEntries) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.main_activity) + actionBar = supportActionBar + isTwoPaneLayout = findViewById(R.id.master_detail_wrapper) != null + supportFragmentManager.addOnBackStackChangedListener(this) + backPressedCallback = onBackPressedDispatcher.addCallback(this) { handleBackPressed() } + onBackStackChanged() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.main_activity, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + // The back arrow in the action bar should act the same as the back button. + onBackPressedDispatcher.onBackPressed() + true + } + + R.id.menu_action_edit -> { + supportFragmentManager.commit { + replace(if (isTwoPaneLayout) R.id.detail_container else R.id.list_detail_container, TunnelEditorFragment()) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + addToBackStack(null) + } + true + } + // This menu item is handled by the editor fragment. + R.id.menu_action_save -> false + R.id.menu_settings -> { + startActivity(Intent(this, SettingsActivity::class.java)) + true + } + + else -> super.onOptionsItemSelected(item) + } + } + + override fun onSelectedTunnelChanged( + oldTunnel: ObservableTunnel?, + newTunnel: ObservableTunnel? + ): Boolean { + val fragmentManager = supportFragmentManager + if (fragmentManager.isStateSaved) { + return false + } + + val backStackEntries = fragmentManager.backStackEntryCount + if (newTunnel == null) { + // Clear everything off the back stack (all editors and detail fragments). + fragmentManager.popBackStackImmediate(0, FragmentManager.POP_BACK_STACK_INCLUSIVE) + return true + } + if (backStackEntries == 2) { + // Pop the editor off the back stack to reveal the detail fragment. Use the immediate + // method to avoid the editor picking up the new tunnel while it is still visible. + fragmentManager.popBackStackImmediate() + } else if (backStackEntries == 0) { + // Create and show a new detail fragment. + fragmentManager.commit { + add(if (isTwoPaneLayout) R.id.detail_container else R.id.list_detail_container, TunnelDetailFragment()) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + addToBackStack(null) + } + } + return true + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt new file mode 100644 index 0000000..33f44cd --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt @@ -0,0 +1,113 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.wireguard.android.Application +import com.wireguard.android.QuickTileService +import com.wireguard.android.R +import com.wireguard.android.backend.WgQuickBackend +import com.wireguard.android.preference.PreferencesPreferenceDataStore +import com.wireguard.android.util.AdminKnobs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Interface for changing application-global persistent settings. + */ +class SettingsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (supportFragmentManager.findFragmentById(android.R.id.content) == null) { + supportFragmentManager.commit { + add(android.R.id.content, SettingsFragment()) + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return super.onOptionsItemSelected(item) + } + + class SettingsFragment : PreferenceFragmentCompat() { + + // Since this is pretty much abandoned by androidx, it never got updated for proper EdgeToEdge support, + // which is enabled everywhere for API 35. So handle the insets manually here. + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = super.onCreateView(inflater, container, savedInstanceState) + view.fitsSystemWindows = true + return view + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, key: String?) { + preferenceManager.preferenceDataStore = PreferencesPreferenceDataStore(lifecycleScope, Application.getPreferencesDataStore()) + addPreferencesFromResource(R.xml.preferences) + preferenceScreen.initialExpandedChildrenCount = 5 + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || QuickTileService.isAdded) { + val quickTile = preferenceManager.findPreference("quick_tile") + quickTile?.parent?.removePreference(quickTile) + --preferenceScreen.initialExpandedChildrenCount + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val darkTheme = preferenceManager.findPreference("dark_theme") + darkTheme?.parent?.removePreference(darkTheme) + --preferenceScreen.initialExpandedChildrenCount + } + if (AdminKnobs.disableConfigExport) { + val zipExporter = preferenceManager.findPreference("zip_exporter") + zipExporter?.parent?.removePreference(zipExporter) + } + val wgQuickOnlyPrefs = arrayOf( + preferenceManager.findPreference("tools_installer"), + preferenceManager.findPreference("restore_on_boot"), + preferenceManager.findPreference("multiple_tunnels") + ).filterNotNull() + wgQuickOnlyPrefs.forEach { it.isVisible = false } + lifecycleScope.launch { + if (Application.getBackend() is WgQuickBackend) { + ++preferenceScreen.initialExpandedChildrenCount + wgQuickOnlyPrefs.forEach { it.isVisible = true } + } else { + wgQuickOnlyPrefs.forEach { it.parent?.removePreference(it) } + } + } + preferenceManager.findPreference("log_viewer")?.setOnPreferenceClickListener { + startActivity(Intent(requireContext(), LogViewerActivity::class.java)) + true + } + val kernelModuleEnabler = preferenceManager.findPreference("kernel_module_enabler") + if (WgQuickBackend.hasKernelSupport()) { + lifecycleScope.launch { + if (Application.getBackend() !is WgQuickBackend) { + try { + withContext(Dispatchers.IO) { Application.getRootShell().start() } + } catch (_: Throwable) { + kernelModuleEnabler?.parent?.removePreference(kernelModuleEnabler) + } + } + } + } else { + kernelModuleEnabler?.parent?.removePreference(kernelModuleEnabler) + } + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt new file mode 100644 index 0000000..8d5f4cf --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt @@ -0,0 +1,24 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.os.Bundle +import com.wireguard.android.R +import com.wireguard.android.model.ObservableTunnel + +/** + * Standalone activity for creating tunnels. + */ +class TunnelCreatorActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.tunnel_creator_activity) + } + + override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?): Boolean { + finish() + return true + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt new file mode 100644 index 0000000..a0e9aa0 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt @@ -0,0 +1,69 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.content.ComponentName +import android.os.Build +import android.os.Bundle +import android.service.quicksettings.TileService +import android.util.Log +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.wireguard.android.Application +import com.wireguard.android.QuickTileService +import com.wireguard.android.R +import com.wireguard.android.backend.GoBackend +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.util.ErrorMessages +import kotlinx.coroutines.launch + +class TunnelToggleActivity : AppCompatActivity() { + private val permissionActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { toggleTunnelWithPermissionsResult() } + + private fun toggleTunnelWithPermissionsResult() { + val tunnel = Application.getTunnelManager().lastUsedTunnel ?: return + lifecycleScope.launch { + try { + tunnel.setStateAsync(Tunnel.State.TOGGLE) + } catch (e: Throwable) { + TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java)) + val error = ErrorMessages[e] + val message = getString(R.string.toggle_error, error) + Log.e(TAG, message, e) + Toast.makeText(this@TunnelToggleActivity, message, Toast.LENGTH_LONG).show() + finishAffinity() + return@launch + } + TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java)) + finishAffinity() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + if (Application.getBackend() is GoBackend) { + try { + val intent = GoBackend.VpnService.prepare(this@TunnelToggleActivity) + if (intent != null) { + permissionActivityResultLauncher.launch(intent) + return@launch + } + } catch (e: Exception) { + Toast.makeText(this@TunnelToggleActivity, ErrorMessages[e], Toast.LENGTH_LONG).show() + } + } + toggleTunnelWithPermissionsResult() + } + } + + companion object { + private const val TAG = "WireGuard/TunnelToggleActivity" + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt new file mode 100644 index 0000000..a20c983 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt @@ -0,0 +1,431 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.storage.StorageManager +import android.os.storage.StorageVolume +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.view.forEach +import androidx.databinding.DataBindingUtil +import androidx.databinding.Observable +import androidx.databinding.ObservableBoolean +import androidx.databinding.ObservableField +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.backend.GoBackend +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.Keyed +import com.wireguard.android.databinding.ObservableKeyedArrayList +import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter +import com.wireguard.android.databinding.TvActivityBinding +import com.wireguard.android.databinding.TvFileListItemBinding +import com.wireguard.android.databinding.TvTunnelListItemBinding +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.QuantityFormatter +import com.wireguard.android.util.TunnelImporter +import com.wireguard.android.util.UserKnobs +import com.wireguard.android.util.applicationScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +class TvMainActivity : AppCompatActivity() { + private val tunnelFileImportResultLauncher = registerForActivityResult(object : ActivityResultContracts.OpenDocument() { + override fun createIntent(context: Context, input: Array): Intent { + val intent = super.createIntent(context, input) + + /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than + * what we can do, so detect this and throw an exception that we can catch later. */ + val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + @Suppress("DEPRECATION") + context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + } + if (activitiesToResolveIntent.all { + val name = it.activityInfo.packageName + name.startsWith("com.google.android.tv.frameworkpackagestubs") || name.startsWith("com.android.tv.frameworkpackagestubs") + }) { + throw ActivityNotFoundException() + } + return intent + } + }) { data -> + if (data == null) return@registerForActivityResult + lifecycleScope.launch { + TunnelImporter.importTunnel(contentResolver, data) { + Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show() + } + } + } + private var pendingTunnel: ObservableTunnel? = null + private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val tunnel = pendingTunnel + if (tunnel != null) + setTunnelStateWithPermissionsResult(tunnel) + pendingTunnel = null + } + + private fun setTunnelStateWithPermissionsResult(tunnel: ObservableTunnel) { + lifecycleScope.launch { + try { + tunnel.setStateAsync(Tunnel.State.TOGGLE) + } catch (e: Throwable) { + val error = ErrorMessages[e] + val message = getString(R.string.error_up, error) + Toast.makeText(this@TvMainActivity, message, Toast.LENGTH_LONG).show() + Log.e(TAG, message, e) + } + updateStats() + } + } + + private lateinit var binding: TvActivityBinding + private val isDeleting = ObservableBoolean() + private val files = ObservableKeyedArrayList() + private val filesRoot = ObservableField("") + + override fun onCreate(savedInstanceState: Bundle?) { + if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_YES) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + applicationScope.launch { + UserKnobs.setDarkTheme(true) + } + } + } + super.onCreate(savedInstanceState) + binding = TvActivityBinding.inflate(layoutInflater) + lifecycleScope.launch { + binding.tunnels = Application.getTunnelManager().getTunnels() + if (binding.tunnels?.isEmpty() == true) + binding.importButton.requestFocus() + else + binding.tunnelList.requestFocus() + } + binding.isDeleting = isDeleting + binding.files = files + binding.filesRoot = filesRoot + val gridManager = binding.tunnelList.layoutManager as GridLayoutManager + gridManager.spanSizeLookup = SlatedSpanSizeLookup(gridManager) + binding.tunnelRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler { + override fun onConfigureRow(binding: TvTunnelListItemBinding, item: ObservableTunnel, position: Int) { + binding.isDeleting = isDeleting + binding.isFocused = ObservableBoolean() + binding.root.setOnFocusChangeListener { _, focused -> + binding.isFocused?.set(focused) + } + binding.root.setOnClickListener { + lifecycleScope.launch { + if (isDeleting.get()) { + try { + item.deleteAsync() + if (this@TvMainActivity.binding.tunnels?.isEmpty() != false) + isDeleting.set(false) + } catch (e: Throwable) { + val error = ErrorMessages[e] + val message = getString(R.string.config_delete_error, error) + Toast.makeText(this@TvMainActivity, message, Toast.LENGTH_LONG).show() + Log.e(TAG, message, e) + } + } else { + if (Application.getBackend() is GoBackend) { + val intent = GoBackend.VpnService.prepare(binding.root.context) + if (intent != null) { + pendingTunnel = item + permissionActivityResultLauncher.launch(intent) + return@launch + } + } + setTunnelStateWithPermissionsResult(item) + } + } + } + } + } + + binding.filesRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler { + override fun onConfigureRow(binding: TvFileListItemBinding, item: KeyedFile, position: Int) { + binding.root.setOnClickListener { + if (item.file.isDirectory) + navigateTo(item.file) + else { + val uri = Uri.fromFile(item.file) + files.clear() + filesRoot.set("") + lifecycleScope.launch { + TunnelImporter.importTunnel(contentResolver, uri) { + Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show() + } + } + runOnUiThread { + this@TvMainActivity.binding.tunnelList.requestFocus() + } + } + } + } + } + + binding.importButton.setOnClickListener { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (filesRoot.get()?.isEmpty() != false) { + navigateTo(File("/")) + runOnUiThread { + binding.filesList.requestFocus() + } + } else { + files.clear() + filesRoot.set("") + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + } else { + try { + tunnelFileImportResultLauncher.launch(arrayOf("*/*")) + } catch (_: Throwable) { + MaterialAlertDialogBuilder(binding.root.context).setMessage(R.string.tv_no_file_picker).setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> + try { + startActivity(Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("https://play.google.com/store/apps/details?id=com.cxinventor.file.explorer") + setPackage("com.android.vending") + }) + } catch (_: Throwable) { + } + }.show() + } + } + } + + binding.deleteButton.setOnClickListener { + isDeleting.set(!isDeleting.get()) + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + + val backPressedCallback = onBackPressedDispatcher.addCallback(this) { handleBackPressed() } + val updateBackPressedCallback = object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable?, propertyId: Int) { + backPressedCallback.isEnabled = isDeleting.get() || filesRoot.get()?.isNotEmpty() == true + } + } + isDeleting.addOnPropertyChangedCallback(updateBackPressedCallback) + filesRoot.addOnPropertyChangedCallback(updateBackPressedCallback) + backPressedCallback.isEnabled = false + + binding.executePendingBindings() + setContentView(binding.root) + + lifecycleScope.launch { + while (true) { + updateStats() + delay(1000) + } + } + } + + private var pendingNavigation: File? = null + private val permissionRequestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + val to = pendingNavigation + if (it && to != null) + navigateTo(to) + pendingNavigation = null + } + + private var cachedRoots: Collection? = null + + private suspend fun makeStorageRoots(): Collection = withContext(Dispatchers.IO) { + cachedRoots?.let { return@withContext it } + val list = HashSet() + val storageManager: StorageManager = getSystemService() ?: return@withContext list + list.addAll(storageManager.storageVolumes.mapNotNull { volume -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + volume.directory?.let { KeyedFile(it, volume.getDescription(this@TvMainActivity)) } + } else { + KeyedFile((StorageVolume::class.java.getMethod("getPathFile").invoke(volume) as File), volume.getDescription(this@TvMainActivity)) + } + }) + cachedRoots = list + list + } + + private fun isBelowCachedRoots(maybeChild: File): Boolean { + val cachedRoots = cachedRoots ?: return true + for (root in cachedRoots) { + if (maybeChild.canonicalPath.startsWith(root.file.canonicalPath)) + return false + } + return true + } + + private fun navigateTo(directory: File) { + require(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + pendingNavigation = directory + permissionRequestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + return + } + + lifecycleScope.launch { + if (isBelowCachedRoots(directory)) { + val roots = makeStorageRoots() + if (roots.count() == 1) { + navigateTo(roots.first().file) + return@launch + } + files.clear() + files.addAll(roots) + filesRoot.set(getString(R.string.tv_select_a_storage_drive)) + return@launch + } + + val newFiles = withContext(Dispatchers.IO) { + val newFiles = ArrayList() + try { + directory.parentFile?.let { + newFiles.add(KeyedFile(it, "../")) + } + val listing = directory.listFiles() ?: return@withContext null + listing.forEach { + if (it.extension == "conf" || it.extension == "zip" || it.isDirectory) + newFiles.add(KeyedFile(it)) + } + newFiles.sortWith { a, b -> + if (a.file.isDirectory && !b.file.isDirectory) -1 + else if (!a.file.isDirectory && b.file.isDirectory) 1 + else a.file.compareTo(b.file) + } + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + newFiles + } + if (newFiles?.isEmpty() != false) + return@launch + files.clear() + files.addAll(newFiles) + filesRoot.set(directory.canonicalPath) + } + } + + private fun handleBackPressed() { + when { + isDeleting.get() -> { + isDeleting.set(false) + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + + filesRoot.get()?.isNotEmpty() == true -> { + files.clear() + filesRoot.set("") + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + } + } + + private suspend fun updateStats() { + binding.tunnelList.forEach { viewItem -> + val listItem = DataBindingUtil.findBinding(viewItem) + ?: return@forEach + try { + val tunnel = listItem.item!! + if (tunnel.state != Tunnel.State.UP || isDeleting.get()) { + throw Exception() + } + val statistics = tunnel.getStatisticsAsync() + val rx = statistics.totalRx() + val tx = statistics.totalTx() + listItem.tunnelTransfer.text = getString(R.string.transfer_rx_tx, QuantityFormatter.formatBytes(rx), QuantityFormatter.formatBytes(tx)) + listItem.tunnelTransfer.visibility = View.VISIBLE + } catch (_: Throwable) { + listItem.tunnelTransfer.visibility = View.GONE + listItem.tunnelTransfer.text = "" + } + } + } + + class KeyedFile(val file: File, private val forcedKey: String? = null) : Keyed { + override val key: String + get() = forcedKey ?: if (file.isDirectory) "${file.name}/" else file.name + } + + private class SlatedSpanSizeLookup(private val gridManager: GridLayoutManager) : SpanSizeLookup() { + private val originalHeight = gridManager.spanCount + private var newWidth = 0 + private lateinit var sizeMap: Array + + private fun emptyUnderIndex(index: Int, size: Int): Int { + sizeMap[size - 1]?.let { return it[index] } + val sizes = IntArray(size) + val oh = originalHeight + val nw = newWidth + var empties = 0 + for (i in 0 until size) { + val ox = (i + empties) / oh + val oy = (i + empties) % oh + var empty = 0 + for (j in oy + 1 until oh) { + val ni = nw * j + ox + if (ni < size) + break + empty++ + } + empties += empty + sizes[i] = empty + } + sizeMap[size - 1] = sizes + return sizes[index] + } + + override fun getSpanSize(position: Int): Int { + if (newWidth == 0) { + val child = gridManager.getChildAt(0) ?: return 1 + if (child.width == 0) return 1 + newWidth = gridManager.width / child.width + sizeMap = Array(originalHeight * newWidth - 1) { null } + } + val total = gridManager.itemCount + if (total >= originalHeight * newWidth || total == 0) + return 1 + return emptyUnderIndex(position, total) + 1 + } + } + + companion object { + private const val TAG = "WireGuard/TvMainActivity" + } +} diff --git a/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt new file mode 100644 index 0000000..45f3860 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt @@ -0,0 +1,68 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.configStore + +import com.wireguard.config.Config + +/** + * Interface for persistent storage providers for WireGuard configurations. + */ +interface ConfigStore { + /** + * Create a persistent tunnel, which must have a unique name within the persistent storage + * medium. + * + * @param name The name of the tunnel to create. + * @param config Configuration for the new tunnel. + * @return The configuration that was actually saved to persistent storage. + */ + @Throws(Exception::class) + fun create(name: String, config: Config): Config + + /** + * Delete a persistent tunnel. + * + * @param name The name of the tunnel to delete. + */ + @Throws(Exception::class) + fun delete(name: String) + + /** + * Enumerate the names of tunnels present in persistent storage. + * + * @return The set of present tunnel names. + */ + fun enumerate(): Set + + /** + * Load the configuration for the tunnel given by `name`. + * + * @param name The identifier for the configuration in persistent storage (i.e. the name of the + * tunnel). + * @return An in-memory representation of the configuration loaded from persistent storage. + */ + @Throws(Exception::class) + fun load(name: String): Config + + /** + * Rename the configuration for the tunnel given by `name`. + * + * @param name The identifier for the existing configuration in persistent storage. + * @param replacement The new identifier for the configuration in persistent storage. + */ + @Throws(Exception::class) + fun rename(name: String, replacement: String) + + /** + * Save the configuration for an existing tunnel given by `name`. + * + * @param name The identifier for the configuration in persistent storage (i.e. the name of + * the tunnel). + * @param config An updated configuration object for the tunnel. + * @return The configuration that was actually saved to persistent storage. + */ + @Throws(Exception::class) + fun save(name: String, config: Config): Config +} diff --git a/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt new file mode 100644 index 0000000..98b738e --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt @@ -0,0 +1,82 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.configStore + +import android.content.Context +import android.util.Log +import com.wireguard.android.R +import com.wireguard.config.BadConfigException +import com.wireguard.config.Config +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.nio.charset.StandardCharsets + +/** + * Configuration store that uses a `wg-quick`-style file for each configured tunnel. + */ +class FileConfigStore(private val context: Context) : ConfigStore { + @Throws(IOException::class) + override fun create(name: String, config: Config): Config { + Log.d(TAG, "Creating configuration for tunnel $name") + val file = fileFor(name) + if (!file.createNewFile()) + throw IOException(context.getString(R.string.config_file_exists_error, file.name)) + FileOutputStream(file, false).use { it.write(config.toWgQuickString().toByteArray(StandardCharsets.UTF_8)) } + return config + } + + @Throws(IOException::class) + override fun delete(name: String) { + Log.d(TAG, "Deleting configuration for tunnel $name") + val file = fileFor(name) + if (!file.delete()) + throw IOException(context.getString(R.string.config_delete_error, file.name)) + } + + override fun enumerate(): Set { + return context.fileList() + .filter { it.endsWith(".conf") } + .map { it.substring(0, it.length - ".conf".length) } + .toSet() + } + + private fun fileFor(name: String): File { + return File(context.filesDir, "$name.conf") + } + + @Throws(BadConfigException::class, IOException::class) + override fun load(name: String): Config { + FileInputStream(fileFor(name)).use { stream -> return Config.parse(stream) } + } + + @Throws(IOException::class) + override fun rename(name: String, replacement: String) { + Log.d(TAG, "Renaming configuration for tunnel $name to $replacement") + val file = fileFor(name) + val replacementFile = fileFor(replacement) + if (!replacementFile.createNewFile()) throw IOException(context.getString(R.string.config_exists_error, replacement)) + if (!file.renameTo(replacementFile)) { + if (!replacementFile.delete()) Log.w(TAG, "Couldn't delete marker file for new name $replacement") + throw IOException(context.getString(R.string.config_rename_error, file.name)) + } + } + + @Throws(IOException::class) + override fun save(name: String, config: Config): Config { + Log.d(TAG, "Saving configuration for tunnel $name") + val file = fileFor(name) + if (!file.isFile) + throw FileNotFoundException(context.getString(R.string.config_not_found_error, file.name)) + FileOutputStream(file, false).use { stream -> stream.write(config.toWgQuickString().toByteArray(StandardCharsets.UTF_8)) } + return config + } + + companion object { + private const val TAG = "WireGuard/FileConfigStore" + } +} diff --git a/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt new file mode 100644 index 0000000..df3bd08 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt @@ -0,0 +1,194 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.databinding + +import android.text.InputFilter +import android.view.LayoutInflater +import android.view.View +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import androidx.databinding.BindingAdapter +import androidx.databinding.DataBindingUtil +import androidx.databinding.ObservableList +import androidx.databinding.ViewDataBinding +import androidx.databinding.adapters.ListenerUtil +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.wireguard.android.BR +import com.wireguard.android.R +import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler +import com.wireguard.android.widget.ToggleSwitch +import com.wireguard.android.widget.ToggleSwitch.OnBeforeCheckedChangeListener +import com.wireguard.android.widget.TvCardView +import com.wireguard.config.Attribute +import com.wireguard.config.InetNetwork +import java.net.InetAddress +import java.util.Optional + +/** + * Static methods for use by generated code in the Android data binding library. + */ +object BindingAdapters { + @JvmStatic + @BindingAdapter("checked") + fun setChecked(view: ToggleSwitch, checked: Boolean) { + view.setCheckedInternal(checked) + } + + @JvmStatic + @BindingAdapter("filter") + fun setFilter(view: TextView, filter: InputFilter) { + view.filters = arrayOf(filter) + } + + @JvmStatic + @BindingAdapter("items", "layout", "fragment") + fun setItems( + view: LinearLayout, + oldList: ObservableList?, oldLayoutId: Int, @Suppress("UNUSED_PARAMETER") oldFragment: Fragment?, + newList: ObservableList?, newLayoutId: Int, newFragment: Fragment? + ) { + if (oldList === newList && oldLayoutId == newLayoutId) + return + var listener: ItemChangeListener? = 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 = ItemChangeListener(view, newLayoutId, newFragment) + ListenerUtil.trackListener(view, listener, R.id.item_change_listener) + } + // Either the list changed, or this is an entirely new listener because the layout changed. + listener.setList(newList) + } + + @JvmStatic + @BindingAdapter("items", "layout") + fun setItems( + view: LinearLayout, + oldList: Iterable?, oldLayoutId: Int, + newList: Iterable?, newLayoutId: Int + ) { + if (oldList === newList && oldLayoutId == newLayoutId) + return + view.removeAllViews() + if (newList == null) + return + val layoutInflater = LayoutInflater.from(view.context) + for (item in newList) { + val binding = DataBindingUtil.inflate(layoutInflater, newLayoutId, view, false) + binding.setVariable(BR.collection, newList) + binding.setVariable(BR.item, item) + binding.executePendingBindings() + view.addView(binding.root) + } + } + + @JvmStatic + @BindingAdapter(requireAll = false, value = ["items", "layout", "configurationHandler"]) + fun > setItems( + view: RecyclerView, + oldList: ObservableKeyedArrayList?, oldLayoutId: Int, + @Suppress("UNUSED_PARAMETER") oldRowConfigurationHandler: RowConfigurationHandler<*, *>?, + newList: ObservableKeyedArrayList?, newLayoutId: Int, + newRowConfigurationHandler: RowConfigurationHandler<*, *>? + ) { + if (view.layoutManager == null) + view.layoutManager = LinearLayoutManager(view.context, RecyclerView.VERTICAL, false) + if (oldList === newList && oldLayoutId == newLayoutId) + return + // The ListAdapter interface is not generic, so this cannot be checked. + @Suppress("UNCHECKED_CAST") var adapter = view.adapter as? ObservableKeyedRecyclerViewAdapter? + // If the layout changes, any existing adapter must be replaced. + if (adapter != null && oldList != null && oldLayoutId != newLayoutId) { + adapter.setList(null) + adapter = null + } + // Avoid setting an adapter when there is no new list or layout. + if (newList == null || newLayoutId == 0) + return + if (adapter == null) { + adapter = ObservableKeyedRecyclerViewAdapter(view.context, newLayoutId, newList) + view.adapter = adapter + } + adapter.setRowConfigurationHandler(newRowConfigurationHandler) + // Either the list changed, or this is an entirely new listener because the layout changed. + adapter.setList(newList) + } + + @JvmStatic + @BindingAdapter("onBeforeCheckedChanged") + fun setOnBeforeCheckedChanged( + view: ToggleSwitch, + listener: OnBeforeCheckedChangeListener? + ) { + view.setOnBeforeCheckedChangeListener(listener) + } + + @JvmStatic + @BindingAdapter("onFocusChange") + fun setOnFocusChange( + view: EditText, + listener: View.OnFocusChangeListener? + ) { + view.onFocusChangeListener = listener + } + + @JvmStatic + @BindingAdapter("android:text") + fun setOptionalText(view: TextView, text: Optional<*>?) { + view.text = text?.map { it.toString() }?.orElse("") ?: "" + } + + @JvmStatic + @BindingAdapter("android:text") + fun setInetNetworkSetText(view: TextView, networks: Iterable?) { + view.text = if (networks != null) Attribute.join(networks) else "" + } + + @JvmStatic + @BindingAdapter("android:text") + fun setInetAddressSetText(view: TextView, addresses: Iterable?) { + view.text = if (addresses != null) Attribute.join(addresses.map { it?.hostAddress }) else "" + } + + @JvmStatic + @BindingAdapter("android:text") + fun setStringSetText(view: TextView, strings: Iterable?) { + view.text = if (strings != null) Attribute.join(strings) else "" + } + + @JvmStatic + fun tryParseInt(s: String?): Int { + if (s == null) + return 0 + return try { + Integer.parseInt(s) + } catch (_: Throwable) { + 0 + } + } + + @JvmStatic + @BindingAdapter("isUp") + fun setIsUp(card: TvCardView, up: Boolean) { + card.isUp = up + } + + @JvmStatic + @BindingAdapter("isDeleting") + fun setIsDeleting(card: TvCardView, deleting: Boolean) { + card.isDeleting = deleting + } +} diff --git a/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt new file mode 100644 index 0000000..84ec3ed --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt @@ -0,0 +1,122 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.databinding + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.databinding.ObservableList +import androidx.databinding.ViewDataBinding +import androidx.fragment.app.Fragment +import com.wireguard.android.BR +import java.lang.ref.WeakReference + +/** + * Helper class for binding an ObservableList to the children of a ViewGroup. + */ +internal class ItemChangeListener(private val container: ViewGroup, private val layoutId: Int, private val fragment: Fragment?) { + private val callback = OnListChangedCallback(this) + private val layoutInflater: LayoutInflater = LayoutInflater.from(container.context) + private var list: ObservableList? = null + + private fun getView(position: Int, convertView: View?): View { + var binding = if (convertView != null) DataBindingUtil.getBinding(convertView) else null + if (binding == null) { + binding = DataBindingUtil.inflate(layoutInflater, layoutId, container, false) + } + require(list != null) { "Trying to get a view while list is still null" } + binding!!.setVariable(BR.collection, list) + binding.setVariable(BR.item, list!![position]) + binding.setVariable(BR.fragment, fragment) + binding.executePendingBindings() + return binding.root + } + + fun setList(newList: ObservableList?) { + list?.removeOnListChangedCallback(callback) + list = newList + if (list != null) { + list!!.addOnListChangedCallback(callback) + callback.onChanged(list!!) + } else { + container.removeAllViews() + } + } + + private class OnListChangedCallback constructor(listener: ItemChangeListener) : ObservableList.OnListChangedCallback>() { + private val weakListener: WeakReference> = WeakReference(listener) + + override fun onChanged(sender: ObservableList) { + val listener = weakListener.get() + if (listener != null) { + // TODO: recycle views + listener.container.removeAllViews() + for (i in sender.indices) + listener.container.addView(listener.getView(i, null)) + } else { + sender.removeOnListChangedCallback(this) + } + } + + override fun onItemRangeChanged( + sender: ObservableList, positionStart: Int, + itemCount: Int + ) { + val listener = weakListener.get() + if (listener != null) { + for (i in positionStart until positionStart + itemCount) { + val child = listener.container.getChildAt(i) + listener.container.removeViewAt(i) + listener.container.addView(listener.getView(i, child)) + } + } else { + sender.removeOnListChangedCallback(this) + } + } + + override fun onItemRangeInserted( + sender: ObservableList, positionStart: Int, + itemCount: Int + ) { + val listener = weakListener.get() + if (listener != null) { + for (i in positionStart until positionStart + itemCount) + listener.container.addView(listener.getView(i, null)) + } else { + sender.removeOnListChangedCallback(this) + } + } + + override fun onItemRangeMoved( + sender: ObservableList, fromPosition: Int, + toPosition: Int, itemCount: Int + ) { + val listener = weakListener.get() + if (listener != null) { + val views = arrayOfNulls(itemCount) + for (i in 0 until itemCount) views[i] = listener.container.getChildAt(fromPosition + i) + listener.container.removeViews(fromPosition, itemCount) + for (i in 0 until itemCount) listener.container.addView(views[i], toPosition + i) + } else { + sender.removeOnListChangedCallback(this) + } + } + + override fun onItemRangeRemoved( + sender: ObservableList, positionStart: Int, + itemCount: Int + ) { + val listener = weakListener.get() + if (listener != null) { + listener.container.removeViews(positionStart, itemCount) + } else { + sender.removeOnListChangedCallback(this) + } + } + + } + +} diff --git a/ui/src/main/java/com/wireguard/android/databinding/Keyed.kt b/ui/src/main/java/com/wireguard/android/databinding/Keyed.kt new file mode 100644 index 0000000..fc4ee35 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/databinding/Keyed.kt @@ -0,0 +1,12 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.databinding + +/** + * Interface for objects that have a identifying key of the given type. + */ +interface Keyed { + val key: K +} diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt new file mode 100644 index 0000000..4d6c3a2 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt @@ -0,0 +1,32 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.databinding + +import androidx.databinding.ObservableArrayList + +/** + * ArrayList that allows looking up elements by some key property. As the key property must always + * be retrievable, this list cannot hold `null` elements. Because this class places no + * restrictions on the order or duplication of keys, lookup by key, as well as all list modification + * operations, require O(n) time. + */ +open class ObservableKeyedArrayList> : ObservableArrayList() { + fun containsKey(key: K) = indexOfKey(key) >= 0 + + operator fun get(key: K): E? { + val index = indexOfKey(key) + return if (index >= 0) get(index) else null + } + + open fun indexOfKey(key: K): Int { + val iterator = listIterator() + while (iterator.hasNext()) { + val index = iterator.nextIndex() + if (iterator.next()!!.key == key) + return index + } + return -1 + } +} diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt new file mode 100644 index 0000000..a9ef491 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt @@ -0,0 +1,106 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.databinding + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.databinding.ObservableList +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView +import com.wireguard.android.BR +import java.lang.ref.WeakReference + +/** + * A generic `RecyclerView.Adapter` backed by a `ObservableKeyedArrayList`. + */ +class ObservableKeyedRecyclerViewAdapter> internal constructor( + context: Context, private val layoutId: Int, + list: ObservableKeyedArrayList? +) : RecyclerView.Adapter() { + private val callback = OnListChangedCallback(this) + private val layoutInflater: LayoutInflater = LayoutInflater.from(context) + private var list: ObservableKeyedArrayList? = null + private var rowConfigurationHandler: RowConfigurationHandler? = null + + private fun getItem(position: Int): E? = if (list == null || position < 0 || position >= list!!.size) null else list?.get(position) + + override fun getItemCount() = list?.size ?: 0 + + override fun getItemId(position: Int) = (getKey(position)?.hashCode() ?: -1).toLong() + + private fun getKey(position: Int): K? = getItem(position)?.key + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.binding.setVariable(BR.collection, list) + holder.binding.setVariable(BR.key, getKey(position)) + holder.binding.setVariable(BR.item, getItem(position)) + holder.binding.executePendingBindings() + if (rowConfigurationHandler != null) { + val item = getItem(position) + if (item != null) { + rowConfigurationHandler?.onConfigureRow(holder.binding, item, position) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(DataBindingUtil.inflate(layoutInflater, layoutId, parent, false)) + + fun setList(newList: ObservableKeyedArrayList?) { + list?.removeOnListChangedCallback(callback) + list = newList + list?.addOnListChangedCallback(callback) + notifyDataSetChanged() + } + + fun setRowConfigurationHandler(rowConfigurationHandler: RowConfigurationHandler<*, *>?) { + @Suppress("UNCHECKED_CAST") + this.rowConfigurationHandler = rowConfigurationHandler as? RowConfigurationHandler + } + + interface RowConfigurationHandler { + fun onConfigureRow(binding: B, item: T, position: Int) + } + + private class OnListChangedCallback> constructor(adapter: ObservableKeyedRecyclerViewAdapter<*, E>) : ObservableList.OnListChangedCallback>() { + private val weakAdapter: WeakReference> = WeakReference(adapter) + + override fun onChanged(sender: ObservableList) { + val adapter = weakAdapter.get() + if (adapter != null) + adapter.notifyDataSetChanged() + else + sender.removeOnListChangedCallback(this) + } + + override fun onItemRangeChanged(sender: ObservableList, positionStart: Int, + itemCount: Int) { + onChanged(sender) + } + + override fun onItemRangeInserted(sender: ObservableList, positionStart: Int, + itemCount: Int) { + onChanged(sender) + } + + override fun onItemRangeMoved(sender: ObservableList, fromPosition: Int, + toPosition: Int, itemCount: Int) { + onChanged(sender) + } + + override fun onItemRangeRemoved(sender: ObservableList, positionStart: Int, + itemCount: Int) { + onChanged(sender) + } + + } + + class ViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) + + init { + setList(list) + } +} diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt b/ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt new file mode 100644 index 0000000..d6c039f --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt @@ -0,0 +1,82 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.databinding + +import java.util.AbstractList +import java.util.Collections +import java.util.Comparator +import java.util.Spliterator + +/** + * KeyedArrayList that enforces uniqueness and sorted order across the set of keys. This class uses + * binary search to improve lookup and replacement times to O(log(n)). However, due to the + * array-based nature of this class, insertion and removal of elements with anything but the largest + * key still require O(n) time. + */ +class ObservableSortedKeyedArrayList>(private val comparator: Comparator) : ObservableKeyedArrayList() { + @Transient + private val keyList = KeyList(this) + + override fun add(element: E): Boolean { + val insertionPoint = getInsertionPoint(element) + if (insertionPoint < 0) { + // Skipping insertion is non-destructive if the new and existing objects are the same. + if (element === get(-insertionPoint - 1)) return false + throw IllegalArgumentException("Element with same key already exists in list") + } + super.add(insertionPoint, element) + return true + } + + override fun add(index: Int, element: E) { + val insertionPoint = getInsertionPoint(element) + require(insertionPoint >= 0) { "Element with same key already exists in list" } + if (insertionPoint != index) throw IndexOutOfBoundsException("Wrong index given for element") + super.add(index, element) + } + + override fun addAll(elements: Collection): Boolean { + var didChange = false + for (e in elements) { + if (add(e)) + didChange = true + } + return didChange + } + + override fun addAll(index: Int, elements: Collection): Boolean { + var i = index + for (e in elements) + add(i++, e) + return true + } + + private fun getInsertionPoint(e: E) = -Collections.binarySearch(keyList, e.key, comparator) - 1 + + override fun indexOfKey(key: K): Int { + val index = Collections.binarySearch(keyList, key, comparator) + return if (index >= 0) index else -1 + } + + override fun set(index: Int, element: E): E { + val order = comparator.compare(element.key, get(index).key) + if (order != 0) { + // Allow replacement if the new key would be inserted adjacent to the replaced element. + val insertionPoint = getInsertionPoint(element) + if (insertionPoint < index || insertionPoint > index + 1) + throw IndexOutOfBoundsException("Wrong index given for element") + } + return super.set(index, element) + } + + private class KeyList>(private val list: ObservableSortedKeyedArrayList) : AbstractList(), Set { + override fun get(index: Int): K = list[index].key + + override val size + get() = list.size + + override fun spliterator(): Spliterator = super.spliterator() + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt new file mode 100644 index 0000000..f077cba --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt @@ -0,0 +1,104 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.content.pm.PackageManager +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.wireguard.android.R +import com.wireguard.android.util.resolveAttribute + +class AddTunnelsSheet : BottomSheetDialogFragment() { + + private var behavior: BottomSheetBehavior? = null + private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) { + } + + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + dismiss() + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + if (savedInstanceState != null) dismiss() + val view = inflater.inflate(R.layout.add_tunnels_bottom_sheet, container, false) + if (activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) != true) { + val qrcode = view.findViewById(R.id.create_from_qrcode) + qrcode.isEnabled = false + qrcode.visibility = View.GONE + } + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + val dialog = dialog as BottomSheetDialog? ?: return + behavior = dialog.behavior + behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + peekHeight = 0 + addBottomSheetCallback(bottomSheetCallback) + } + dialog.findViewById(R.id.create_empty)?.setOnClickListener { + dismiss() + onRequestCreateConfig() + } + dialog.findViewById(R.id.create_from_file)?.setOnClickListener { + dismiss() + onRequestImportConfig() + } + dialog.findViewById(R.id.create_from_qrcode)?.setOnClickListener { + dismiss() + onRequestScanQRCode() + } + } + }) + val gradientDrawable = GradientDrawable().apply { + setColor(requireContext().resolveAttribute(com.google.android.material.R.attr.colorSurface)) + } + view.background = gradientDrawable + } + + override fun dismiss() { + super.dismiss() + behavior?.removeBottomSheetCallback(bottomSheetCallback) + } + + private fun onRequestCreateConfig() { + setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_CREATE)) + } + + private fun onRequestImportConfig() { + setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_IMPORT)) + } + + private fun onRequestScanQRCode() { + setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_SCAN)) + } + + companion object { + const val REQUEST_KEY_NEW_TUNNEL = "request_new_tunnel" + const val REQUEST_METHOD = "request_method" + const val REQUEST_CREATE = "request_create" + const val REQUEST_IMPORT = "request_import" + const val REQUEST_SCAN = "request_scan" + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt new file mode 100644 index 0000000..692dd80 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt @@ -0,0 +1,170 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.Manifest +import android.app.Dialog +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PackageInfoFlags +import android.os.Build +import android.os.Bundle +import android.widget.Button +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.databinding.Observable +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayout +import com.wireguard.android.BR +import com.wireguard.android.R +import com.wireguard.android.databinding.AppListDialogFragmentBinding +import com.wireguard.android.databinding.ObservableKeyedArrayList +import com.wireguard.android.model.ApplicationData +import com.wireguard.android.util.ErrorMessages +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class AppListDialogFragment : DialogFragment() { + private val appData = ObservableKeyedArrayList() + private var currentlySelectedApps = emptyList() + private var initiallyExcluded = false + private var button: Button? = null + private var tabs: TabLayout? = null + + private fun loadData() { + val activity = activity ?: return + val pm = activity.packageManager + lifecycleScope.launch(Dispatchers.Default) { + try { + val applicationData: MutableList = ArrayList() + withContext(Dispatchers.IO) { + val packageInfos = getPackagesHoldingPermissions(pm, arrayOf(Manifest.permission.INTERNET)) + packageInfos.forEach { + val packageName = it.packageName + val appInfo = it.applicationInfo ?: return@forEach + val appData = + ApplicationData(appInfo.loadIcon(pm), appInfo.loadLabel(pm).toString(), packageName, currentlySelectedApps.contains(packageName)) + applicationData.add(appData) + appData.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable?, propertyId: Int) { + if (propertyId == BR.selected) + setButtonText() + } + }) + } + } + applicationData.sortWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + withContext(Dispatchers.Main.immediate) { + appData.clear() + appData.addAll(applicationData) + setButtonText() + } + } catch (e: Throwable) { + withContext(Dispatchers.Main.immediate) { + val error = ErrorMessages[e] + val message = activity.getString(R.string.error_fetching_apps, error) + Toast.makeText(activity, message, Toast.LENGTH_LONG).show() + dismissAllowingStateLoss() + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + currentlySelectedApps = (arguments?.getStringArrayList(KEY_SELECTED_APPS) ?: emptyList()) + initiallyExcluded = arguments?.getBoolean(KEY_IS_EXCLUDED) ?: true + } + + private fun getPackagesHoldingPermissions(pm: PackageManager, permissions: Array): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackagesHoldingPermissions(permissions, PackageInfoFlags.of(0L)) + } else { + @Suppress("DEPRECATION") + pm.getPackagesHoldingPermissions(permissions, 0) + } + } + + private fun setButtonText() { + val numSelected = appData.count { it.isSelected } + button?.text = if (numSelected == 0) + getString(R.string.use_all_applications) + else when (tabs?.selectedTabPosition) { + 0 -> resources.getQuantityString(R.plurals.exclude_n_applications, numSelected, numSelected) + 1 -> resources.getQuantityString(R.plurals.include_n_applications, numSelected, numSelected) + else -> null + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val alertDialogBuilder = MaterialAlertDialogBuilder(requireActivity()) + val binding = AppListDialogFragmentBinding.inflate(requireActivity().layoutInflater, null, false) + binding.executePendingBindings() + alertDialogBuilder.setView(binding.root) + tabs = binding.tabs + tabs?.apply { + selectTab(binding.tabs.getTabAt(if (initiallyExcluded) 0 else 1)) + addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabReselected(tab: TabLayout.Tab?) = Unit + override fun onTabUnselected(tab: TabLayout.Tab?) = Unit + override fun onTabSelected(tab: TabLayout.Tab?) = setButtonText() + }) + } + alertDialogBuilder.setPositiveButton(" ") { _, _ -> setSelectionAndDismiss() } + alertDialogBuilder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + alertDialogBuilder.setNeutralButton(R.string.toggle_all) { _, _ -> } + binding.fragment = this + binding.appData = appData + loadData() + val dialog = alertDialogBuilder.create() + dialog.setOnShowListener { + button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + setButtonText() + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { _ -> + val selectAll = appData.none { it.isSelected } + appData.forEach { + it.isSelected = selectAll + } + } + } + return dialog + } + + private fun setSelectionAndDismiss() { + val selectedApps: MutableList = ArrayList() + for (data in appData) { + if (data.isSelected) { + selectedApps.add(data.packageName) + } + } + setFragmentResult( + REQUEST_SELECTION, bundleOf( + KEY_SELECTED_APPS to selectedApps.toTypedArray(), + KEY_IS_EXCLUDED to (tabs?.selectedTabPosition == 0) + ) + ) + dismiss() + } + + companion object { + const val KEY_SELECTED_APPS = "selected_apps" + const val KEY_IS_EXCLUDED = "is_excluded" + const val REQUEST_SELECTION = "request_selection" + + fun newInstance(selectedApps: ArrayList?, isExcluded: Boolean): AppListDialogFragment { + val extras = Bundle() + extras.putStringArrayList(KEY_SELECTED_APPS, selectedApps) + extras.putBoolean(KEY_IS_EXCLUDED, isExcluded) + val fragment = AppListDialogFragment() + fragment.arguments = extras + return fragment + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt new file mode 100644 index 0000000..2e551f8 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt @@ -0,0 +1,114 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.content.Context +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.activity.BaseActivity +import com.wireguard.android.activity.BaseActivity.OnSelectedTunnelChangedListener +import com.wireguard.android.backend.GoBackend +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.TunnelDetailFragmentBinding +import com.wireguard.android.databinding.TunnelListItemBinding +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.util.ErrorMessages +import kotlinx.coroutines.launch + +/** + * Base class for fragments that need to know the currently-selected tunnel. Only does anything when + * attached to a `BaseActivity`. + */ +abstract class BaseFragment : Fragment(), OnSelectedTunnelChangedListener { + private var pendingTunnel: ObservableTunnel? = null + private var pendingTunnelUp: Boolean? = null + private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val tunnel = pendingTunnel + val checked = pendingTunnelUp + if (tunnel != null && checked != null) + setTunnelStateWithPermissionsResult(tunnel, checked) + pendingTunnel = null + pendingTunnelUp = null + } + + protected var selectedTunnel: ObservableTunnel? + get() = (activity as? BaseActivity)?.selectedTunnel + protected set(tunnel) { + (activity as? BaseActivity)?.selectedTunnel = tunnel + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (activity as? BaseActivity)?.addOnSelectedTunnelChangedListener(this) + } + + override fun onDetach() { + (activity as? BaseActivity)?.removeOnSelectedTunnelChangedListener(this) + super.onDetach() + } + + fun setTunnelState(view: View, checked: Boolean) { + val tunnel = when (val binding = DataBindingUtil.findBinding(view)) { + is TunnelDetailFragmentBinding -> binding.tunnel + is TunnelListItemBinding -> binding.item + else -> return + } ?: return + val activity = activity ?: return + activity.lifecycleScope.launch { + if (Application.getBackend() is GoBackend) { + try { + val intent = GoBackend.VpnService.prepare(activity) + if (intent != null) { + pendingTunnel = tunnel + pendingTunnelUp = checked + permissionActivityResultLauncher.launch(intent) + return@launch + } + } catch (e: Throwable) { + val message = activity.getString(R.string.error_prepare, ErrorMessages[e]) + Snackbar.make(view, message, Snackbar.LENGTH_LONG) + .setAnchorView(view.findViewById(R.id.create_fab)) + .show() + Log.e(TAG, message, e) + } + } + setTunnelStateWithPermissionsResult(tunnel, checked) + } + } + + private fun setTunnelStateWithPermissionsResult(tunnel: ObservableTunnel, checked: Boolean) { + val activity = activity ?: return + activity.lifecycleScope.launch { + try { + tunnel.setStateAsync(Tunnel.State.of(checked)) + } catch (e: Throwable) { + val error = ErrorMessages[e] + val messageResId = if (checked) R.string.error_up else R.string.error_down + val message = activity.getString(messageResId, error) + val view = view + if (view != null) + Snackbar.make(view, message, Snackbar.LENGTH_LONG) + .setAnchorView(view.findViewById(R.id.create_fab)) + .show() + else + Toast.makeText(activity, message, Toast.LENGTH_LONG).show() + Log.e(TAG, message, e) + } + } + } + + companion object { + private const val TAG = "WireGuard/BaseFragment" + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt new file mode 100644 index 0000000..23da3fc --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt @@ -0,0 +1,82 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.app.Dialog +import android.os.Bundle +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.databinding.ConfigNamingDialogFragmentBinding +import com.wireguard.config.BadConfigException +import com.wireguard.config.Config +import kotlinx.coroutines.launch +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.charset.StandardCharsets + +class ConfigNamingDialogFragment : DialogFragment() { + private var binding: ConfigNamingDialogFragmentBinding? = null + private var config: Config? = null + + private fun createTunnelAndDismiss() { + val binding = binding ?: return + val activity = activity ?: return + val name = binding.tunnelNameText.text.toString() + activity.lifecycleScope.launch { + try { + Application.getTunnelManager().create(name, config) + dismiss() + } catch (e: Throwable) { + binding.tunnelNameTextLayout.error = e.message + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configText = requireArguments().getString(KEY_CONFIG_TEXT) + val configBytes = configText!!.toByteArray(StandardCharsets.UTF_8) + config = try { + Config.parse(ByteArrayInputStream(configBytes)) + } catch (e: Throwable) { + when (e) { + is BadConfigException, is IOException -> throw IllegalArgumentException("Invalid config passed to ${javaClass.simpleName}", e) + else -> throw e + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val activity = requireActivity() + val alertDialogBuilder = MaterialAlertDialogBuilder(activity) + alertDialogBuilder.setTitle(R.string.import_from_qr_code) + binding = ConfigNamingDialogFragmentBinding.inflate(activity.layoutInflater, null, false) + binding?.apply { + executePendingBindings() + alertDialogBuilder.setView(root) + } + alertDialogBuilder.setPositiveButton(R.string.create_tunnel) { _, _ -> createTunnelAndDismiss() } + alertDialogBuilder.setNegativeButton(R.string.cancel) { _, _ -> dismiss() } + val dialog = alertDialogBuilder.create() + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + return dialog + } + + companion object { + private const val KEY_CONFIG_TEXT = "config_text" + + fun newInstance(configText: String?): ConfigNamingDialogFragment { + val extras = Bundle() + extras.putString(KEY_CONFIG_TEXT, configText) + val fragment = ConfigNamingDialogFragment() + fragment.arguments = extras + return fragment + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt new file mode 100644 index 0000000..7731391 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt @@ -0,0 +1,150 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.core.view.MenuProvider +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import com.wireguard.android.R +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.TunnelDetailFragmentBinding +import com.wireguard.android.databinding.TunnelDetailPeerBinding +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.util.QuantityFormatter +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Fragment that shows details about a specific tunnel. + */ +class TunnelDetailFragment : BaseFragment(), MenuProvider { + private var binding: TunnelDetailFragmentBinding? = null + private var lastState = Tunnel.State.TOGGLE + private var timerActive = true + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return false + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.tunnel_detail, menu) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + super.onCreateView(inflater, container, savedInstanceState) + binding = TunnelDetailFragmentBinding.inflate(inflater, container, false) + binding?.executePendingBindings() + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onResume() { + super.onResume() + timerActive = true + lifecycleScope.launch { + while (timerActive) { + updateStats() + delay(1000) + } + } + } + + override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) { + val binding = binding ?: return + binding.tunnel = newTunnel + if (newTunnel == null) { + binding.config = null + } else { + lifecycleScope.launch { + try { + binding.config = newTunnel.getConfigAsync() + } catch (_: Throwable) { + binding.config = null + } + } + } + lastState = Tunnel.State.TOGGLE + lifecycleScope.launch { updateStats() } + } + + override fun onStop() { + timerActive = false + super.onStop() + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + binding ?: return + binding!!.fragment = this + onSelectedTunnelChanged(null, selectedTunnel) + super.onViewStateRestored(savedInstanceState) + } + + private suspend fun updateStats() { + val binding = binding ?: return + val tunnel = binding.tunnel ?: return + if (!isResumed) return + val state = tunnel.state + if (state != Tunnel.State.UP && lastState == state) return + lastState = state + try { + val statistics = tunnel.getStatisticsAsync() + for (i in 0 until binding.peersLayout.childCount) { + val peer: TunnelDetailPeerBinding = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i)) + ?: continue + val publicKey = peer.item!!.publicKey + val peerStats = statistics.peer(publicKey) + if (peerStats == null || (peerStats.rxBytes == 0L && peerStats.txBytes == 0L)) { + peer.transferLabel.visibility = View.GONE + peer.transferText.visibility = View.GONE + } else { + peer.transferText.text = getString( + R.string.transfer_rx_tx, + QuantityFormatter.formatBytes(peerStats.rxBytes), + QuantityFormatter.formatBytes(peerStats.txBytes) + ) + peer.transferLabel.visibility = View.VISIBLE + peer.transferText.visibility = View.VISIBLE + } + if (peerStats == null || peerStats.latestHandshakeEpochMillis == 0L) { + peer.latestHandshakeLabel.visibility = View.GONE + peer.latestHandshakeText.visibility = View.GONE + } else { + peer.latestHandshakeText.text = QuantityFormatter.formatEpochAgo(peerStats.latestHandshakeEpochMillis) + peer.latestHandshakeLabel.visibility = View.VISIBLE + peer.latestHandshakeText.visibility = View.VISIBLE + } + } + } catch (e: Throwable) { + for (i in 0 until binding.peersLayout.childCount) { + val peer: TunnelDetailPeerBinding = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i)) + ?: continue + peer.transferLabel.visibility = View.GONE + peer.transferText.visibility = View.GONE + peer.latestHandshakeLabel.visibility = View.GONE + peer.latestHandshakeText.visibility = View.GONE + } + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt new file mode 100644 index 0000000..f5d28ad --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt @@ -0,0 +1,333 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.content.Context +import android.os.Bundle +import android.text.InputType +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.Toast +import androidx.core.os.BundleCompat +import androidx.core.view.MenuProvider +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.TunnelEditorFragmentBinding +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.util.AdminKnobs +import com.wireguard.android.util.BiometricAuthenticator +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.viewmodel.ConfigProxy +import com.wireguard.config.Config +import kotlinx.coroutines.launch + +/** + * Fragment for editing a WireGuard configuration. + */ +class TunnelEditorFragment : BaseFragment(), MenuProvider { + private var haveShownKeys = false + private var binding: TunnelEditorFragmentBinding? = null + private var tunnel: ObservableTunnel? = null + + private fun onConfigLoaded(config: Config) { + binding?.config = ConfigProxy(config) + } + + private fun onConfigSaved(savedTunnel: Tunnel, throwable: Throwable?) { + val ctx = activity ?: Application.get() + if (throwable == null) { + val message = ctx.getString(R.string.config_save_success, savedTunnel.name) + Log.d(TAG, message) + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + onFinished() + } else { + val error = ErrorMessages[throwable] + val message = ctx.getString(R.string.config_save_error, savedTunnel.name, error) + Log.e(TAG, message, throwable) + val binding = binding + if (binding != null) + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show() + else + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.config_editor, menu) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + super.onCreateView(inflater, container, savedInstanceState) + binding = TunnelEditorFragmentBinding.inflate(inflater, container, false) + binding?.apply { + executePendingBindings() + privateKeyTextLayout.setEndIconOnClickListener { config?.`interface`?.generateKeyPair() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + override fun onDestroyView() { + activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + binding = null + super.onDestroyView() + } + + private fun onFinished() { + // Hide the keyboard; it rarely goes away on its own. + val activity = activity ?: return + val focusedView = activity.currentFocus + if (focusedView != null) { + val inputManager = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + inputManager?.hideSoftInputFromWindow( + focusedView.windowToken, + InputMethodManager.HIDE_NOT_ALWAYS + ) + } + parentFragmentManager.popBackStackImmediate() + + // If we just made a new one, save it to select the details page. + if (selectedTunnel != tunnel) + selectedTunnel = tunnel + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId == R.id.menu_action_save) { + binding ?: return false + val newConfig = try { + binding!!.config!!.resolve() + } catch (e: Throwable) { + val error = ErrorMessages[e] + val tunnelName = if (tunnel == null) binding!!.name else tunnel!!.name + val message = getString(R.string.config_save_error, tunnelName, error) + Log.e(TAG, message, e) + Snackbar.make(binding!!.mainContainer, error, Snackbar.LENGTH_LONG).show() + return false + } + val activity = requireActivity() + activity.lifecycleScope.launch { + when { + tunnel == null -> { + Log.d(TAG, "Attempting to create new tunnel " + binding!!.name) + val manager = Application.getTunnelManager() + try { + onTunnelCreated(manager.create(binding!!.name!!, newConfig), null) + } catch (e: Throwable) { + onTunnelCreated(null, e) + } + } + + tunnel!!.name != binding!!.name -> { + Log.d(TAG, "Attempting to rename tunnel to " + binding!!.name) + try { + tunnel!!.setNameAsync(binding!!.name!!) + onTunnelRenamed(tunnel!!, newConfig, null) + } catch (e: Throwable) { + onTunnelRenamed(tunnel!!, newConfig, e) + } + } + + else -> { + Log.d(TAG, "Attempting to save config of " + tunnel!!.name) + try { + tunnel!!.setConfigAsync(newConfig) + onConfigSaved(tunnel!!, null) + } catch (e: Throwable) { + onConfigSaved(tunnel!!, e) + } + } + } + } + return true + } + return false + } + + @Suppress("UNUSED_PARAMETER") + fun onRequestSetExcludedIncludedApplications(view: View?) { + if (binding != null) { + var isExcluded = true + var selectedApps = ArrayList(binding!!.config!!.`interface`.excludedApplications) + if (selectedApps.isEmpty()) { + selectedApps = ArrayList(binding!!.config!!.`interface`.includedApplications) + if (selectedApps.isNotEmpty()) + isExcluded = false + } + val fragment = AppListDialogFragment.newInstance(selectedApps, isExcluded) + childFragmentManager.setFragmentResultListener(AppListDialogFragment.REQUEST_SELECTION, viewLifecycleOwner) { _, bundle -> + requireNotNull(binding) { "Tried to set excluded/included apps while no view was loaded" } + val newSelections = requireNotNull(bundle.getStringArray(AppListDialogFragment.KEY_SELECTED_APPS)) + val excluded = requireNotNull(bundle.getBoolean(AppListDialogFragment.KEY_IS_EXCLUDED)) + if (excluded) { + binding!!.config!!.`interface`.includedApplications.clear() + binding!!.config!!.`interface`.excludedApplications.apply { + clear() + addAll(newSelections) + } + } else { + binding!!.config!!.`interface`.excludedApplications.clear() + binding!!.config!!.`interface`.includedApplications.apply { + clear() + addAll(newSelections) + } + } + } + fragment.show(childFragmentManager, null) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + if (binding != null) outState.putParcelable(KEY_LOCAL_CONFIG, binding!!.config) + outState.putString(KEY_ORIGINAL_NAME, if (tunnel == null) null else tunnel!!.name) + super.onSaveInstanceState(outState) + } + + override fun onSelectedTunnelChanged( + oldTunnel: ObservableTunnel?, + newTunnel: ObservableTunnel? + ) { + tunnel = newTunnel + if (binding == null) return + binding!!.config = ConfigProxy() + if (tunnel != null) { + binding!!.name = tunnel!!.name + lifecycleScope.launch { + try { + onConfigLoaded(tunnel!!.getConfigAsync()) + } catch (_: Throwable) { + } + } + } else { + binding!!.name = "" + } + } + + private fun onTunnelCreated(newTunnel: ObservableTunnel?, throwable: Throwable?) { + val ctx = activity ?: Application.get() + if (throwable == null) { + tunnel = newTunnel + val message = ctx.getString(R.string.tunnel_create_success, tunnel!!.name) + Log.d(TAG, message) + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + onFinished() + } else { + val error = ErrorMessages[throwable] + val message = ctx.getString(R.string.tunnel_create_error, error) + Log.e(TAG, message, throwable) + val binding = binding + if (binding != null) + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show() + else + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + } + } + + private suspend fun onTunnelRenamed( + renamedTunnel: ObservableTunnel, newConfig: Config, + throwable: Throwable? + ) { + val ctx = activity ?: Application.get() + if (throwable == null) { + val message = ctx.getString(R.string.tunnel_rename_success, renamedTunnel.name) + Log.d(TAG, message) + // Now save the rest of configuration changes. + Log.d(TAG, "Attempting to save config of renamed tunnel " + tunnel!!.name) + try { + renamedTunnel.setConfigAsync(newConfig) + onConfigSaved(renamedTunnel, null) + } catch (e: Throwable) { + onConfigSaved(renamedTunnel, e) + } + } else { + val error = ErrorMessages[throwable] + val message = ctx.getString(R.string.tunnel_rename_error, error) + Log.e(TAG, message, throwable) + val binding = binding + if (binding != null) + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show() + else + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + } + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + binding ?: return + binding!!.fragment = this + if (savedInstanceState == null) { + onSelectedTunnelChanged(null, selectedTunnel) + } else { + tunnel = selectedTunnel + val config = BundleCompat.getParcelable(savedInstanceState, KEY_LOCAL_CONFIG, ConfigProxy::class.java)!! + val originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME) + if (tunnel != null && tunnel!!.name != originalName) onSelectedTunnelChanged(null, tunnel) else binding!!.config = config + } + super.onViewStateRestored(savedInstanceState) + } + + private var showingAuthenticator = false + + fun onKeyClick(view: View) = onKeyFocusChange(view, true) + + fun onKeyFocusChange(view: View, isFocused: Boolean) { + if (!isFocused || showingAuthenticator) return + val edit = view as? EditText ?: return + if (edit.inputType == InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) return + if (!haveShownKeys && edit.text.isNotEmpty()) { + if (AdminKnobs.disableConfigExport) return + showingAuthenticator = true + BiometricAuthenticator.authenticate(R.string.biometric_prompt_private_key_title, this) { + showingAuthenticator = false + when (it) { + is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { + haveShownKeys = true + showPrivateKey(edit) + } + + is BiometricAuthenticator.Result.Failure -> { + Snackbar.make( + binding!!.mainContainer, + it.message, + Snackbar.LENGTH_SHORT + ).show() + } + + is BiometricAuthenticator.Result.Cancelled -> {} + } + } + } else { + showPrivateKey(edit) + } + } + + private fun showPrivateKey(edit: EditText) { + activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + edit.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + } + + companion object { + private const val KEY_LOCAL_CONFIG = "local_config" + private const val KEY_ORIGINAL_NAME = "original_name" + private const val TAG = "WireGuard/TunnelEditorFragment" + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt new file mode 100644 index 0000000..119b6af --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt @@ -0,0 +1,342 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.content.Intent +import android.content.res.Resources +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.google.zxing.qrcode.QRCodeReader +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.activity.TunnelCreatorActivity +import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler +import com.wireguard.android.databinding.TunnelListFragmentBinding +import com.wireguard.android.databinding.TunnelListItemBinding +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.updater.SnackbarUpdateShower +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.QrCodeFromFileScanner +import com.wireguard.android.util.TunnelImporter +import com.wireguard.android.widget.MultiselectableRelativeLayout +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch + +/** + * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels. + */ +class TunnelListFragment : BaseFragment() { + private val actionModeListener = ActionModeListener() + private var actionMode: ActionMode? = null + private var backPressedCallback: OnBackPressedCallback? = null + private var binding: TunnelListFragmentBinding? = null + private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data -> + if (data == null) return@registerForActivityResult + val activity = activity ?: return@registerForActivityResult + val contentResolver = activity.contentResolver ?: return@registerForActivityResult + activity.lifecycleScope.launch { + if (QrCodeFromFileScanner.validContentType(contentResolver, data)) { + try { + val qrCodeFromFileScanner = QrCodeFromFileScanner(contentResolver, QRCodeReader()) + val result = qrCodeFromFileScanner.scan(data) + TunnelImporter.importTunnel(parentFragmentManager, result.text) { showSnackbar(it) } + } catch (e: Exception) { + val error = ErrorMessages[e] + val message = Application.get().resources.getString(R.string.import_error, error) + Log.e(TAG, message, e) + showSnackbar(message) + } + } else { + TunnelImporter.importTunnel(contentResolver, data) { showSnackbar(it) } + } + } + } + + private val qrImportResultLauncher = registerForActivityResult(ScanContract()) { result -> + val qrCode = result.contents + val activity = activity + if (qrCode != null && activity != null) { + activity.lifecycleScope.launch { TunnelImporter.importTunnel(parentFragmentManager, qrCode) { showSnackbar(it) } } + } + } + + private val snackbarUpdateShower = SnackbarUpdateShower(this) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (savedInstanceState != null) { + val checkedItems = savedInstanceState.getIntegerArrayList(CHECKED_ITEMS) + if (checkedItems != null) { + for (i in checkedItems) actionModeListener.setItemChecked(i, true) + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + super.onCreateView(inflater, container, savedInstanceState) + binding = TunnelListFragmentBinding.inflate(inflater, container, false) + val bottomSheet = AddTunnelsSheet() + binding?.apply { + createFab.setOnClickListener { + if (childFragmentManager.findFragmentByTag("BOTTOM_SHEET") != null) + return@setOnClickListener + childFragmentManager.setFragmentResultListener(AddTunnelsSheet.REQUEST_KEY_NEW_TUNNEL, viewLifecycleOwner) { _, bundle -> + when (bundle.getString(AddTunnelsSheet.REQUEST_METHOD)) { + AddTunnelsSheet.REQUEST_CREATE -> { + startActivity(Intent(requireActivity(), TunnelCreatorActivity::class.java)) + } + + AddTunnelsSheet.REQUEST_IMPORT -> { + tunnelFileImportResultLauncher.launch("*/*") + } + + AddTunnelsSheet.REQUEST_SCAN -> { + qrImportResultLauncher.launch( + ScanOptions() + .setOrientationLocked(false) + .setBeepEnabled(false) + .setPrompt(getString(R.string.qr_code_hint)) + ) + } + } + } + bottomSheet.showNow(childFragmentManager, "BOTTOM_SHEET") + } + executePendingBindings() + snackbarUpdateShower.attach(mainContainer, createFab) + } + backPressedCallback = requireActivity().onBackPressedDispatcher.addCallback(this) { actionMode?.finish() } + backPressedCallback?.isEnabled = false + + return binding?.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putIntegerArrayList(CHECKED_ITEMS, actionModeListener.getCheckedItems()) + } + + override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) { + binding ?: return + lifecycleScope.launch { + val tunnels = Application.getTunnelManager().getTunnels() + if (newTunnel != null) viewForTunnel(newTunnel, tunnels)?.setSingleSelected(true) + if (oldTunnel != null) viewForTunnel(oldTunnel, tunnels)?.setSingleSelected(false) + } + } + + private fun onTunnelDeletionFinished(count: Int, throwable: Throwable?) { + val message: String + val ctx = activity ?: Application.get() + if (throwable == null) { + message = ctx.resources.getQuantityString(R.plurals.delete_success, count, count) + } else { + val error = ErrorMessages[throwable] + message = ctx.resources.getQuantityString(R.plurals.delete_error, count, count, error) + Log.e(TAG, message, throwable) + } + showSnackbar(message) + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + binding ?: return + binding!!.fragment = this + lifecycleScope.launch { binding!!.tunnels = Application.getTunnelManager().getTunnels() } + binding!!.rowConfigurationHandler = object : RowConfigurationHandler { + override fun onConfigureRow(binding: TunnelListItemBinding, item: ObservableTunnel, position: Int) { + binding.fragment = this@TunnelListFragment + binding.root.setOnClickListener { + if (actionMode == null) { + selectedTunnel = item + } else { + actionModeListener.toggleItemChecked(position) + } + } + binding.root.setOnLongClickListener { + actionModeListener.toggleItemChecked(position) + true + } + if (actionMode != null) + (binding.root as MultiselectableRelativeLayout).setMultiSelected(actionModeListener.checkedItems.contains(position)) + else + (binding.root as MultiselectableRelativeLayout).setSingleSelected(selectedTunnel == item) + } + } + } + + private fun showSnackbar(message: CharSequence) { + val binding = binding + if (binding != null) + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG) + .setAnchorView(binding.createFab) + .show() + else + Toast.makeText(activity ?: Application.get(), message, Toast.LENGTH_SHORT).show() + } + + private fun viewForTunnel(tunnel: ObservableTunnel, tunnels: List<*>): MultiselectableRelativeLayout? { + return binding?.tunnelList?.findViewHolderForAdapterPosition(tunnels.indexOf(tunnel))?.itemView as? MultiselectableRelativeLayout + } + + private inner class ActionModeListener : ActionMode.Callback { + val checkedItems: MutableCollection = HashSet() + private var resources: Resources? = null + + fun getCheckedItems(): ArrayList { + return ArrayList(checkedItems) + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_action_delete -> { + val activity = activity ?: return true + val copyCheckedItems = HashSet(checkedItems) + binding?.createFab?.apply { + visibility = View.VISIBLE + scaleX = 1f + scaleY = 1f + } + activity.lifecycleScope.launch { + try { + val tunnels = Application.getTunnelManager().getTunnels() + val tunnelsToDelete = ArrayList() + for (position in copyCheckedItems) tunnelsToDelete.add(tunnels[position]) + val futures = tunnelsToDelete.map { async(SupervisorJob()) { it.deleteAsync() } } + onTunnelDeletionFinished(futures.awaitAll().size, null) + } catch (e: Throwable) { + onTunnelDeletionFinished(0, e) + } + } + checkedItems.clear() + mode.finish() + true + } + + R.id.menu_action_select_all -> { + lifecycleScope.launch { + val tunnels = Application.getTunnelManager().getTunnels() + for (i in 0 until tunnels.size) { + setItemChecked(i, true) + } + } + true + } + + else -> false + } + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + actionMode = mode + backPressedCallback?.isEnabled = true + if (activity != null) { + resources = activity!!.resources + } + animateFab(binding?.createFab, false) + mode.menuInflater.inflate(R.menu.tunnel_list_action_mode, menu) + binding?.tunnelList?.adapter?.notifyDataSetChanged() + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + actionMode = null + backPressedCallback?.isEnabled = false + resources = null + animateFab(binding?.createFab, true) + checkedItems.clear() + binding?.tunnelList?.adapter?.notifyDataSetChanged() + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + updateTitle(mode) + return false + } + + fun setItemChecked(position: Int, checked: Boolean) { + if (checked) { + checkedItems.add(position) + } else { + checkedItems.remove(position) + } + val adapter = if (binding == null) null else binding!!.tunnelList.adapter + if (actionMode == null && !checkedItems.isEmpty() && activity != null) { + (activity as AppCompatActivity).startSupportActionMode(this) + } else if (actionMode != null && checkedItems.isEmpty()) { + actionMode!!.finish() + } + adapter?.notifyItemChanged(position) + updateTitle(actionMode) + } + + fun toggleItemChecked(position: Int) { + setItemChecked(position, !checkedItems.contains(position)) + } + + private fun updateTitle(mode: ActionMode?) { + if (mode == null) { + return + } + val count = checkedItems.size + if (count == 0) { + mode.title = "" + } else { + mode.title = resources!!.getQuantityString(R.plurals.delete_title, count, count) + } + } + + private fun animateFab(view: View?, show: Boolean) { + view ?: return + val animation = AnimationUtils.loadAnimation( + context, if (show) R.anim.scale_up else R.anim.scale_down + ) + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationRepeat(animation: Animation?) { + } + + override fun onAnimationEnd(animation: Animation?) { + if (!show) view.visibility = View.GONE + } + + override fun onAnimationStart(animation: Animation?) { + if (show) view.visibility = View.VISIBLE + } + }) + view.startAnimation(animation) + } + } + + companion object { + private const val CHECKED_ITEMS = "CHECKED_ITEMS" + private const val TAG = "WireGuard/TunnelListFragment" + } +} diff --git a/ui/src/main/java/com/wireguard/android/model/ApplicationData.kt b/ui/src/main/java/com/wireguard/android/model/ApplicationData.kt new file mode 100644 index 0000000..e6b5705 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/model/ApplicationData.kt @@ -0,0 +1,22 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.model + +import android.graphics.drawable.Drawable +import androidx.databinding.BaseObservable +import androidx.databinding.Bindable +import com.wireguard.android.BR +import com.wireguard.android.databinding.Keyed + +class ApplicationData(val icon: Drawable, val name: String, val packageName: String, isSelected: Boolean) : BaseObservable(), Keyed { + override val key = name + + @get:Bindable + var isSelected = isSelected + set(value) { + field = value + notifyPropertyChanged(BR.selected) + } +} diff --git a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt new file mode 100644 index 0000000..227c129 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt @@ -0,0 +1,146 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.model + +import android.util.Log +import androidx.databinding.BaseObservable +import androidx.databinding.Bindable +import com.wireguard.android.BR +import com.wireguard.android.backend.Statistics +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.Keyed +import com.wireguard.android.util.applicationScope +import com.wireguard.config.Config +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Encapsulates the volatile and nonvolatile state of a WireGuard tunnel. + */ +class ObservableTunnel internal constructor( + private val manager: TunnelManager, + private var name: String, + config: Config?, + state: Tunnel.State +) : BaseObservable(), Keyed, Tunnel { + override val key + get() = name + + @Bindable + override fun getName() = name + + suspend fun setNameAsync(name: String): String = withContext(Dispatchers.Main.immediate) { + if (name != this@ObservableTunnel.name) + manager.setTunnelName(this@ObservableTunnel, name) + else + this@ObservableTunnel.name + } + + fun onNameChanged(name: String): String { + this.name = name + notifyPropertyChanged(BR.name) + return name + } + + + @get:Bindable + var state = state + private set + + override fun onStateChange(newState: Tunnel.State) { + onStateChanged(newState) + } + + fun onStateChanged(state: Tunnel.State): Tunnel.State { + if (state != Tunnel.State.UP) onStatisticsChanged(null) + this.state = state + notifyPropertyChanged(BR.state) + return state + } + + suspend fun setStateAsync(state: Tunnel.State): Tunnel.State = withContext(Dispatchers.Main.immediate) { + if (state != this@ObservableTunnel.state) + manager.setTunnelState(this@ObservableTunnel, state) + else + this@ObservableTunnel.state + } + + + @get:Bindable + var config = config + get() { + if (field == null) + // Opportunistically fetch this if we don't have a cached one, and rely on data bindings to update it eventually + applicationScope.launch { + try { + manager.getTunnelConfig(this@ObservableTunnel) + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + return field + } + private set + + suspend fun getConfigAsync(): Config = withContext(Dispatchers.Main.immediate) { + config ?: manager.getTunnelConfig(this@ObservableTunnel) + } + + suspend fun setConfigAsync(config: Config): Config = withContext(Dispatchers.Main.immediate) { + this@ObservableTunnel.config.let { + if (config != it) + manager.setTunnelConfig(this@ObservableTunnel, config) + else + it + } + } + + fun onConfigChanged(config: Config?): Config? { + this.config = config + notifyPropertyChanged(BR.config) + return config + } + + + @get:Bindable + var statistics: Statistics? = null + get() { + if (field == null || field?.isStale != false) + // Opportunistically fetch this if we don't have a cached one, and rely on data bindings to update it eventually + applicationScope.launch { + try { + manager.getTunnelStatistics(this@ObservableTunnel) + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + return field + } + private set + + suspend fun getStatisticsAsync(): Statistics = withContext(Dispatchers.Main.immediate) { + statistics.let { + if (it == null || it.isStale) + manager.getTunnelStatistics(this@ObservableTunnel) + else + it + } + } + + fun onStatisticsChanged(statistics: Statistics?): Statistics? { + this.statistics = statistics + notifyPropertyChanged(BR.statistics) + return statistics + } + + + suspend fun deleteAsync() = manager.delete(this) + + + companion object { + private const val TAG = "WireGuard/ObservableTunnel" + } +} diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt b/ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt new file mode 100644 index 0000000..3be1019 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt @@ -0,0 +1,61 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.model + +object TunnelComparator : Comparator { + private class NaturalSortString(originalString: String) { + class NaturalSortToken(val maybeString: String?, val maybeNumber: Int?) : Comparable { + override fun compareTo(other: NaturalSortToken): Int { + if (maybeString == null) { + if (other.maybeString != null || maybeNumber!! < other.maybeNumber!!) { + return -1 + } else if (maybeNumber > other.maybeNumber) { + return 1 + } + } else if (other.maybeString == null || maybeString > other.maybeString) { + return 1 + } else if (maybeString < other.maybeString) { + return -1 + } + return 0 + } + } + + val tokens: MutableList = ArrayList() + + init { + for (s in NATURAL_SORT_DIGIT_FINDER.findAll(originalString.split(WHITESPACE_FINDER).joinToString(" ").lowercase())) { + try { + val n = s.value.toInt() + tokens.add(NaturalSortToken(null, n)) + } catch (_: NumberFormatException) { + tokens.add(NaturalSortToken(s.value, null)) + } + } + } + + private companion object { + private val NATURAL_SORT_DIGIT_FINDER = Regex("""\d+|\D+""") + private val WHITESPACE_FINDER = Regex("""\s""") + } + } + + override fun compare(a: String, b: String): Int { + if (a == b) + return 0 + val na = NaturalSortString(a) + val nb = NaturalSortString(b) + for (i in 0 until nb.tokens.size) { + if (i == na.tokens.size) { + return -1 + } + val c = na.tokens[i].compareTo(nb.tokens[i]) + if (c != 0) + return c + } + return 1 + } +} diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt new file mode 100644 index 0000000..e08623d --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt @@ -0,0 +1,254 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.model + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import android.widget.Toast +import androidx.databinding.BaseObservable +import androidx.databinding.Bindable +import com.wireguard.android.Application.Companion.get +import com.wireguard.android.Application.Companion.getBackend +import com.wireguard.android.Application.Companion.getTunnelManager +import com.wireguard.android.BR +import com.wireguard.android.R +import com.wireguard.android.backend.Statistics +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.configStore.ConfigStore +import com.wireguard.android.databinding.ObservableSortedKeyedArrayList +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.UserKnobs +import com.wireguard.android.util.applicationScope +import com.wireguard.config.Config +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Maintains and mediates changes to the set of available WireGuard tunnels, + */ +class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { + private val tunnels = CompletableDeferred>() + private val context: Context = get() + private val tunnelMap: ObservableSortedKeyedArrayList = ObservableSortedKeyedArrayList(TunnelComparator) + private var haveLoaded = false + + private fun addToList(name: String, config: Config?, state: Tunnel.State): ObservableTunnel { + val tunnel = ObservableTunnel(this, name, config, state) + tunnelMap.add(tunnel) + return tunnel + } + + suspend fun getTunnels(): ObservableSortedKeyedArrayList = tunnels.await() + + suspend fun create(name: String, config: Config?): ObservableTunnel = withContext(Dispatchers.Main.immediate) { + if (Tunnel.isNameInvalid(name)) + throw IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name)) + if (tunnelMap.containsKey(name)) + throw IllegalArgumentException(context.getString(R.string.tunnel_error_already_exists, name)) + addToList(name, withContext(Dispatchers.IO) { configStore.create(name, config!!) }, Tunnel.State.DOWN) + } + + suspend fun delete(tunnel: ObservableTunnel) = withContext(Dispatchers.Main.immediate) { + val originalState = tunnel.state + val wasLastUsed = tunnel == lastUsedTunnel + // Make sure nothing touches the tunnel. + if (wasLastUsed) + lastUsedTunnel = null + tunnelMap.remove(tunnel) + try { + if (originalState == Tunnel.State.UP) + withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.DOWN, null) } + try { + withContext(Dispatchers.IO) { configStore.delete(tunnel.name) } + } catch (e: Throwable) { + if (originalState == Tunnel.State.UP) + withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.UP, tunnel.config) } + throw e + } + } catch (e: Throwable) { + // Failure, put the tunnel back. + tunnelMap.add(tunnel) + if (wasLastUsed) + lastUsedTunnel = tunnel + throw e + } + } + + @get:Bindable + var lastUsedTunnel: ObservableTunnel? = null + private set(value) { + if (value == field) return + field = value + notifyPropertyChanged(BR.lastUsedTunnel) + applicationScope.launch { UserKnobs.setLastUsedTunnel(value?.name) } + } + + suspend fun getTunnelConfig(tunnel: ObservableTunnel): Config = withContext(Dispatchers.Main.immediate) { + tunnel.onConfigChanged(withContext(Dispatchers.IO) { configStore.load(tunnel.name) })!! + } + + fun onCreate() { + applicationScope.launch { + try { + onTunnelsLoaded(withContext(Dispatchers.IO) { configStore.enumerate() }, withContext(Dispatchers.IO) { getBackend().runningTunnelNames }) + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + } + + private fun onTunnelsLoaded(present: Iterable, running: Collection) { + for (name in present) + addToList(name, null, if (running.contains(name)) Tunnel.State.UP else Tunnel.State.DOWN) + applicationScope.launch { + val lastUsedName = UserKnobs.lastUsedTunnel.first() + if (lastUsedName != null) + lastUsedTunnel = tunnelMap[lastUsedName] + haveLoaded = true + restoreState(true) + tunnels.complete(tunnelMap) + } + } + + private fun refreshTunnelStates() { + applicationScope.launch { + try { + val running = withContext(Dispatchers.IO) { getBackend().runningTunnelNames } + for (tunnel in tunnelMap) + tunnel.onStateChanged(if (running.contains(tunnel.name)) Tunnel.State.UP else Tunnel.State.DOWN) + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + } + + suspend fun restoreState(force: Boolean) { + if (!haveLoaded || (!force && !UserKnobs.restoreOnBoot.first())) + return + val previouslyRunning = UserKnobs.runningTunnels.first() + if (previouslyRunning.isEmpty()) return + withContext(Dispatchers.IO) { + try { + tunnelMap.filter { previouslyRunning.contains(it.name) }.map { async(Dispatchers.IO + SupervisorJob()) { setTunnelState(it, Tunnel.State.UP) } } + .awaitAll() + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + } + + suspend fun saveState() { + UserKnobs.setRunningTunnels(tunnelMap.filter { it.state == Tunnel.State.UP }.map { it.name }.toSet()) + } + + suspend fun setTunnelConfig(tunnel: ObservableTunnel, config: Config): Config = withContext(Dispatchers.Main.immediate) { + tunnel.onConfigChanged(withContext(Dispatchers.IO) { + getBackend().setState(tunnel, tunnel.state, config) + configStore.save(tunnel.name, config) + })!! + } + + suspend fun setTunnelName(tunnel: ObservableTunnel, name: String): String = withContext(Dispatchers.Main.immediate) { + if (Tunnel.isNameInvalid(name)) + throw IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name)) + if (tunnelMap.containsKey(name)) { + throw IllegalArgumentException(context.getString(R.string.tunnel_error_already_exists, name)) + } + val originalState = tunnel.state + val wasLastUsed = tunnel == lastUsedTunnel + // Make sure nothing touches the tunnel. + if (wasLastUsed) + lastUsedTunnel = null + tunnelMap.remove(tunnel) + var throwable: Throwable? = null + var newName: String? = null + try { + if (originalState == Tunnel.State.UP) + withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.DOWN, null) } + withContext(Dispatchers.IO) { configStore.rename(tunnel.name, name) } + newName = tunnel.onNameChanged(name) + if (originalState == Tunnel.State.UP) + withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.UP, tunnel.config) } + } catch (e: Throwable) { + throwable = e + // On failure, we don't know what state the tunnel might be in. Fix that. + getTunnelState(tunnel) + } + // Add the tunnel back to the manager, under whatever name it thinks it has. + tunnelMap.add(tunnel) + if (wasLastUsed) + lastUsedTunnel = tunnel + if (throwable != null) + throw throwable + newName!! + } + + suspend fun setTunnelState(tunnel: ObservableTunnel, state: Tunnel.State): Tunnel.State = withContext(Dispatchers.Main.immediate) { + var newState = tunnel.state + var throwable: Throwable? = null + try { + newState = withContext(Dispatchers.IO) { getBackend().setState(tunnel, state, tunnel.getConfigAsync()) } + if (newState == Tunnel.State.UP) + lastUsedTunnel = tunnel + } catch (e: Throwable) { + throwable = e + } + tunnel.onStateChanged(newState) + saveState() + if (throwable != null) + throw throwable + newState + } + + class IntentReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + applicationScope.launch { + val manager = getTunnelManager() + if (intent == null) return@launch + val action = intent.action ?: return@launch + if ("com.wireguard.android.action.REFRESH_TUNNEL_STATES" == action) { + manager.refreshTunnelStates() + return@launch + } + if (!UserKnobs.allowRemoteControlIntents.first()) + return@launch + val state = when (action) { + "com.wireguard.android.action.SET_TUNNEL_UP" -> Tunnel.State.UP + "com.wireguard.android.action.SET_TUNNEL_DOWN" -> Tunnel.State.DOWN + else -> return@launch + } + val tunnelName = intent.getStringExtra("tunnel") ?: return@launch + val tunnels = manager.getTunnels() + val tunnel = tunnels[tunnelName] ?: return@launch + try { + manager.setTunnelState(tunnel, state) + } catch (e: Throwable) { + Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_LONG).show() + } + } + } + } + + suspend fun getTunnelState(tunnel: ObservableTunnel): Tunnel.State = withContext(Dispatchers.Main.immediate) { + tunnel.onStateChanged(withContext(Dispatchers.IO) { getBackend().getState(tunnel) }) + } + + suspend fun getTunnelStatistics(tunnel: ObservableTunnel): Statistics = withContext(Dispatchers.Main.immediate) { + tunnel.onStatisticsChanged(withContext(Dispatchers.IO) { getBackend().getStatistics(tunnel) })!! + } + + companion object { + private const val TAG = "WireGuard/TunnelManager" + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt b/ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt new file mode 100644 index 0000000..2f66a2c --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt @@ -0,0 +1,43 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.preference + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.AttributeSet +import android.widget.Toast +import androidx.preference.Preference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.wireguard.android.R +import com.wireguard.android.updater.Updater +import com.wireguard.android.util.ErrorMessages +import androidx.core.net.toUri + +class DonatePreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) { + override fun getSummary() = context.getString(R.string.donate_summary) + + override fun getTitle() = context.getString(R.string.donate_title) + + override fun onClick() { + /* Google Play Store forbids links to our donation page. */ + if (Updater.installerIsGooglePlay(context)) { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.donate_title) + .setMessage(R.string.donate_google_play_disappointment) + .show() + return + } + + val intent = Intent(Intent.ACTION_VIEW) + intent.data = "https://www.wireguard.com/donations/".toUri() + try { + context.startActivity(intent) + } catch (e: Throwable) { + Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show() + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt b/ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt new file mode 100644 index 0000000..3d1c27f --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt @@ -0,0 +1,88 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.preference + +import android.content.Context +import android.content.Intent +import android.util.AttributeSet +import android.util.Log +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.activity.SettingsActivity +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.backend.WgQuickBackend +import com.wireguard.android.util.UserKnobs +import com.wireguard.android.util.activity +import com.wireguard.android.util.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.system.exitProcess + +class KernelModuleEnablerPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) { + private var state = State.UNKNOWN + + init { + isVisible = false + lifecycleScope.launch { + setState(if (Application.getBackend() is WgQuickBackend) State.ENABLED else State.DISABLED) + } + } + + override fun getSummary() = if (state == State.UNKNOWN) "" else context.getString(state.summaryResourceId) + + override fun getTitle() = if (state == State.UNKNOWN) "" else context.getString(state.titleResourceId) + + override fun onClick() { + activity.lifecycleScope.launch { + if (state == State.DISABLED) { + setState(State.ENABLING) + UserKnobs.setEnableKernelModule(true) + } else if (state == State.ENABLED) { + setState(State.DISABLING) + UserKnobs.setEnableKernelModule(false) + } + val observableTunnels = Application.getTunnelManager().getTunnels() + val downings = observableTunnels.map { async(SupervisorJob()) { it.setStateAsync(Tunnel.State.DOWN) } } + try { + downings.awaitAll() + withContext(Dispatchers.IO) { + val restartIntent = Intent(context, SettingsActivity::class.java) + restartIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + Application.get().startActivity(restartIntent) + exitProcess(0) + } + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + } + + private fun setState(state: State) { + if (this.state == state) return + this.state = state + if (isEnabled != state.shouldEnableView) isEnabled = state.shouldEnableView + if (isVisible != state.visible) isVisible = state.visible + notifyChanged() + } + + private enum class State(val titleResourceId: Int, val summaryResourceId: Int, val shouldEnableView: Boolean, val visible: Boolean) { + UNKNOWN(0, 0, false, false), + ENABLED(R.string.module_enabler_enabled_title, R.string.module_enabler_enabled_summary, true, true), + DISABLED(R.string.module_enabler_disabled_title, R.string.module_enabler_disabled_summary, true, true), + ENABLING(R.string.module_enabler_disabled_title, R.string.success_application_will_restart, false, true), + DISABLING(R.string.module_enabler_enabled_title, R.string.success_application_will_restart, false, true); + } + + companion object { + private const val TAG = "WireGuard/KernelModuleEnablerPreference" + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt b/ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt new file mode 100644 index 0000000..e2fc51e --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt @@ -0,0 +1,135 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.preference + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.preference.PreferenceDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +class PreferencesPreferenceDataStore(private val coroutineScope: CoroutineScope, private val dataStore: DataStore) : PreferenceDataStore() { + override fun putString(key: String?, value: String?) { + if (key == null) return + val pk = stringPreferencesKey(key) + coroutineScope.launch { + dataStore.edit { + if (value == null) it.remove(pk) + else it[pk] = value + } + } + } + + override fun putStringSet(key: String?, values: Set?) { + if (key == null) return + val pk = stringSetPreferencesKey(key) + val filteredValues = values?.filterNotNull()?.toSet() + coroutineScope.launch { + dataStore.edit { + if (filteredValues == null || filteredValues.isEmpty()) it.remove(pk) + else it[pk] = filteredValues + } + } + } + + override fun putInt(key: String?, value: Int) { + if (key == null) return + val pk = intPreferencesKey(key) + coroutineScope.launch { + dataStore.edit { + it[pk] = value + } + } + } + + override fun putLong(key: String?, value: Long) { + if (key == null) return + val pk = longPreferencesKey(key) + coroutineScope.launch { + dataStore.edit { + it[pk] = value + } + } + } + + override fun putFloat(key: String?, value: Float) { + if (key == null) return + val pk = floatPreferencesKey(key) + coroutineScope.launch { + dataStore.edit { + it[pk] = value + } + } + } + + override fun putBoolean(key: String?, value: Boolean) { + if (key == null) return + val pk = booleanPreferencesKey(key) + coroutineScope.launch { + dataStore.edit { + it[pk] = value + } + } + } + + override fun getString(key: String?, defValue: String?): String? { + if (key == null) return defValue + val pk = stringPreferencesKey(key) + return runBlocking { + dataStore.data.map { it[pk] ?: defValue }.first() + } + } + + override fun getStringSet(key: String?, defValues: Set?): Set? { + if (key == null) return defValues + val pk = stringSetPreferencesKey(key) + return runBlocking { + dataStore.data.map { it[pk] ?: defValues }.first() + } + } + + override fun getInt(key: String?, defValue: Int): Int { + if (key == null) return defValue + val pk = intPreferencesKey(key) + return runBlocking { + dataStore.data.map { it[pk] ?: defValue }.first() + } + } + + override fun getLong(key: String?, defValue: Long): Long { + if (key == null) return defValue + val pk = longPreferencesKey(key) + return runBlocking { + dataStore.data.map { it[pk] ?: defValue }.first() + } + } + + override fun getFloat(key: String?, defValue: Float): Float { + if (key == null) return defValue + val pk = floatPreferencesKey(key) + return runBlocking { + dataStore.data.map { it[pk] ?: defValue }.first() + } + } + + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + if (key == null) return defValue + val pk = booleanPreferencesKey(key) + return runBlocking { + dataStore.data.map { it[pk] ?: defValue }.first() + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt b/ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt new file mode 100644 index 0000000..458b9f9 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt @@ -0,0 +1,50 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.preference + +import android.app.StatusBarManager +import android.content.ComponentName +import android.content.Context +import android.graphics.drawable.Icon +import android.os.Build +import android.util.AttributeSet +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.preference.Preference +import com.wireguard.android.QuickTileService +import com.wireguard.android.R + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +class QuickTilePreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) { + override fun getSummary() = context.getString(R.string.quick_settings_tile_add_summary) + + override fun getTitle() = context.getString(R.string.quick_settings_tile_add_title) + + override fun onClick() { + val statusBarManager = context.getSystemService(StatusBarManager::class.java) + statusBarManager.requestAddTileService( + ComponentName(context, QuickTileService::class.java), + context.getString(R.string.quick_settings_tile_action), + Icon.createWithResource(context, R.drawable.ic_tile), + context.mainExecutor + ) { + when (it) { + StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED, + StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED -> { + parent?.removePreference(this) + --preferenceManager.preferenceScreen.initialExpandedChildrenCount + } + StatusBarManager.TILE_ADD_REQUEST_ERROR_MISMATCHED_PACKAGE, + StatusBarManager.TILE_ADD_REQUEST_ERROR_REQUEST_IN_PROGRESS, + StatusBarManager.TILE_ADD_REQUEST_ERROR_BAD_COMPONENT, + StatusBarManager.TILE_ADD_REQUEST_ERROR_NOT_CURRENT_USER, + StatusBarManager.TILE_ADD_REQUEST_ERROR_APP_NOT_IN_FOREGROUND, + StatusBarManager.TILE_ADD_REQUEST_ERROR_NO_STATUS_BAR_SERVICE -> + Toast.makeText(context, context.getString(R.string.quick_settings_tile_add_failure, it), Toast.LENGTH_SHORT).show() + } + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt new file mode 100644 index 0000000..b22048b --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt @@ -0,0 +1,79 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.preference + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.Preference +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.util.ToolsInstaller +import com.wireguard.android.util.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Preference implementing a button that asynchronously runs `ToolsInstaller` and displays the + * result as the preference summary. + */ +class ToolsInstallerPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) { + private var state = State.INITIAL + override fun getSummary() = context.getString(state.messageResourceId) + + override fun getTitle() = context.getString(R.string.tools_installer_title) + + override fun onAttached() { + super.onAttached() + lifecycleScope.launch { + try { + val state = withContext(Dispatchers.IO) { Application.getToolsInstaller().areInstalled() } + when { + state == ToolsInstaller.ERROR -> setState(State.INITIAL) + state and ToolsInstaller.YES == ToolsInstaller.YES -> setState(State.ALREADY) + state and (ToolsInstaller.MAGISK or ToolsInstaller.NO) == ToolsInstaller.MAGISK or ToolsInstaller.NO -> setState(State.INITIAL_MAGISK) + state and (ToolsInstaller.SYSTEM or ToolsInstaller.NO) == ToolsInstaller.SYSTEM or ToolsInstaller.NO -> setState(State.INITIAL_SYSTEM) + else -> setState(State.INITIAL) + } + } catch (_: Throwable) { + setState(State.INITIAL) + } + } + } + + override fun onClick() { + setState(State.WORKING) + lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { Application.getToolsInstaller().install() } + when { + result and (ToolsInstaller.YES or ToolsInstaller.MAGISK) == ToolsInstaller.YES or ToolsInstaller.MAGISK -> setState(State.SUCCESS_MAGISK) + result and (ToolsInstaller.YES or ToolsInstaller.SYSTEM) == ToolsInstaller.YES or ToolsInstaller.SYSTEM -> setState(State.SUCCESS_SYSTEM) + else -> setState(State.FAILURE) + } + } catch (_: Throwable) { + setState(State.FAILURE) + } + } + } + + private fun setState(state: State) { + if (this.state == state) return + this.state = state + if (isEnabled != state.shouldEnableView) isEnabled = state.shouldEnableView + notifyChanged() + } + + private enum class State(val messageResourceId: Int, val shouldEnableView: Boolean) { + INITIAL(R.string.tools_installer_initial, true), + ALREADY(R.string.tools_installer_already, false), + FAILURE(R.string.tools_installer_failure, true), + WORKING(R.string.tools_installer_working, false), + INITIAL_SYSTEM(R.string.tools_installer_initial_system, true), + SUCCESS_SYSTEM(R.string.tools_installer_success_system, false), + INITIAL_MAGISK(R.string.tools_installer_initial_magisk, true), + SUCCESS_MAGISK(R.string.tools_installer_success_magisk, false); + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt new file mode 100644 index 0000000..3850482 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt @@ -0,0 +1,63 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.preference + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.AttributeSet +import android.widget.Toast +import androidx.preference.Preference +import com.wireguard.android.Application +import com.wireguard.android.BuildConfig +import com.wireguard.android.R +import com.wireguard.android.backend.Backend +import com.wireguard.android.backend.GoBackend +import com.wireguard.android.backend.WgQuickBackend +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class VersionPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) { + private var versionSummary: String? = null + + override fun getSummary() = versionSummary + + override fun getTitle() = context.getString(R.string.version_title, BuildConfig.VERSION_NAME) + + override fun onClick() { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse("https://www.wireguard.com/") + try { + context.startActivity(intent) + } catch (e: Throwable) { + Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show() + } + } + + companion object { + private fun getBackendPrettyName(context: Context, backend: Backend) = when (backend) { + is WgQuickBackend -> context.getString(R.string.type_name_kernel_module) + is GoBackend -> context.getString(R.string.type_name_go_userspace) + else -> "" + } + } + + init { + lifecycleScope.launch { + val backend = Application.getBackend() + versionSummary = getContext().getString(R.string.version_summary_checking, getBackendPrettyName(context, backend).lowercase()) + notifyChanged() + versionSummary = try { + getContext().getString(R.string.version_summary, getBackendPrettyName(context, backend), withContext(Dispatchers.IO) { backend.version }) + } catch (_: Throwable) { + getContext().getString(R.string.version_summary_unknown, getBackendPrettyName(context, backend).lowercase()) + } + notifyChanged() + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt new file mode 100644 index 0000000..5270115 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt @@ -0,0 +1,113 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.preference + +import android.content.Context +import android.util.AttributeSet +import android.util.Log +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.util.AdminKnobs +import com.wireguard.android.util.BiometricAuthenticator +import com.wireguard.android.util.DownloadsFileSaver +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.activity +import com.wireguard.android.util.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.nio.charset.StandardCharsets +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Preference implementing a button that asynchronously exports config zips. + */ +class ZipExporterPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) { + private var exportedFilePath: String? = null + private val downloadsFileSaver = DownloadsFileSaver(activity) + + private fun exportZip() { + lifecycleScope.launch { + val tunnels = Application.getTunnelManager().getTunnels() + try { + exportedFilePath = withContext(Dispatchers.IO) { + val configs = tunnels.map { async(SupervisorJob()) { it.getConfigAsync() } }.awaitAll() + if (configs.isEmpty()) { + throw IllegalArgumentException(context.getString(R.string.no_tunnels_error)) + } + val outputFile = downloadsFileSaver.save("wireguard-export.zip", "application/zip", true) + if (outputFile == null) { + withContext(Dispatchers.Main.immediate) { + isEnabled = true + } + return@withContext null + } + try { + ZipOutputStream(outputFile.outputStream).use { zip -> + for (i in configs.indices) { + zip.putNextEntry(ZipEntry(tunnels[i].name + ".conf")) + zip.write(configs[i].toWgQuickString().toByteArray(StandardCharsets.UTF_8)) + } + zip.closeEntry() + } + } catch (e: Throwable) { + outputFile.delete() + throw e + } + outputFile.fileName + } + notifyChanged() + } catch (e: Throwable) { + val error = ErrorMessages[e] + val message = context.getString(R.string.zip_export_error, error) + Log.e(TAG, message, e) + Snackbar.make( + activity.findViewById(android.R.id.content), + message, Snackbar.LENGTH_LONG + ).show() + isEnabled = true + } + } + } + + override fun getSummary() = + if (exportedFilePath == null) context.getString(R.string.zip_export_summary) else context.getString(R.string.zip_export_success, exportedFilePath) + + override fun getTitle() = context.getString(R.string.zip_export_title) + + override fun onClick() { + if (AdminKnobs.disableConfigExport) return + val fragment = activity.supportFragmentManager.fragments.first() + BiometricAuthenticator.authenticate(R.string.biometric_prompt_zip_exporter_title, fragment) { + when (it) { + // When we have successful authentication, or when there is no biometric hardware available. + is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { + isEnabled = false + exportZip() + } + + is BiometricAuthenticator.Result.Failure -> { + Snackbar.make( + activity.findViewById(android.R.id.content), + it.message, + Snackbar.LENGTH_SHORT + ).show() + } + + is BiometricAuthenticator.Result.Cancelled -> {} + } + } + } + + companion object { + private const val TAG = "WireGuard/ZipExporterPreference" + } +} diff --git a/ui/src/main/java/com/wireguard/android/updater/Ed25519.java b/ui/src/main/java/com/wireguard/android/updater/Ed25519.java new file mode 100644 index 0000000..44e99b8 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/updater/Ed25519.java @@ -0,0 +1,2507 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.updater; + +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Arrays; + +/** + * Implementation of Ed25519 signature verification. + * + *

This implementation is based on the ed25519/ref10 implementation in NaCl.

+ * + *

It implements this twisted Edwards curve: + * + *

+ * -x^2 + y^2 = 1 + (-121665 / 121666 mod 2^255-19)*x^2*y^2
+ * 
+ * + * @see Bernstein D.J., Birkner P., Joye M., Lange + * T., Peters C. (2008) Twisted Edwards Curves + * @see Hisil H., Wong K.KH., Carter G., Dawson E. + * (2008) Twisted Edwards Curves Revisited + */ +final class Ed25519 { + + // d = -121665 / 121666 mod 2^255-19 + private static final long[] D; + // 2d + private static final long[] D2; + // 2^((p-1)/4) mod p where p = 2^255-19 + private static final long[] SQRTM1; + + /** + * Base point for the Edwards twisted curve = (x, 4/5) and its exponentiations. B_TABLE[i][j] = + * (j+1)*256^i*B for i in [0, 32) and j in [0, 8). Base point B = B_TABLE[0][0] + */ + private static final CachedXYT[][] B_TABLE; + private static final CachedXYT[] B2; + + private static final BigInteger P_BI = + BigInteger.valueOf(2).pow(255).subtract(BigInteger.valueOf(19)); + private static final BigInteger D_BI = + BigInteger.valueOf(-121665).multiply(BigInteger.valueOf(121666).modInverse(P_BI)).mod(P_BI); + private static final BigInteger D2_BI = BigInteger.valueOf(2).multiply(D_BI).mod(P_BI); + private static final BigInteger SQRTM1_BI = + BigInteger.valueOf(2).modPow(P_BI.subtract(BigInteger.ONE).divide(BigInteger.valueOf(4)), P_BI); + + private Ed25519() { + } + + private static class Point { + private BigInteger x; + private BigInteger y; + } + + private static BigInteger recoverX(BigInteger y) { + // x^2 = (y^2 - 1) / (d * y^2 + 1) mod 2^255-19 + BigInteger xx = + y.pow(2) + .subtract(BigInteger.ONE) + .multiply(D_BI.multiply(y.pow(2)).add(BigInteger.ONE).modInverse(P_BI)); + BigInteger x = xx.modPow(P_BI.add(BigInteger.valueOf(3)).divide(BigInteger.valueOf(8)), P_BI); + if (!x.pow(2).subtract(xx).mod(P_BI).equals(BigInteger.ZERO)) { + x = x.multiply(SQRTM1_BI).mod(P_BI); + } + if (x.testBit(0)) { + x = P_BI.subtract(x); + } + return x; + } + + private static Point edwards(Point a, Point b) { + Point o = new Point(); + BigInteger xxyy = D_BI.multiply(a.x.multiply(b.x).multiply(a.y).multiply(b.y)).mod(P_BI); + o.x = + (a.x.multiply(b.y).add(b.x.multiply(a.y))) + .multiply(BigInteger.ONE.add(xxyy).modInverse(P_BI)) + .mod(P_BI); + o.y = + (a.y.multiply(b.y).add(a.x.multiply(b.x))) + .multiply(BigInteger.ONE.subtract(xxyy).modInverse(P_BI)) + .mod(P_BI); + return o; + } + + private static byte[] toLittleEndian(BigInteger n) { + byte[] b = new byte[32]; + byte[] nBytes = n.toByteArray(); + System.arraycopy(nBytes, 0, b, 32 - nBytes.length, nBytes.length); + for (int i = 0; i < b.length / 2; i++) { + byte t = b[i]; + b[i] = b[b.length - i - 1]; + b[b.length - i - 1] = t; + } + return b; + } + + private static CachedXYT getCachedXYT(Point p) { + return new CachedXYT( + Field25519.expand(toLittleEndian(p.y.add(p.x).mod(P_BI))), + Field25519.expand(toLittleEndian(p.y.subtract(p.x).mod(P_BI))), + Field25519.expand(toLittleEndian(D2_BI.multiply(p.x).multiply(p.y).mod(P_BI)))); + } + + static { + Point b = new Point(); + b.y = BigInteger.valueOf(4).multiply(BigInteger.valueOf(5).modInverse(P_BI)).mod(P_BI); + b.x = recoverX(b.y); + + D = Field25519.expand(toLittleEndian(D_BI)); + D2 = Field25519.expand(toLittleEndian(D2_BI)); + SQRTM1 = Field25519.expand(toLittleEndian(SQRTM1_BI)); + + Point bi = b; + B_TABLE = new CachedXYT[32][8]; + for (int i = 0; i < 32; i++) { + Point bij = bi; + for (int j = 0; j < 8; j++) { + B_TABLE[i][j] = getCachedXYT(bij); + bij = edwards(bij, bi); + } + for (int j = 0; j < 8; j++) { + bi = edwards(bi, bi); + } + } + bi = b; + Point b2 = edwards(b, b); + B2 = new CachedXYT[8]; + for (int i = 0; i < 8; i++) { + B2[i] = getCachedXYT(bi); + bi = edwards(bi, b2); + } + } + + private static final int PUBLIC_KEY_LEN = Field25519.FIELD_LEN; + private static final int SIGNATURE_LEN = Field25519.FIELD_LEN * 2; + + /** + * Defines field 25519 function based on curve25519-donna C + * implementation (mostly identical). + * + *

Field elements are written as an array of signed, 64-bit limbs (an array of longs), least + * significant first. The value of the field element is: + * + *

+     * x[0] + 2^26·x[1] + 2^51·x[2] + 2^77·x[3] + 2^102·x[4] + 2^128·x[5] + 2^153·x[6] + 2^179·x[7] +
+     * 2^204·x[8] + 2^230·x[9],
+     * 
+ * + *

i.e. the limbs are 26, 25, 26, 25, ... bits wide. + */ + private static final class Field25519 { + /** + * During Field25519 computation, the mixed radix representation may be in different forms: + *

    + *
  • Reduced-size form: the array has size at most 10. + *
  • Non-reduced-size form: the array is not reduced modulo 2^255 - 19 and has size at most + * 19. + *
+ *

+ * TODO(quannguyen): + *

    + *
  • Clarify ill-defined terminologies. + *
  • The reduction procedure is different from DJB's paper + * (http://cr.yp.to/ecdh/curve25519-20060209.pdf). The coefficients after reducing degree and + * reducing coefficients aren't guaranteed to be in range {-2^25, ..., 2^25}. We should check to + * see what's going on. + *
  • Consider using method mult() everywhere and making product() private. + *
+ */ + + static final int FIELD_LEN = 32; + static final int LIMB_CNT = 10; + private static final long TWO_TO_25 = 1 << 25; + private static final long TWO_TO_26 = TWO_TO_25 << 1; + + private static final int[] EXPAND_START = {0, 3, 6, 9, 12, 16, 19, 22, 25, 28}; + private static final int[] EXPAND_SHIFT = {0, 2, 3, 5, 6, 0, 1, 3, 4, 6}; + private static final int[] MASK = {0x3ffffff, 0x1ffffff}; + private static final int[] SHIFT = {26, 25}; + + /** + * Sums two numbers: output = in1 + in2 + *

+ * On entry: in1, in2 are in reduced-size form. + */ + static void sum(long[] output, long[] in1, long[] in2) { + for (int i = 0; i < LIMB_CNT; i++) { + output[i] = in1[i] + in2[i]; + } + } + + /** + * Sums two numbers: output += in + *

+ * On entry: in is in reduced-size form. + */ + static void sum(long[] output, long[] in) { + sum(output, output, in); + } + + /** + * Find the difference of two numbers: output = in1 - in2 + * (note the order of the arguments!). + *

+ * On entry: in1, in2 are in reduced-size form. + */ + static void sub(long[] output, long[] in1, long[] in2) { + for (int i = 0; i < LIMB_CNT; i++) { + output[i] = in1[i] - in2[i]; + } + } + + /** + * Find the difference of two numbers: output = in - output + * (note the order of the arguments!). + *

+ * On entry: in, output are in reduced-size form. + */ + static void sub(long[] output, long[] in) { + sub(output, in, output); + } + + /** + * Multiply a number by a scalar: output = in * scalar + */ + static void scalarProduct(long[] output, long[] in, long scalar) { + for (int i = 0; i < LIMB_CNT; i++) { + output[i] = in[i] * scalar; + } + } + + /** + * Multiply two numbers: out = in2 * in + *

+ * output must be distinct to both inputs. The inputs are reduced coefficient form, + * the output is not. + *

+ * out[x] <= 14 * the largest product of the input limbs. + */ + static void product(long[] out, long[] in2, long[] in) { + out[0] = in2[0] * in[0]; + out[1] = in2[0] * in[1] + + in2[1] * in[0]; + out[2] = 2 * in2[1] * in[1] + + in2[0] * in[2] + + in2[2] * in[0]; + out[3] = in2[1] * in[2] + + in2[2] * in[1] + + in2[0] * in[3] + + in2[3] * in[0]; + out[4] = in2[2] * in[2] + + 2 * (in2[1] * in[3] + in2[3] * in[1]) + + in2[0] * in[4] + + in2[4] * in[0]; + out[5] = in2[2] * in[3] + + in2[3] * in[2] + + in2[1] * in[4] + + in2[4] * in[1] + + in2[0] * in[5] + + in2[5] * in[0]; + out[6] = 2 * (in2[3] * in[3] + in2[1] * in[5] + in2[5] * in[1]) + + in2[2] * in[4] + + in2[4] * in[2] + + in2[0] * in[6] + + in2[6] * in[0]; + out[7] = in2[3] * in[4] + + in2[4] * in[3] + + in2[2] * in[5] + + in2[5] * in[2] + + in2[1] * in[6] + + in2[6] * in[1] + + in2[0] * in[7] + + in2[7] * in[0]; + out[8] = in2[4] * in[4] + + 2 * (in2[3] * in[5] + in2[5] * in[3] + in2[1] * in[7] + in2[7] * in[1]) + + in2[2] * in[6] + + in2[6] * in[2] + + in2[0] * in[8] + + in2[8] * in[0]; + out[9] = in2[4] * in[5] + + in2[5] * in[4] + + in2[3] * in[6] + + in2[6] * in[3] + + in2[2] * in[7] + + in2[7] * in[2] + + in2[1] * in[8] + + in2[8] * in[1] + + in2[0] * in[9] + + in2[9] * in[0]; + out[10] = + 2 * (in2[5] * in[5] + in2[3] * in[7] + in2[7] * in[3] + in2[1] * in[9] + in2[9] * in[1]) + + in2[4] * in[6] + + in2[6] * in[4] + + in2[2] * in[8] + + in2[8] * in[2]; + out[11] = in2[5] * in[6] + + in2[6] * in[5] + + in2[4] * in[7] + + in2[7] * in[4] + + in2[3] * in[8] + + in2[8] * in[3] + + in2[2] * in[9] + + in2[9] * in[2]; + out[12] = in2[6] * in[6] + + 2 * (in2[5] * in[7] + in2[7] * in[5] + in2[3] * in[9] + in2[9] * in[3]) + + in2[4] * in[8] + + in2[8] * in[4]; + out[13] = in2[6] * in[7] + + in2[7] * in[6] + + in2[5] * in[8] + + in2[8] * in[5] + + in2[4] * in[9] + + in2[9] * in[4]; + out[14] = 2 * (in2[7] * in[7] + in2[5] * in[9] + in2[9] * in[5]) + + in2[6] * in[8] + + in2[8] * in[6]; + out[15] = in2[7] * in[8] + + in2[8] * in[7] + + in2[6] * in[9] + + in2[9] * in[6]; + out[16] = in2[8] * in[8] + + 2 * (in2[7] * in[9] + in2[9] * in[7]); + out[17] = in2[8] * in[9] + + in2[9] * in[8]; + out[18] = 2 * in2[9] * in[9]; + } + + /** + * Reduce a field element by calling reduceSizeByModularReduction and reduceCoefficients. + * + * @param input An input array of any length. If the array has 19 elements, it will be used as + * temporary buffer and its contents changed. + * @param output An output array of size LIMB_CNT. After the call |output[i]| < 2^26 will hold. + */ + static void reduce(long[] input, long[] output) { + long[] tmp; + if (input.length == 19) { + tmp = input; + } else { + tmp = new long[19]; + System.arraycopy(input, 0, tmp, 0, input.length); + } + reduceSizeByModularReduction(tmp); + reduceCoefficients(tmp); + System.arraycopy(tmp, 0, output, 0, LIMB_CNT); + } + + /** + * Reduce a long form to a reduced-size form by taking the input mod 2^255 - 19. + *

+ * On entry: |output[i]| < 14*2^54 + * On exit: |output[0..8]| < 280*2^54 + */ + static void reduceSizeByModularReduction(long[] output) { + // The coefficients x[10], x[11],..., x[18] are eliminated by reduction modulo 2^255 - 19. + // For example, the coefficient x[18] is multiplied by 19 and added to the coefficient x[8]. + // + // Each of these shifts and adds ends up multiplying the value by 19. + // + // For output[0..8], the absolute entry value is < 14*2^54 and we add, at most, 19*14*2^54 thus, + // on exit, |output[0..8]| < 280*2^54. + output[8] += output[18] << 4; + output[8] += output[18] << 1; + output[8] += output[18]; + output[7] += output[17] << 4; + output[7] += output[17] << 1; + output[7] += output[17]; + output[6] += output[16] << 4; + output[6] += output[16] << 1; + output[6] += output[16]; + output[5] += output[15] << 4; + output[5] += output[15] << 1; + output[5] += output[15]; + output[4] += output[14] << 4; + output[4] += output[14] << 1; + output[4] += output[14]; + output[3] += output[13] << 4; + output[3] += output[13] << 1; + output[3] += output[13]; + output[2] += output[12] << 4; + output[2] += output[12] << 1; + output[2] += output[12]; + output[1] += output[11] << 4; + output[1] += output[11] << 1; + output[1] += output[11]; + output[0] += output[10] << 4; + output[0] += output[10] << 1; + output[0] += output[10]; + } + + /** + * Reduce all coefficients of the short form input so that |x| < 2^26. + *

+ * On entry: |output[i]| < 280*2^54 + */ + static void reduceCoefficients(long[] output) { + output[10] = 0; + + for (int i = 0; i < LIMB_CNT; i += 2) { + long over = output[i] / TWO_TO_26; + // The entry condition (that |output[i]| < 280*2^54) means that over is, at most, 280*2^28 in + // the first iteration of this loop. This is added to the next limb and we can approximate the + // resulting bound of that limb by 281*2^54. + output[i] -= over << 26; + output[i + 1] += over; + + // For the first iteration, |output[i+1]| < 281*2^54, thus |over| < 281*2^29. When this is + // added to the next limb, the resulting bound can be approximated as 281*2^54. + // + // For subsequent iterations of the loop, 281*2^54 remains a conservative bound and no + // overflow occurs. + over = output[i + 1] / TWO_TO_25; + output[i + 1] -= over << 25; + output[i + 2] += over; + } + // Now |output[10]| < 281*2^29 and all other coefficients are reduced. + output[0] += output[10] << 4; + output[0] += output[10] << 1; + output[0] += output[10]; + + output[10] = 0; + // Now output[1..9] are reduced, and |output[0]| < 2^26 + 19*281*2^29 so |over| will be no more + // than 2^16. + long over = output[0] / TWO_TO_26; + output[0] -= over << 26; + output[1] += over; + // Now output[0,2..9] are reduced, and |output[1]| < 2^25 + 2^16 < 2^26. The bound on + // |output[1]| is sufficient to meet our needs. + } + + /** + * A helpful wrapper around {@ref Field25519#product}: output = in * in2. + *

+ * On entry: |in[i]| < 2^27 and |in2[i]| < 2^27. + *

+ * The output is reduced degree (indeed, one need only provide storage for 10 limbs) and + * |output[i]| < 2^26. + */ + static void mult(long[] output, long[] in, long[] in2) { + long[] t = new long[19]; + product(t, in, in2); + // |t[i]| < 2^26 + reduce(t, output); + } + + /** + * Square a number: out = in**2 + *

+ * output must be distinct from the input. The inputs are reduced coefficient form, the output is + * not. + *

+ * out[x] <= 14 * the largest product of the input limbs. + */ + private static void squareInner(long[] out, long[] in) { + out[0] = in[0] * in[0]; + out[1] = 2 * in[0] * in[1]; + out[2] = 2 * (in[1] * in[1] + in[0] * in[2]); + out[3] = 2 * (in[1] * in[2] + in[0] * in[3]); + out[4] = in[2] * in[2] + + 4 * in[1] * in[3] + + 2 * in[0] * in[4]; + out[5] = 2 * (in[2] * in[3] + in[1] * in[4] + in[0] * in[5]); + out[6] = 2 * (in[3] * in[3] + in[2] * in[4] + in[0] * in[6] + 2 * in[1] * in[5]); + out[7] = 2 * (in[3] * in[4] + in[2] * in[5] + in[1] * in[6] + in[0] * in[7]); + out[8] = in[4] * in[4] + + 2 * (in[2] * in[6] + in[0] * in[8] + 2 * (in[1] * in[7] + in[3] * in[5])); + out[9] = 2 * (in[4] * in[5] + in[3] * in[6] + in[2] * in[7] + in[1] * in[8] + in[0] * in[9]); + out[10] = 2 * (in[5] * in[5] + + in[4] * in[6] + + in[2] * in[8] + + 2 * (in[3] * in[7] + in[1] * in[9])); + out[11] = 2 * (in[5] * in[6] + in[4] * in[7] + in[3] * in[8] + in[2] * in[9]); + out[12] = in[6] * in[6] + + 2 * (in[4] * in[8] + 2 * (in[5] * in[7] + in[3] * in[9])); + out[13] = 2 * (in[6] * in[7] + in[5] * in[8] + in[4] * in[9]); + out[14] = 2 * (in[7] * in[7] + in[6] * in[8] + 2 * in[5] * in[9]); + out[15] = 2 * (in[7] * in[8] + in[6] * in[9]); + out[16] = in[8] * in[8] + 4 * in[7] * in[9]; + out[17] = 2 * in[8] * in[9]; + out[18] = 2 * in[9] * in[9]; + } + + /** + * Returns in^2. + *

+ * On entry: The |in| argument is in reduced coefficients form and |in[i]| < 2^27. + *

+ * On exit: The |output| argument is in reduced coefficients form (indeed, one need only provide + * storage for 10 limbs) and |out[i]| < 2^26. + */ + static void square(long[] output, long[] in) { + long[] t = new long[19]; + squareInner(t, in); + // |t[i]| < 14*2^54 because the largest product of two limbs will be < 2^(27+27) and SquareInner + // adds together, at most, 14 of those products. + reduce(t, output); + } + + /** + * Takes a little-endian, 32-byte number and expands it into mixed radix form. + */ + static long[] expand(byte[] input) { + long[] output = new long[LIMB_CNT]; + for (int i = 0; i < LIMB_CNT; i++) { + output[i] = ((((long) (input[EXPAND_START[i]] & 0xff)) + | ((long) (input[EXPAND_START[i] + 1] & 0xff)) << 8 + | ((long) (input[EXPAND_START[i] + 2] & 0xff)) << 16 + | ((long) (input[EXPAND_START[i] + 3] & 0xff)) << 24) >> EXPAND_SHIFT[i]) & MASK[i & 1]; + } + return output; + } + + /** + * Takes a fully reduced mixed radix form number and contract it into a little-endian, 32-byte + * array. + *

+ * On entry: |input_limbs[i]| < 2^26 + */ + @SuppressWarnings("NarrowingCompoundAssignment") + static byte[] contract(long[] inputLimbs) { + long[] input = Arrays.copyOf(inputLimbs, LIMB_CNT); + for (int j = 0; j < 2; j++) { + for (int i = 0; i < 9; i++) { + // This calculation is a time-invariant way to make input[i] non-negative by borrowing + // from the next-larger limb. + int carry = -(int) ((input[i] & (input[i] >> 31)) >> SHIFT[i & 1]); + input[i] = input[i] + (carry << SHIFT[i & 1]); + input[i + 1] -= carry; + } + + // There's no greater limb for input[9] to borrow from, but we can multiply by 19 and borrow + // from input[0], which is valid mod 2^255-19. + { + int carry = -(int) ((input[9] & (input[9] >> 31)) >> 25); + input[9] += (carry << 25); + input[0] -= (carry * 19); + } + + // After the first iteration, input[1..9] are non-negative and fit within 25 or 26 bits, + // depending on position. However, input[0] may be negative. + } + + // The first borrow-propagation pass above ended with every limb except (possibly) input[0] + // non-negative. + // + // If input[0] was negative after the first pass, then it was because of a carry from input[9]. + // On entry, input[9] < 2^26 so the carry was, at most, one, since (2**26-1) >> 25 = 1. Thus + // input[0] >= -19. + // + // In the second pass, each limb is decreased by at most one. Thus the second borrow-propagation + // pass could only have wrapped around to decrease input[0] again if the first pass left + // input[0] negative *and* input[1] through input[9] were all zero. In that case, input[1] is + // now 2^25 - 1, and this last borrow-propagation step will leave input[1] non-negative. + { + int carry = -(int) ((input[0] & (input[0] >> 31)) >> 26); + input[0] += (carry << 26); + input[1] -= carry; + } + + // All input[i] are now non-negative. However, there might be values between 2^25 and 2^26 in a + // limb which is, nominally, 25 bits wide. + for (int j = 0; j < 2; j++) { + for (int i = 0; i < 9; i++) { + int carry = (int) (input[i] >> SHIFT[i & 1]); + input[i] &= MASK[i & 1]; + input[i + 1] += carry; + } + } + + { + int carry = (int) (input[9] >> 25); + input[9] &= 0x1ffffff; + input[0] += 19 * carry; + } + + // If the first carry-chain pass, just above, ended up with a carry from input[9], and that + // caused input[0] to be out-of-bounds, then input[0] was < 2^26 + 2*19, because the carry was, + // at most, two. + // + // If the second pass carried from input[9] again then input[0] is < 2*19 and the input[9] -> + // input[0] carry didn't push input[0] out of bounds. + + // It still remains the case that input might be between 2^255-19 and 2^255. In this case, + // input[1..9] must take their maximum value and input[0] must be >= (2^255-19) & 0x3ffffff, + // which is 0x3ffffed. + int mask = gte((int) input[0], 0x3ffffed); + for (int i = 1; i < LIMB_CNT; i++) { + mask &= eq((int) input[i], MASK[i & 1]); + } + + // mask is either 0xffffffff (if input >= 2^255-19) and zero otherwise. Thus this conditionally + // subtracts 2^255-19. + input[0] -= mask & 0x3ffffed; + input[1] -= mask & 0x1ffffff; + for (int i = 2; i < LIMB_CNT; i += 2) { + input[i] -= mask & 0x3ffffff; + input[i + 1] -= mask & 0x1ffffff; + } + + for (int i = 0; i < LIMB_CNT; i++) { + input[i] <<= EXPAND_SHIFT[i]; + } + byte[] output = new byte[FIELD_LEN]; + for (int i = 0; i < LIMB_CNT; i++) { + output[EXPAND_START[i]] |= input[i] & 0xff; + output[EXPAND_START[i] + 1] |= (input[i] >> 8) & 0xff; + output[EXPAND_START[i] + 2] |= (input[i] >> 16) & 0xff; + output[EXPAND_START[i] + 3] |= (input[i] >> 24) & 0xff; + } + return output; + } + + /** + * Computes inverse of z = z(2^255 - 21) + *

+ * Shamelessly copied from agl's code which was shamelessly copied from djb's code. Only the + * comment format and the variable namings are different from those. + */ + static void inverse(long[] out, long[] z) { + long[] z2 = new long[Field25519.LIMB_CNT]; + long[] z9 = new long[Field25519.LIMB_CNT]; + long[] z11 = new long[Field25519.LIMB_CNT]; + long[] z2To5Minus1 = new long[Field25519.LIMB_CNT]; + long[] z2To10Minus1 = new long[Field25519.LIMB_CNT]; + long[] z2To20Minus1 = new long[Field25519.LIMB_CNT]; + long[] z2To50Minus1 = new long[Field25519.LIMB_CNT]; + long[] z2To100Minus1 = new long[Field25519.LIMB_CNT]; + long[] t0 = new long[Field25519.LIMB_CNT]; + long[] t1 = new long[Field25519.LIMB_CNT]; + + square(z2, z); // 2 + square(t1, z2); // 4 + square(t0, t1); // 8 + mult(z9, t0, z); // 9 + mult(z11, z9, z2); // 11 + square(t0, z11); // 22 + mult(z2To5Minus1, t0, z9); // 2^5 - 2^0 = 31 + + square(t0, z2To5Minus1); // 2^6 - 2^1 + square(t1, t0); // 2^7 - 2^2 + square(t0, t1); // 2^8 - 2^3 + square(t1, t0); // 2^9 - 2^4 + square(t0, t1); // 2^10 - 2^5 + mult(z2To10Minus1, t0, z2To5Minus1); // 2^10 - 2^0 + + square(t0, z2To10Minus1); // 2^11 - 2^1 + square(t1, t0); // 2^12 - 2^2 + for (int i = 2; i < 10; i += 2) { // 2^20 - 2^10 + square(t0, t1); + square(t1, t0); + } + mult(z2To20Minus1, t1, z2To10Minus1); // 2^20 - 2^0 + + square(t0, z2To20Minus1); // 2^21 - 2^1 + square(t1, t0); // 2^22 - 2^2 + for (int i = 2; i < 20; i += 2) { // 2^40 - 2^20 + square(t0, t1); + square(t1, t0); + } + mult(t0, t1, z2To20Minus1); // 2^40 - 2^0 + + square(t1, t0); // 2^41 - 2^1 + square(t0, t1); // 2^42 - 2^2 + for (int i = 2; i < 10; i += 2) { // 2^50 - 2^10 + square(t1, t0); + square(t0, t1); + } + mult(z2To50Minus1, t0, z2To10Minus1); // 2^50 - 2^0 + + square(t0, z2To50Minus1); // 2^51 - 2^1 + square(t1, t0); // 2^52 - 2^2 + for (int i = 2; i < 50; i += 2) { // 2^100 - 2^50 + square(t0, t1); + square(t1, t0); + } + mult(z2To100Minus1, t1, z2To50Minus1); // 2^100 - 2^0 + + square(t1, z2To100Minus1); // 2^101 - 2^1 + square(t0, t1); // 2^102 - 2^2 + for (int i = 2; i < 100; i += 2) { // 2^200 - 2^100 + square(t1, t0); + square(t0, t1); + } + mult(t1, t0, z2To100Minus1); // 2^200 - 2^0 + + square(t0, t1); // 2^201 - 2^1 + square(t1, t0); // 2^202 - 2^2 + for (int i = 2; i < 50; i += 2) { // 2^250 - 2^50 + square(t0, t1); + square(t1, t0); + } + mult(t0, t1, z2To50Minus1); // 2^250 - 2^0 + + square(t1, t0); // 2^251 - 2^1 + square(t0, t1); // 2^252 - 2^2 + square(t1, t0); // 2^253 - 2^3 + square(t0, t1); // 2^254 - 2^4 + square(t1, t0); // 2^255 - 2^5 + mult(out, t1, z11); // 2^255 - 21 + } + + + /** + * Returns 0xffffffff iff a == b and zero otherwise. + */ + private static int eq(int a, int b) { + a = ~(a ^ b); + a &= a << 16; + a &= a << 8; + a &= a << 4; + a &= a << 2; + a &= a << 1; + return a >> 31; + } + + /** + * returns 0xffffffff if a >= b and zero otherwise, where a and b are both non-negative. + */ + private static int gte(int a, int b) { + a -= b; + // a >= 0 iff a >= b. + return ~(a >> 31); + } + } + + // (x = 0, y = 1) point + private static final CachedXYT CACHED_NEUTRAL = new CachedXYT( + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new long[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + private static final PartialXYZT NEUTRAL = new PartialXYZT( + new XYZ(new long[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}), + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + + /** + * Projective point representation (X:Y:Z) satisfying x = X/Z, y = Y/Z + *

+ * Note that this is referred as ge_p2 in ref10 impl. + * Also note that x = X, y = Y and z = Z below following Java coding style. + *

+ * See + * Koyama K., Tsuruoka Y. (1993) Speeding up Elliptic Cryptosystems by Using a Signed Binary + * Window Method. + *

+ * https://hyperelliptic.org/EFD/g1p/auto-twisted-projective.html + */ + private static class XYZ { + + final long[] x; + final long[] y; + final long[] z; + + XYZ() { + this(new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT]); + } + + XYZ(long[] x, long[] y, long[] z) { + this.x = x; + this.y = y; + this.z = z; + } + + XYZ(XYZ xyz) { + x = Arrays.copyOf(xyz.x, Field25519.LIMB_CNT); + y = Arrays.copyOf(xyz.y, Field25519.LIMB_CNT); + z = Arrays.copyOf(xyz.z, Field25519.LIMB_CNT); + } + + XYZ(PartialXYZT partialXYZT) { + this(); + fromPartialXYZT(this, partialXYZT); + } + + /** + * ge_p1p1_to_p2.c + */ + static XYZ fromPartialXYZT(XYZ out, PartialXYZT in) { + Field25519.mult(out.x, in.xyz.x, in.t); + Field25519.mult(out.y, in.xyz.y, in.xyz.z); + Field25519.mult(out.z, in.xyz.z, in.t); + return out; + } + + /** + * Encodes this point to bytes. + */ + byte[] toBytes() { + long[] recip = new long[Field25519.LIMB_CNT]; + long[] x = new long[Field25519.LIMB_CNT]; + long[] y = new long[Field25519.LIMB_CNT]; + Field25519.inverse(recip, z); + Field25519.mult(x, this.x, recip); + Field25519.mult(y, this.y, recip); + byte[] s = Field25519.contract(y); + s[31] = (byte) (s[31] ^ (getLsb(x) << 7)); + return s; + } + + + /** + * Best effort fix-timing array comparison. + * + * @return true if two arrays are equal. + */ + private static boolean bytesEqual(final byte[] x, final byte[] y) { + if (x == null || y == null) { + return false; + } + if (x.length != y.length) { + return false; + } + int res = 0; + for (int i = 0; i < x.length; i++) { + res |= x[i] ^ y[i]; + } + return res == 0; + } + + /** + * Checks that the point is on curve + */ + boolean isOnCurve() { + long[] x2 = new long[Field25519.LIMB_CNT]; + Field25519.square(x2, x); + long[] y2 = new long[Field25519.LIMB_CNT]; + Field25519.square(y2, y); + long[] z2 = new long[Field25519.LIMB_CNT]; + Field25519.square(z2, z); + long[] z4 = new long[Field25519.LIMB_CNT]; + Field25519.square(z4, z2); + long[] lhs = new long[Field25519.LIMB_CNT]; + // lhs = y^2 - x^2 + Field25519.sub(lhs, y2, x2); + // lhs = z^2 * (y2 - x2) + Field25519.mult(lhs, lhs, z2); + long[] rhs = new long[Field25519.LIMB_CNT]; + // rhs = x^2 * y^2 + Field25519.mult(rhs, x2, y2); + // rhs = D * x^2 * y^2 + Field25519.mult(rhs, rhs, D); + // rhs = z^4 + D * x^2 * y^2 + Field25519.sum(rhs, z4); + // Field25519.mult reduces its output, but Field25519.sum does not, so we have to manually + // reduce it here. + Field25519.reduce(rhs, rhs); + // z^2 (y^2 - x^2) == z^4 + D * x^2 * y^2 + return bytesEqual(Field25519.contract(lhs), Field25519.contract(rhs)); + } + } + + /** + * Represents extended projective point representation (X:Y:Z:T) satisfying x = X/Z, y = Y/Z, + * XY = ZT + *

+ * Note that this is referred as ge_p3 in ref10 impl. + * Also note that t = T below following Java coding style. + *

+ * See + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + *

+ * https://hyperelliptic.org/EFD/g1p/auto-twisted-extended.html + */ + private static class XYZT { + + final XYZ xyz; + final long[] t; + + XYZT() { + this(new XYZ(), new long[Field25519.LIMB_CNT]); + } + + XYZT(XYZ xyz, long[] t) { + this.xyz = xyz; + this.t = t; + } + + XYZT(PartialXYZT partialXYZT) { + this(); + fromPartialXYZT(this, partialXYZT); + } + + /** + * ge_p1p1_to_p2.c + */ + private static XYZT fromPartialXYZT(XYZT out, PartialXYZT in) { + Field25519.mult(out.xyz.x, in.xyz.x, in.t); + Field25519.mult(out.xyz.y, in.xyz.y, in.xyz.z); + Field25519.mult(out.xyz.z, in.xyz.z, in.t); + Field25519.mult(out.t, in.xyz.x, in.xyz.y); + return out; + } + + /** + * Decodes {@code s} into an extented projective point. + * See Section 5.1.3 Decoding in https://tools.ietf.org/html/rfc8032#section-5.1.3 + */ + private static XYZT fromBytesNegateVarTime(byte[] s) throws GeneralSecurityException { + long[] x = new long[Field25519.LIMB_CNT]; + long[] y = Field25519.expand(s); + long[] z = new long[Field25519.LIMB_CNT]; + z[0] = 1; + long[] t = new long[Field25519.LIMB_CNT]; + long[] u = new long[Field25519.LIMB_CNT]; + long[] v = new long[Field25519.LIMB_CNT]; + long[] vxx = new long[Field25519.LIMB_CNT]; + long[] check = new long[Field25519.LIMB_CNT]; + Field25519.square(u, y); + Field25519.mult(v, u, D); + Field25519.sub(u, u, z); // u = y^2 - 1 + Field25519.sum(v, v, z); // v = dy^2 + 1 + + long[] v3 = new long[Field25519.LIMB_CNT]; + Field25519.square(v3, v); + Field25519.mult(v3, v3, v); // v3 = v^3 + Field25519.square(x, v3); + Field25519.mult(x, x, v); + Field25519.mult(x, x, u); // x = uv^7 + + pow2252m3(x, x); // x = (uv^7)^((q-5)/8) + Field25519.mult(x, x, v3); + Field25519.mult(x, x, u); // x = uv^3(uv^7)^((q-5)/8) + + Field25519.square(vxx, x); + Field25519.mult(vxx, vxx, v); + Field25519.sub(check, vxx, u); // vx^2-u + if (isNonZeroVarTime(check)) { + Field25519.sum(check, vxx, u); // vx^2+u + if (isNonZeroVarTime(check)) { + throw new GeneralSecurityException("Cannot convert given bytes to extended projective " + + "coordinates. No square root exists for modulo 2^255-19"); + } + Field25519.mult(x, x, SQRTM1); + } + + if (!isNonZeroVarTime(x) && (s[31] & 0xff) >> 7 != 0) { + throw new GeneralSecurityException("Cannot convert given bytes to extended projective " + + "coordinates. Computed x is zero and encoded x's least significant bit is not zero"); + } + if (getLsb(x) == ((s[31] & 0xff) >> 7)) { + neg(x, x); + } + + Field25519.mult(t, x, y); + return new XYZT(new XYZ(x, y, z), t); + } + } + + /** + * Partial projective point representation ((X:Z),(Y:T)) satisfying x=X/Z, y=Y/T + *

+ * Note that this is referred as complete form in the original ref10 impl (ge_p1p1). + * Also note that t = T below following Java coding style. + *

+ * Although this has the same types as XYZT, it is redefined to have its own type so that it is + * readable and 1:1 corresponds to ref10 impl. + *

+ * Can be converted to XYZT as follows: + * X1 = X * T = x * Z * T = x * Z1 + * Y1 = Y * Z = y * T * Z = y * Z1 + * Z1 = Z * T = Z * T + * T1 = X * Y = x * Z * y * T = x * y * Z1 = X1Y1 / Z1 + */ + private static class PartialXYZT { + + final XYZ xyz; + final long[] t; + + PartialXYZT() { + this(new XYZ(), new long[Field25519.LIMB_CNT]); + } + + PartialXYZT(XYZ xyz, long[] t) { + this.xyz = xyz; + this.t = t; + } + + PartialXYZT(PartialXYZT other) { + xyz = new XYZ(other.xyz); + t = Arrays.copyOf(other.t, Field25519.LIMB_CNT); + } + } + + /** + * Corresponds to the caching mentioned in the last paragraph of Section 3.1 of + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + * with Z = 1. + */ + private static class CachedXYT { + + final long[] yPlusX; + final long[] yMinusX; + final long[] t2d; + + /** + * Creates a cached XYZT with Z = 1 + * + * @param yPlusX y + x + * @param yMinusX y - x + * @param t2d 2d * xy + */ + CachedXYT(long[] yPlusX, long[] yMinusX, long[] t2d) { + this.yPlusX = yPlusX; + this.yMinusX = yMinusX; + this.t2d = t2d; + } + + CachedXYT(CachedXYT other) { + yPlusX = Arrays.copyOf(other.yPlusX, Field25519.LIMB_CNT); + yMinusX = Arrays.copyOf(other.yMinusX, Field25519.LIMB_CNT); + t2d = Arrays.copyOf(other.t2d, Field25519.LIMB_CNT); + } + + // z is one implicitly, so this just copies {@code in} to {@code output}. + void multByZ(long[] output, long[] in) { + System.arraycopy(in, 0, output, 0, Field25519.LIMB_CNT); + } + + /** + * If icopy is 1, copies {@code other} into this point. Time invariant wrt to icopy value. + */ + void copyConditional(CachedXYT other, int icopy) { + copyConditional(yPlusX, other.yPlusX, icopy); + copyConditional(yMinusX, other.yMinusX, icopy); + copyConditional(t2d, other.t2d, icopy); + } + + /** + * Conditionally copies a reduced-form limb arrays {@code b} into {@code a} if {@code icopy} is 1, + * but leave {@code a} unchanged if 'iswap' is 0. Runs in data-invariant time to avoid + * side-channel attacks. + * + *

NOTE that this function requires that {@code icopy} be 1 or 0; other values give wrong + * results. Also, the two limb arrays must be in reduced-coefficient, reduced-degree form: the + * values in a[10..19] or b[10..19] aren't swapped, and all all values in a[0..9],b[0..9] must + * have magnitude less than Integer.MAX_VALUE. + */ + static void copyConditional(long[] a, long[] b, int icopy) { + int copy = -icopy; + for (int i = 0; i < Field25519.LIMB_CNT; i++) { + int x = copy & (((int) a[i]) ^ ((int) b[i])); + a[i] = ((int) a[i]) ^ x; + } + } + } + + private static class CachedXYZT extends CachedXYT { + + private final long[] z; + + CachedXYZT() { + this(new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT]); + } + + /** + * ge_p3_to_cached.c + */ + CachedXYZT(XYZT xyzt) { + this(); + Field25519.sum(yPlusX, xyzt.xyz.y, xyzt.xyz.x); + Field25519.sub(yMinusX, xyzt.xyz.y, xyzt.xyz.x); + System.arraycopy(xyzt.xyz.z, 0, z, 0, Field25519.LIMB_CNT); + Field25519.mult(t2d, xyzt.t, D2); + } + + /** + * Creates a cached XYZT + * + * @param yPlusX Y + X + * @param yMinusX Y - X + * @param z Z + * @param t2d 2d * (XY/Z) + */ + CachedXYZT(long[] yPlusX, long[] yMinusX, long[] z, long[] t2d) { + super(yPlusX, yMinusX, t2d); + this.z = z; + } + + @Override + public void multByZ(long[] output, long[] in) { + Field25519.mult(output, in, z); + } + } + + /** + * Addition defined in Section 3.1 of + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + *

+ * Please note that this is a partial of the operation listed there leaving out the final + * conversion from PartialXYZT to XYZT. + * + * @param extended extended projective point input + * @param cached cached projective point input + */ + private static void add(PartialXYZT partialXYZT, XYZT extended, CachedXYT cached) { + long[] t = new long[Field25519.LIMB_CNT]; + + // Y1 + X1 + Field25519.sum(partialXYZT.xyz.x, extended.xyz.y, extended.xyz.x); + + // Y1 - X1 + Field25519.sub(partialXYZT.xyz.y, extended.xyz.y, extended.xyz.x); + + // A = (Y1 - X1) * (Y2 - X2) + Field25519.mult(partialXYZT.xyz.y, partialXYZT.xyz.y, cached.yMinusX); + + // B = (Y1 + X1) * (Y2 + X2) + Field25519.mult(partialXYZT.xyz.z, partialXYZT.xyz.x, cached.yPlusX); + + // C = T1 * 2d * T2 = 2d * T1 * T2 (2d is written as k in the paper) + Field25519.mult(partialXYZT.t, extended.t, cached.t2d); + + // Z1 * Z2 + cached.multByZ(partialXYZT.xyz.x, extended.xyz.z); + + // D = 2 * Z1 * Z2 + Field25519.sum(t, partialXYZT.xyz.x, partialXYZT.xyz.x); + + // X3 = B - A + Field25519.sub(partialXYZT.xyz.x, partialXYZT.xyz.z, partialXYZT.xyz.y); + + // Y3 = B + A + Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.y); + + // Z3 = D + C + Field25519.sum(partialXYZT.xyz.z, t, partialXYZT.t); + + // T3 = D - C + Field25519.sub(partialXYZT.t, t, partialXYZT.t); + } + + /** + * Based on the addition defined in Section 3.1 of + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + *

+ * Please note that this is a partial of the operation listed there leaving out the final + * conversion from PartialXYZT to XYZT. + * + * @param extended extended projective point input + * @param cached cached projective point input + */ + private static void sub(PartialXYZT partialXYZT, XYZT extended, CachedXYT cached) { + long[] t = new long[Field25519.LIMB_CNT]; + + // Y1 + X1 + Field25519.sum(partialXYZT.xyz.x, extended.xyz.y, extended.xyz.x); + + // Y1 - X1 + Field25519.sub(partialXYZT.xyz.y, extended.xyz.y, extended.xyz.x); + + // A = (Y1 - X1) * (Y2 + X2) + Field25519.mult(partialXYZT.xyz.y, partialXYZT.xyz.y, cached.yPlusX); + + // B = (Y1 + X1) * (Y2 - X2) + Field25519.mult(partialXYZT.xyz.z, partialXYZT.xyz.x, cached.yMinusX); + + // C = T1 * 2d * T2 = 2d * T1 * T2 (2d is written as k in the paper) + Field25519.mult(partialXYZT.t, extended.t, cached.t2d); + + // Z1 * Z2 + cached.multByZ(partialXYZT.xyz.x, extended.xyz.z); + + // D = 2 * Z1 * Z2 + Field25519.sum(t, partialXYZT.xyz.x, partialXYZT.xyz.x); + + // X3 = B - A + Field25519.sub(partialXYZT.xyz.x, partialXYZT.xyz.z, partialXYZT.xyz.y); + + // Y3 = B + A + Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.y); + + // Z3 = D - C + Field25519.sub(partialXYZT.xyz.z, t, partialXYZT.t); + + // T3 = D + C + Field25519.sum(partialXYZT.t, t, partialXYZT.t); + } + + /** + * Doubles {@code p} and puts the result into this PartialXYZT. + *

+ * This is based on the addition defined in formula 7 in Section 3.3 of + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + *

+ * Please note that this is a partial of the operation listed there leaving out the final + * conversion from PartialXYZT to XYZT and also this fixes a typo in calculation of Y3 and T3 in + * the paper, H should be replaced with A+B. + */ + private static void doubleXYZ(PartialXYZT partialXYZT, XYZ p) { + long[] t0 = new long[Field25519.LIMB_CNT]; + + // XX = X1^2 + Field25519.square(partialXYZT.xyz.x, p.x); + + // YY = Y1^2 + Field25519.square(partialXYZT.xyz.z, p.y); + + // B' = Z1^2 + Field25519.square(partialXYZT.t, p.z); + + // B = 2 * B' + Field25519.sum(partialXYZT.t, partialXYZT.t, partialXYZT.t); + + // A = X1 + Y1 + Field25519.sum(partialXYZT.xyz.y, p.x, p.y); + + // AA = A^2 + Field25519.square(t0, partialXYZT.xyz.y); + + // Y3 = YY + XX + Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.x); + + // Z3 = YY - XX + Field25519.sub(partialXYZT.xyz.z, partialXYZT.xyz.z, partialXYZT.xyz.x); + + // X3 = AA - Y3 + Field25519.sub(partialXYZT.xyz.x, t0, partialXYZT.xyz.y); + + // T3 = B - Z3 + Field25519.sub(partialXYZT.t, partialXYZT.t, partialXYZT.xyz.z); + } + + /** + * Doubles {@code p} and puts the result into this PartialXYZT. + */ + private static void doubleXYZT(PartialXYZT partialXYZT, XYZT p) { + doubleXYZ(partialXYZT, p.xyz); + } + + /** + * Compares two byte values in constant time. + */ + private static int eq(int a, int b) { + int r = ~(a ^ b) & 0xff; + r &= r << 4; + r &= r << 2; + r &= r << 1; + return (r >> 7) & 1; + } + + /** + * This is a constant time operation where point b*B*256^pos is stored in {@code t}. + * When b is 0, t remains the same (i.e., neutral point). + *

+ * Although B_TABLE[32][8] (B_TABLE[i][j] = (j+1)*B*256^i) has j values in [0, 7], the select + * method negates the corresponding point if b is negative (which is straight forward in elliptic + * curves by just negating y coordinate). Therefore we can get multiples of B with the half of + * memory requirements. + * + * @param t neutral element (i.e., point 0), also serves as output. + * @param pos in B[pos][j] = (j+1)*B*256^pos + * @param b value in [-8, 8] range. + */ + private static void select(CachedXYT t, int pos, byte b) { + int bnegative = (b & 0xff) >> 7; + int babs = b - (((-bnegative) & b) << 1); + + t.copyConditional(B_TABLE[pos][0], eq(babs, 1)); + t.copyConditional(B_TABLE[pos][1], eq(babs, 2)); + t.copyConditional(B_TABLE[pos][2], eq(babs, 3)); + t.copyConditional(B_TABLE[pos][3], eq(babs, 4)); + t.copyConditional(B_TABLE[pos][4], eq(babs, 5)); + t.copyConditional(B_TABLE[pos][5], eq(babs, 6)); + t.copyConditional(B_TABLE[pos][6], eq(babs, 7)); + t.copyConditional(B_TABLE[pos][7], eq(babs, 8)); + + long[] yPlusX = Arrays.copyOf(t.yMinusX, Field25519.LIMB_CNT); + long[] yMinusX = Arrays.copyOf(t.yPlusX, Field25519.LIMB_CNT); + long[] t2d = Arrays.copyOf(t.t2d, Field25519.LIMB_CNT); + neg(t2d, t2d); + CachedXYT minust = new CachedXYT(yPlusX, yMinusX, t2d); + t.copyConditional(minust, bnegative); + } + + /** + * Computes {@code a}*B + * where a = a[0]+256*a[1]+...+256^31 a[31] and + * B is the Ed25519 base point (x,4/5) with x positive. + *

+ * Preconditions: + * a[31] <= 127 + * + * @throws IllegalStateException iff there is arithmetic error. + */ + @SuppressWarnings("NarrowingCompoundAssignment") + private static XYZ scalarMultWithBase(byte[] a) { + byte[] e = new byte[2 * Field25519.FIELD_LEN]; + for (int i = 0; i < Field25519.FIELD_LEN; i++) { + e[2 * i + 0] = (byte) (((a[i] & 0xff) >> 0) & 0xf); + e[2 * i + 1] = (byte) (((a[i] & 0xff) >> 4) & 0xf); + } + // each e[i] is between 0 and 15 + // e[63] is between 0 and 7 + + // Rewrite e in a way that each e[i] is in [-8, 8]. + // This can be done since a[63] is in [0, 7], the carry-over onto the most significant byte + // a[63] can be at most 1. + int carry = 0; + for (int i = 0; i < e.length - 1; i++) { + e[i] += carry; + carry = e[i] + 8; + carry >>= 4; + e[i] -= carry << 4; + } + e[e.length - 1] += carry; + + PartialXYZT ret = new PartialXYZT(NEUTRAL); + XYZT xyzt = new XYZT(); + // Although B_TABLE's i can be at most 31 (stores only 32 4bit multiples of B) and we have 64 + // 4bit values in e array, the below for loop adds cached values by iterating e by two in odd + // indices. After the result, we can double the result point 4 times to shift the multiplication + // scalar by 4 bits. + for (int i = 1; i < e.length; i += 2) { + CachedXYT t = new CachedXYT(CACHED_NEUTRAL); + select(t, i / 2, e[i]); + add(ret, XYZT.fromPartialXYZT(xyzt, ret), t); + } + + // Doubles the result 4 times to shift the multiplication scalar 4 bits to get the actual result + // for the odd indices in e. + XYZ xyz = new XYZ(); + doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret)); + doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret)); + doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret)); + doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret)); + + // Add multiples of B for even indices of e. + for (int i = 0; i < e.length; i += 2) { + CachedXYT t = new CachedXYT(CACHED_NEUTRAL); + select(t, i / 2, e[i]); + add(ret, XYZT.fromPartialXYZT(xyzt, ret), t); + } + + // This check is to protect against flaws, i.e. if there is a computation error through a + // faulty CPU or if the implementation contains a bug. + XYZ result = new XYZ(ret); + if (!result.isOnCurve()) { + throw new IllegalStateException("arithmetic error in scalar multiplication"); + } + return result; + } + + @SuppressWarnings("NarrowingCompoundAssignment") + private static byte[] slide(byte[] a) { + byte[] r = new byte[256]; + // Writes each bit in a[0..31] into r[0..255]: + // a = a[0]+256*a[1]+...+256^31*a[31] is equal to + // r = r[0]+2*r[1]+...+2^255*r[255] + for (int i = 0; i < 256; i++) { + r[i] = (byte) (1 & ((a[i >> 3] & 0xff) >> (i & 7))); + } + + // Transforms r[i] as odd values in [-15, 15] + for (int i = 0; i < 256; i++) { + if (r[i] != 0) { + for (int b = 1; b <= 6 && i + b < 256; b++) { + if (r[i + b] != 0) { + if (r[i] + (r[i + b] << b) <= 15) { + r[i] += r[i + b] << b; + r[i + b] = 0; + } else if (r[i] - (r[i + b] << b) >= -15) { + r[i] -= r[i + b] << b; + for (int k = i + b; k < 256; k++) { + if (r[k] == 0) { + r[k] = 1; + break; + } + r[k] = 0; + } + } else { + break; + } + } + } + } + } + return r; + } + + /** + * Computes {@code a}*{@code pointA}+{@code b}*B + * where a = a[0]+256*a[1]+...+256^31*a[31]. + * and b = b[0]+256*b[1]+...+256^31*b[31]. + * B is the Ed25519 base point (x,4/5) with x positive. + *

+ * Note that execution time varies based on the input since this will only be used in verification + * of signatures. + */ + private static XYZ doubleScalarMultVarTime(byte[] a, XYZT pointA, byte[] b) { + // pointA, 3*pointA, 5*pointA, 7*pointA, 9*pointA, 11*pointA, 13*pointA, 15*pointA + CachedXYZT[] pointAArray = new CachedXYZT[8]; + pointAArray[0] = new CachedXYZT(pointA); + PartialXYZT t = new PartialXYZT(); + doubleXYZT(t, pointA); + XYZT doubleA = new XYZT(t); + for (int i = 1; i < pointAArray.length; i++) { + add(t, doubleA, pointAArray[i - 1]); + pointAArray[i] = new CachedXYZT(new XYZT(t)); + } + + byte[] aSlide = slide(a); + byte[] bSlide = slide(b); + t = new PartialXYZT(NEUTRAL); + XYZT u = new XYZT(); + int i = 255; + for (; i >= 0; i--) { + if (aSlide[i] != 0 || bSlide[i] != 0) { + break; + } + } + for (; i >= 0; i--) { + doubleXYZ(t, new XYZ(t)); + if (aSlide[i] > 0) { + add(t, XYZT.fromPartialXYZT(u, t), pointAArray[aSlide[i] / 2]); + } else if (aSlide[i] < 0) { + sub(t, XYZT.fromPartialXYZT(u, t), pointAArray[-aSlide[i] / 2]); + } + if (bSlide[i] > 0) { + add(t, XYZT.fromPartialXYZT(u, t), B2[bSlide[i] / 2]); + } else if (bSlide[i] < 0) { + sub(t, XYZT.fromPartialXYZT(u, t), B2[-bSlide[i] / 2]); + } + } + + return new XYZ(t); + } + + /** + * Returns true if {@code in} is nonzero. + *

+ * Note that execution time might depend on the input {@code in}. + */ + private static boolean isNonZeroVarTime(long[] in) { + long[] inCopy = new long[in.length + 1]; + System.arraycopy(in, 0, inCopy, 0, in.length); + Field25519.reduceCoefficients(inCopy); + byte[] bytes = Field25519.contract(inCopy); + for (byte b : bytes) { + if (b != 0) { + return true; + } + } + return false; + } + + /** + * Returns the least significant bit of {@code in}. + */ + private static int getLsb(long[] in) { + return Field25519.contract(in)[0] & 1; + } + + /** + * Negates all values in {@code in} and store it in {@code out}. + */ + private static void neg(long[] out, long[] in) { + for (int i = 0; i < in.length; i++) { + out[i] = -in[i]; + } + } + + /** + * Computes {@code in}^(2^252-3) mod 2^255-19 and puts the result in {@code out}. + */ + private static void pow2252m3(long[] out, long[] in) { + long[] t0 = new long[Field25519.LIMB_CNT]; + long[] t1 = new long[Field25519.LIMB_CNT]; + long[] t2 = new long[Field25519.LIMB_CNT]; + + // z2 = z1^2^1 + Field25519.square(t0, in); + + // z8 = z2^2^2 + Field25519.square(t1, t0); + for (int i = 1; i < 2; i++) { + Field25519.square(t1, t1); + } + + // z9 = z1*z8 + Field25519.mult(t1, in, t1); + + // z11 = z2*z9 + Field25519.mult(t0, t0, t1); + + // z22 = z11^2^1 + Field25519.square(t0, t0); + + // z_5_0 = z9*z22 + Field25519.mult(t0, t1, t0); + + // z_10_5 = z_5_0^2^5 + Field25519.square(t1, t0); + for (int i = 1; i < 5; i++) { + Field25519.square(t1, t1); + } + + // z_10_0 = z_10_5*z_5_0 + Field25519.mult(t0, t1, t0); + + // z_20_10 = z_10_0^2^10 + Field25519.square(t1, t0); + for (int i = 1; i < 10; i++) { + Field25519.square(t1, t1); + } + + // z_20_0 = z_20_10*z_10_0 + Field25519.mult(t1, t1, t0); + + // z_40_20 = z_20_0^2^20 + Field25519.square(t2, t1); + for (int i = 1; i < 20; i++) { + Field25519.square(t2, t2); + } + + // z_40_0 = z_40_20*z_20_0 + Field25519.mult(t1, t2, t1); + + // z_50_10 = z_40_0^2^10 + Field25519.square(t1, t1); + for (int i = 1; i < 10; i++) { + Field25519.square(t1, t1); + } + + // z_50_0 = z_50_10*z_10_0 + Field25519.mult(t0, t1, t0); + + // z_100_50 = z_50_0^2^50 + Field25519.square(t1, t0); + for (int i = 1; i < 50; i++) { + Field25519.square(t1, t1); + } + + // z_100_0 = z_100_50*z_50_0 + Field25519.mult(t1, t1, t0); + + // z_200_100 = z_100_0^2^100 + Field25519.square(t2, t1); + for (int i = 1; i < 100; i++) { + Field25519.square(t2, t2); + } + + // z_200_0 = z_200_100*z_100_0 + Field25519.mult(t1, t2, t1); + + // z_250_50 = z_200_0^2^50 + Field25519.square(t1, t1); + for (int i = 1; i < 50; i++) { + Field25519.square(t1, t1); + } + + // z_250_0 = z_250_50*z_50_0 + Field25519.mult(t0, t1, t0); + + // z_252_2 = z_250_0^2^2 + Field25519.square(t0, t0); + for (int i = 1; i < 2; i++) { + Field25519.square(t0, t0); + } + + // z_252_3 = z_252_2*z1 + Field25519.mult(out, t0, in); + } + + /** + * Returns 3 bytes of {@code in} starting from {@code idx} in Little-Endian format. + */ + private static long load3(byte[] in, int idx) { + long result; + result = (long) in[idx] & 0xff; + result |= (long) (in[idx + 1] & 0xff) << 8; + result |= (long) (in[idx + 2] & 0xff) << 16; + return result; + } + + /** + * Returns 4 bytes of {@code in} starting from {@code idx} in Little-Endian format. + */ + private static long load4(byte[] in, int idx) { + long result = load3(in, idx); + result |= (long) (in[idx + 3] & 0xff) << 24; + return result; + } + + /** + * Input: + * s[0]+256*s[1]+...+256^63*s[63] = s + *

+ * Output: + * s[0]+256*s[1]+...+256^31*s[31] = s mod l + * where l = 2^252 + 27742317777372353535851937790883648493. + * Overwrites s in place. + */ + private static void reduce(byte[] s) { + // Observation: + // 2^252 mod l is equivalent to -27742317777372353535851937790883648493 mod l + // Let m = -27742317777372353535851937790883648493 + // Thus a*2^252+b mod l is equivalent to a*m+b mod l + // + // First s is divided into chunks of 21 bits as follows: + // s0+2^21*s1+2^42*s3+...+2^462*s23 = s[0]+256*s[1]+...+256^63*s[63] + long s0 = 2097151 & load3(s, 0); + long s1 = 2097151 & (load4(s, 2) >> 5); + long s2 = 2097151 & (load3(s, 5) >> 2); + long s3 = 2097151 & (load4(s, 7) >> 7); + long s4 = 2097151 & (load4(s, 10) >> 4); + long s5 = 2097151 & (load3(s, 13) >> 1); + long s6 = 2097151 & (load4(s, 15) >> 6); + long s7 = 2097151 & (load3(s, 18) >> 3); + long s8 = 2097151 & load3(s, 21); + long s9 = 2097151 & (load4(s, 23) >> 5); + long s10 = 2097151 & (load3(s, 26) >> 2); + long s11 = 2097151 & (load4(s, 28) >> 7); + long s12 = 2097151 & (load4(s, 31) >> 4); + long s13 = 2097151 & (load3(s, 34) >> 1); + long s14 = 2097151 & (load4(s, 36) >> 6); + long s15 = 2097151 & (load3(s, 39) >> 3); + long s16 = 2097151 & load3(s, 42); + long s17 = 2097151 & (load4(s, 44) >> 5); + long s18 = 2097151 & (load3(s, 47) >> 2); + long s19 = 2097151 & (load4(s, 49) >> 7); + long s20 = 2097151 & (load4(s, 52) >> 4); + long s21 = 2097151 & (load3(s, 55) >> 1); + long s22 = 2097151 & (load4(s, 57) >> 6); + long s23 = (load4(s, 60) >> 3); + long carry0; + long carry1; + long carry2; + long carry3; + long carry4; + long carry5; + long carry6; + long carry7; + long carry8; + long carry9; + long carry10; + long carry11; + long carry12; + long carry13; + long carry14; + long carry15; + long carry16; + + // s23*2^462 = s23*2^210*2^252 is equivalent to s23*2^210*m in mod l + // As m is a 125 bit number, the result needs to scattered to 6 limbs (125/21 ceil is 6) + // starting from s11 (s11*2^210) + // m = [666643, 470296, 654183, -997805, 136657, -683901] in 21-bit limbs + s11 += s23 * 666643; + s12 += s23 * 470296; + s13 += s23 * 654183; + s14 -= s23 * 997805; + s15 += s23 * 136657; + s16 -= s23 * 683901; + // s23 = 0; + + s10 += s22 * 666643; + s11 += s22 * 470296; + s12 += s22 * 654183; + s13 -= s22 * 997805; + s14 += s22 * 136657; + s15 -= s22 * 683901; + // s22 = 0; + + s9 += s21 * 666643; + s10 += s21 * 470296; + s11 += s21 * 654183; + s12 -= s21 * 997805; + s13 += s21 * 136657; + s14 -= s21 * 683901; + // s21 = 0; + + s8 += s20 * 666643; + s9 += s20 * 470296; + s10 += s20 * 654183; + s11 -= s20 * 997805; + s12 += s20 * 136657; + s13 -= s20 * 683901; + // s20 = 0; + + s7 += s19 * 666643; + s8 += s19 * 470296; + s9 += s19 * 654183; + s10 -= s19 * 997805; + s11 += s19 * 136657; + s12 -= s19 * 683901; + // s19 = 0; + + s6 += s18 * 666643; + s7 += s18 * 470296; + s8 += s18 * 654183; + s9 -= s18 * 997805; + s10 += s18 * 136657; + s11 -= s18 * 683901; + // s18 = 0; + + // Reduce the bit length of limbs from s6 to s15 to 21-bits. + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry12 = (s12 + (1 << 20)) >> 21; + s13 += carry12; + s12 -= carry12 << 21; + carry14 = (s14 + (1 << 20)) >> 21; + s15 += carry14; + s14 -= carry14 << 21; + carry16 = (s16 + (1 << 20)) >> 21; + s17 += carry16; + s16 -= carry16 << 21; + + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + carry13 = (s13 + (1 << 20)) >> 21; + s14 += carry13; + s13 -= carry13 << 21; + carry15 = (s15 + (1 << 20)) >> 21; + s16 += carry15; + s15 -= carry15 << 21; + + // Resume reduction where we left off. + s5 += s17 * 666643; + s6 += s17 * 470296; + s7 += s17 * 654183; + s8 -= s17 * 997805; + s9 += s17 * 136657; + s10 -= s17 * 683901; + // s17 = 0; + + s4 += s16 * 666643; + s5 += s16 * 470296; + s6 += s16 * 654183; + s7 -= s16 * 997805; + s8 += s16 * 136657; + s9 -= s16 * 683901; + // s16 = 0; + + s3 += s15 * 666643; + s4 += s15 * 470296; + s5 += s15 * 654183; + s6 -= s15 * 997805; + s7 += s15 * 136657; + s8 -= s15 * 683901; + // s15 = 0; + + s2 += s14 * 666643; + s3 += s14 * 470296; + s4 += s14 * 654183; + s5 -= s14 * 997805; + s6 += s14 * 136657; + s7 -= s14 * 683901; + // s14 = 0; + + s1 += s13 * 666643; + s2 += s13 * 470296; + s3 += s13 * 654183; + s4 -= s13 * 997805; + s5 += s13 * 136657; + s6 -= s13 * 683901; + // s13 = 0; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + // Reduce the range of limbs from s0 to s11 to 21-bits. + carry0 = (s0 + (1 << 20)) >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry2 = (s2 + (1 << 20)) >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry4 = (s4 + (1 << 20)) >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + carry1 = (s1 + (1 << 20)) >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry3 = (s3 + (1 << 20)) >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry5 = (s5 + (1 << 20)) >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + // Carry chain reduction to propagate excess bits from s0 to s5 to the most significant limbs. + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry11 = s11 >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + // Do one last reduction as s12 might be 1. + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + // s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + // Serialize the result into the s. + s[0] = (byte) s0; + s[1] = (byte) (s0 >> 8); + s[2] = (byte) ((s0 >> 16) | (s1 << 5)); + s[3] = (byte) (s1 >> 3); + s[4] = (byte) (s1 >> 11); + s[5] = (byte) ((s1 >> 19) | (s2 << 2)); + s[6] = (byte) (s2 >> 6); + s[7] = (byte) ((s2 >> 14) | (s3 << 7)); + s[8] = (byte) (s3 >> 1); + s[9] = (byte) (s3 >> 9); + s[10] = (byte) ((s3 >> 17) | (s4 << 4)); + s[11] = (byte) (s4 >> 4); + s[12] = (byte) (s4 >> 12); + s[13] = (byte) ((s4 >> 20) | (s5 << 1)); + s[14] = (byte) (s5 >> 7); + s[15] = (byte) ((s5 >> 15) | (s6 << 6)); + s[16] = (byte) (s6 >> 2); + s[17] = (byte) (s6 >> 10); + s[18] = (byte) ((s6 >> 18) | (s7 << 3)); + s[19] = (byte) (s7 >> 5); + s[20] = (byte) (s7 >> 13); + s[21] = (byte) s8; + s[22] = (byte) (s8 >> 8); + s[23] = (byte) ((s8 >> 16) | (s9 << 5)); + s[24] = (byte) (s9 >> 3); + s[25] = (byte) (s9 >> 11); + s[26] = (byte) ((s9 >> 19) | (s10 << 2)); + s[27] = (byte) (s10 >> 6); + s[28] = (byte) ((s10 >> 14) | (s11 << 7)); + s[29] = (byte) (s11 >> 1); + s[30] = (byte) (s11 >> 9); + s[31] = (byte) (s11 >> 17); + } + + /** + * Input: + * a[0]+256*a[1]+...+256^31*a[31] = a + * b[0]+256*b[1]+...+256^31*b[31] = b + * c[0]+256*c[1]+...+256^31*c[31] = c + *

+ * Output: + * s[0]+256*s[1]+...+256^31*s[31] = (ab+c) mod l + * where l = 2^252 + 27742317777372353535851937790883648493. + */ + private static void mulAdd(byte[] s, byte[] a, byte[] b, byte[] c) { + // This is very similar to Ed25519.reduce, the difference in here is that it computes ab+c + // See Ed25519.reduce for related comments. + long a0 = 2097151 & load3(a, 0); + long a1 = 2097151 & (load4(a, 2) >> 5); + long a2 = 2097151 & (load3(a, 5) >> 2); + long a3 = 2097151 & (load4(a, 7) >> 7); + long a4 = 2097151 & (load4(a, 10) >> 4); + long a5 = 2097151 & (load3(a, 13) >> 1); + long a6 = 2097151 & (load4(a, 15) >> 6); + long a7 = 2097151 & (load3(a, 18) >> 3); + long a8 = 2097151 & load3(a, 21); + long a9 = 2097151 & (load4(a, 23) >> 5); + long a10 = 2097151 & (load3(a, 26) >> 2); + long a11 = (load4(a, 28) >> 7); + long b0 = 2097151 & load3(b, 0); + long b1 = 2097151 & (load4(b, 2) >> 5); + long b2 = 2097151 & (load3(b, 5) >> 2); + long b3 = 2097151 & (load4(b, 7) >> 7); + long b4 = 2097151 & (load4(b, 10) >> 4); + long b5 = 2097151 & (load3(b, 13) >> 1); + long b6 = 2097151 & (load4(b, 15) >> 6); + long b7 = 2097151 & (load3(b, 18) >> 3); + long b8 = 2097151 & load3(b, 21); + long b9 = 2097151 & (load4(b, 23) >> 5); + long b10 = 2097151 & (load3(b, 26) >> 2); + long b11 = (load4(b, 28) >> 7); + long c0 = 2097151 & load3(c, 0); + long c1 = 2097151 & (load4(c, 2) >> 5); + long c2 = 2097151 & (load3(c, 5) >> 2); + long c3 = 2097151 & (load4(c, 7) >> 7); + long c4 = 2097151 & (load4(c, 10) >> 4); + long c5 = 2097151 & (load3(c, 13) >> 1); + long c6 = 2097151 & (load4(c, 15) >> 6); + long c7 = 2097151 & (load3(c, 18) >> 3); + long c8 = 2097151 & load3(c, 21); + long c9 = 2097151 & (load4(c, 23) >> 5); + long c10 = 2097151 & (load3(c, 26) >> 2); + long c11 = (load4(c, 28) >> 7); + long s0; + long s1; + long s2; + long s3; + long s4; + long s5; + long s6; + long s7; + long s8; + long s9; + long s10; + long s11; + long s12; + long s13; + long s14; + long s15; + long s16; + long s17; + long s18; + long s19; + long s20; + long s21; + long s22; + long s23; + long carry0; + long carry1; + long carry2; + long carry3; + long carry4; + long carry5; + long carry6; + long carry7; + long carry8; + long carry9; + long carry10; + long carry11; + long carry12; + long carry13; + long carry14; + long carry15; + long carry16; + long carry17; + long carry18; + long carry19; + long carry20; + long carry21; + long carry22; + + s0 = c0 + a0 * b0; + s1 = c1 + a0 * b1 + a1 * b0; + s2 = c2 + a0 * b2 + a1 * b1 + a2 * b0; + s3 = c3 + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0; + s4 = c4 + a0 * b4 + a1 * b3 + a2 * b2 + a3 * b1 + a4 * b0; + s5 = c5 + a0 * b5 + a1 * b4 + a2 * b3 + a3 * b2 + a4 * b1 + a5 * b0; + s6 = c6 + a0 * b6 + a1 * b5 + a2 * b4 + a3 * b3 + a4 * b2 + a5 * b1 + a6 * b0; + s7 = c7 + a0 * b7 + a1 * b6 + a2 * b5 + a3 * b4 + a4 * b3 + a5 * b2 + a6 * b1 + a7 * b0; + s8 = c8 + a0 * b8 + a1 * b7 + a2 * b6 + a3 * b5 + a4 * b4 + a5 * b3 + a6 * b2 + a7 * b1 + + a8 * b0; + s9 = c9 + a0 * b9 + a1 * b8 + a2 * b7 + a3 * b6 + a4 * b5 + a5 * b4 + a6 * b3 + a7 * b2 + + a8 * b1 + a9 * b0; + s10 = c10 + a0 * b10 + a1 * b9 + a2 * b8 + a3 * b7 + a4 * b6 + a5 * b5 + a6 * b4 + a7 * b3 + + a8 * b2 + a9 * b1 + a10 * b0; + s11 = c11 + a0 * b11 + a1 * b10 + a2 * b9 + a3 * b8 + a4 * b7 + a5 * b6 + a6 * b5 + a7 * b4 + + a8 * b3 + a9 * b2 + a10 * b1 + a11 * b0; + s12 = a1 * b11 + a2 * b10 + a3 * b9 + a4 * b8 + a5 * b7 + a6 * b6 + a7 * b5 + a8 * b4 + a9 * b3 + + a10 * b2 + a11 * b1; + s13 = a2 * b11 + a3 * b10 + a4 * b9 + a5 * b8 + a6 * b7 + a7 * b6 + a8 * b5 + a9 * b4 + a10 * b3 + + a11 * b2; + s14 = a3 * b11 + a4 * b10 + a5 * b9 + a6 * b8 + a7 * b7 + a8 * b6 + a9 * b5 + a10 * b4 + + a11 * b3; + s15 = a4 * b11 + a5 * b10 + a6 * b9 + a7 * b8 + a8 * b7 + a9 * b6 + a10 * b5 + a11 * b4; + s16 = a5 * b11 + a6 * b10 + a7 * b9 + a8 * b8 + a9 * b7 + a10 * b6 + a11 * b5; + s17 = a6 * b11 + a7 * b10 + a8 * b9 + a9 * b8 + a10 * b7 + a11 * b6; + s18 = a7 * b11 + a8 * b10 + a9 * b9 + a10 * b8 + a11 * b7; + s19 = a8 * b11 + a9 * b10 + a10 * b9 + a11 * b8; + s20 = a9 * b11 + a10 * b10 + a11 * b9; + s21 = a10 * b11 + a11 * b10; + s22 = a11 * b11; + s23 = 0; + + carry0 = (s0 + (1 << 20)) >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry2 = (s2 + (1 << 20)) >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry4 = (s4 + (1 << 20)) >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry12 = (s12 + (1 << 20)) >> 21; + s13 += carry12; + s12 -= carry12 << 21; + carry14 = (s14 + (1 << 20)) >> 21; + s15 += carry14; + s14 -= carry14 << 21; + carry16 = (s16 + (1 << 20)) >> 21; + s17 += carry16; + s16 -= carry16 << 21; + carry18 = (s18 + (1 << 20)) >> 21; + s19 += carry18; + s18 -= carry18 << 21; + carry20 = (s20 + (1 << 20)) >> 21; + s21 += carry20; + s20 -= carry20 << 21; + carry22 = (s22 + (1 << 20)) >> 21; + s23 += carry22; + s22 -= carry22 << 21; + + carry1 = (s1 + (1 << 20)) >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry3 = (s3 + (1 << 20)) >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry5 = (s5 + (1 << 20)) >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + carry13 = (s13 + (1 << 20)) >> 21; + s14 += carry13; + s13 -= carry13 << 21; + carry15 = (s15 + (1 << 20)) >> 21; + s16 += carry15; + s15 -= carry15 << 21; + carry17 = (s17 + (1 << 20)) >> 21; + s18 += carry17; + s17 -= carry17 << 21; + carry19 = (s19 + (1 << 20)) >> 21; + s20 += carry19; + s19 -= carry19 << 21; + carry21 = (s21 + (1 << 20)) >> 21; + s22 += carry21; + s21 -= carry21 << 21; + + s11 += s23 * 666643; + s12 += s23 * 470296; + s13 += s23 * 654183; + s14 -= s23 * 997805; + s15 += s23 * 136657; + s16 -= s23 * 683901; + // s23 = 0; + + s10 += s22 * 666643; + s11 += s22 * 470296; + s12 += s22 * 654183; + s13 -= s22 * 997805; + s14 += s22 * 136657; + s15 -= s22 * 683901; + // s22 = 0; + + s9 += s21 * 666643; + s10 += s21 * 470296; + s11 += s21 * 654183; + s12 -= s21 * 997805; + s13 += s21 * 136657; + s14 -= s21 * 683901; + // s21 = 0; + + s8 += s20 * 666643; + s9 += s20 * 470296; + s10 += s20 * 654183; + s11 -= s20 * 997805; + s12 += s20 * 136657; + s13 -= s20 * 683901; + // s20 = 0; + + s7 += s19 * 666643; + s8 += s19 * 470296; + s9 += s19 * 654183; + s10 -= s19 * 997805; + s11 += s19 * 136657; + s12 -= s19 * 683901; + // s19 = 0; + + s6 += s18 * 666643; + s7 += s18 * 470296; + s8 += s18 * 654183; + s9 -= s18 * 997805; + s10 += s18 * 136657; + s11 -= s18 * 683901; + // s18 = 0; + + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry12 = (s12 + (1 << 20)) >> 21; + s13 += carry12; + s12 -= carry12 << 21; + carry14 = (s14 + (1 << 20)) >> 21; + s15 += carry14; + s14 -= carry14 << 21; + carry16 = (s16 + (1 << 20)) >> 21; + s17 += carry16; + s16 -= carry16 << 21; + + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + carry13 = (s13 + (1 << 20)) >> 21; + s14 += carry13; + s13 -= carry13 << 21; + carry15 = (s15 + (1 << 20)) >> 21; + s16 += carry15; + s15 -= carry15 << 21; + + s5 += s17 * 666643; + s6 += s17 * 470296; + s7 += s17 * 654183; + s8 -= s17 * 997805; + s9 += s17 * 136657; + s10 -= s17 * 683901; + // s17 = 0; + + s4 += s16 * 666643; + s5 += s16 * 470296; + s6 += s16 * 654183; + s7 -= s16 * 997805; + s8 += s16 * 136657; + s9 -= s16 * 683901; + // s16 = 0; + + s3 += s15 * 666643; + s4 += s15 * 470296; + s5 += s15 * 654183; + s6 -= s15 * 997805; + s7 += s15 * 136657; + s8 -= s15 * 683901; + // s15 = 0; + + s2 += s14 * 666643; + s3 += s14 * 470296; + s4 += s14 * 654183; + s5 -= s14 * 997805; + s6 += s14 * 136657; + s7 -= s14 * 683901; + // s14 = 0; + + s1 += s13 * 666643; + s2 += s13 * 470296; + s3 += s13 * 654183; + s4 -= s13 * 997805; + s5 += s13 * 136657; + s6 -= s13 * 683901; + // s13 = 0; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = (s0 + (1 << 20)) >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry2 = (s2 + (1 << 20)) >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry4 = (s4 + (1 << 20)) >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + carry1 = (s1 + (1 << 20)) >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry3 = (s3 + (1 << 20)) >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry5 = (s5 + (1 << 20)) >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry11 = s11 >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + // s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + s[0] = (byte) s0; + s[1] = (byte) (s0 >> 8); + s[2] = (byte) ((s0 >> 16) | (s1 << 5)); + s[3] = (byte) (s1 >> 3); + s[4] = (byte) (s1 >> 11); + s[5] = (byte) ((s1 >> 19) | (s2 << 2)); + s[6] = (byte) (s2 >> 6); + s[7] = (byte) ((s2 >> 14) | (s3 << 7)); + s[8] = (byte) (s3 >> 1); + s[9] = (byte) (s3 >> 9); + s[10] = (byte) ((s3 >> 17) | (s4 << 4)); + s[11] = (byte) (s4 >> 4); + s[12] = (byte) (s4 >> 12); + s[13] = (byte) ((s4 >> 20) | (s5 << 1)); + s[14] = (byte) (s5 >> 7); + s[15] = (byte) ((s5 >> 15) | (s6 << 6)); + s[16] = (byte) (s6 >> 2); + s[17] = (byte) (s6 >> 10); + s[18] = (byte) ((s6 >> 18) | (s7 << 3)); + s[19] = (byte) (s7 >> 5); + s[20] = (byte) (s7 >> 13); + s[21] = (byte) s8; + s[22] = (byte) (s8 >> 8); + s[23] = (byte) ((s8 >> 16) | (s9 << 5)); + s[24] = (byte) (s9 >> 3); + s[25] = (byte) (s9 >> 11); + s[26] = (byte) ((s9 >> 19) | (s10 << 2)); + s[27] = (byte) (s10 >> 6); + s[28] = (byte) ((s10 >> 14) | (s11 << 7)); + s[29] = (byte) (s11 >> 1); + s[30] = (byte) (s11 >> 9); + s[31] = (byte) (s11 >> 17); + } + + // The order of the generator as unsigned bytes in little endian order. + // (2^252 + 0x14def9dea2f79cd65812631a5cf5d3ed, cf. RFC 7748) + private static final byte[] GROUP_ORDER = { + (byte) 0xed, (byte) 0xd3, (byte) 0xf5, (byte) 0x5c, + (byte) 0x1a, (byte) 0x63, (byte) 0x12, (byte) 0x58, + (byte) 0xd6, (byte) 0x9c, (byte) 0xf7, (byte) 0xa2, + (byte) 0xde, (byte) 0xf9, (byte) 0xde, (byte) 0x14, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10}; + + // Checks whether s represents an integer smaller than the order of the group. + // This is needed to ensure that EdDSA signatures are non-malleable, as failing to check + // the range of S allows to modify signatures (cf. RFC 8032, Section 5.2.7 and Section 8.4.) + // @param s an integer in little-endian order. + private static boolean isSmallerThanGroupOrder(byte[] s) { + for (int j = Field25519.FIELD_LEN - 1; j >= 0; j--) { + // compare unsigned bytes + int a = s[j] & 0xff; + int b = GROUP_ORDER[j] & 0xff; + if (a != b) { + return a < b; + } + } + return false; + } + + /** + * Returns true if the EdDSA {@code signature} with {@code message}, can be verified with + * {@code publicKey}. + */ + public static boolean verify(final byte[] message, final byte[] signature, + final byte[] publicKey) { + try { + if (signature.length != SIGNATURE_LEN) { + return false; + } + if (publicKey.length != PUBLIC_KEY_LEN) { + return false; + } + byte[] s = Arrays.copyOfRange(signature, Field25519.FIELD_LEN, SIGNATURE_LEN); + if (!isSmallerThanGroupOrder(s)) { + return false; + } + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + digest.update(signature, 0, Field25519.FIELD_LEN); + digest.update(publicKey); + digest.update(message); + byte[] h = digest.digest(); + reduce(h); + + XYZT negPublicKey = XYZT.fromBytesNegateVarTime(publicKey); + XYZ xyz = doubleScalarMultVarTime(h, negPublicKey, s); + byte[] expectedR = xyz.toBytes(); + for (int i = 0; i < Field25519.FIELD_LEN; i++) { + if (expectedR[i] != signature[i]) { + return false; + } + } + return true; + } catch (final GeneralSecurityException ignored) { + return false; + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt b/ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt new file mode 100644 index 0000000..e613499 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt @@ -0,0 +1,173 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.updater + +import android.content.Intent +import android.net.Uri +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import com.wireguard.android.R +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.QuantityFormatter +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +class SnackbarUpdateShower(private val fragment: Fragment) { + private var lastUserIntervention: Updater.Progress.NeedsUserIntervention? = null + private val intentLauncher = fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + lastUserIntervention?.markAsDone() + } + + private class SwapableSnackbar(fragment: Fragment, view: View, anchor: View?) { + private val actionSnackbar = makeSnackbar(fragment, view, anchor) + private val statusSnackbar = makeSnackbar(fragment, view, anchor) + private var showingAction: Boolean = false + private var showingStatus: Boolean = false + + private fun makeSnackbar(fragment: Fragment, view: View, anchor: View?): Snackbar { + val snackbar = Snackbar.make(fragment.requireContext(), view, "", Snackbar.LENGTH_INDEFINITE) + if (anchor != null) + snackbar.anchorView = anchor + snackbar.setTextMaxLines(6) + snackbar.behavior = object : BaseTransientBottomBar.Behavior() { + override fun canSwipeDismissView(child: View): Boolean { + return false + } + } + snackbar.addCallback(object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(snackbar: Snackbar?, @DismissEvent event: Int) { + super.onDismissed(snackbar, event) + if (event == DISMISS_EVENT_MANUAL || event == DISMISS_EVENT_ACTION || + (snackbar == actionSnackbar && !showingAction) || (snackbar == statusSnackbar && !showingStatus) + ) + return + fragment.lifecycleScope.launch { + delay(5.seconds) + snackbar?.show() + } + } + }) + return snackbar + } + + fun showAction(text: String, action: String, listener: View.OnClickListener) { + if (showingStatus) { + showingStatus = false + statusSnackbar.dismiss() + } + actionSnackbar.setText(text) + actionSnackbar.setAction(action, listener) + if (!showingAction) { + actionSnackbar.show() + showingAction = true + } + } + + fun showText(text: String) { + if (showingAction) { + showingAction = false + actionSnackbar.dismiss() + } + statusSnackbar.setText(text) + if (!showingStatus) { + statusSnackbar.show() + showingStatus = true + } + } + + fun dismiss() { + actionSnackbar.dismiss() + statusSnackbar.dismiss() + showingAction = false + showingStatus = false + } + } + + fun attach(view: View, anchor: View?) { + val snackbar = SwapableSnackbar(fragment, view, anchor) + val context = fragment.requireContext() + + Updater.state.onEach { progress -> + when (progress) { + is Updater.Progress.Complete -> + snackbar.dismiss() + + is Updater.Progress.Available -> + snackbar.showAction(context.getString(R.string.updater_avalable), context.getString(R.string.updater_action)) { + progress.update() + } + + is Updater.Progress.NeedsUserIntervention -> { + lastUserIntervention = progress + intentLauncher.launch(progress.intent) + } + + is Updater.Progress.Installing -> + snackbar.showText(context.getString(R.string.updater_installing)) + + is Updater.Progress.Rechecking -> + snackbar.showText(context.getString(R.string.updater_rechecking)) + + is Updater.Progress.Downloading -> { + if (progress.bytesTotal != 0UL) { + snackbar.showText( + context.getString( + R.string.updater_download_progress, + QuantityFormatter.formatBytes(progress.bytesDownloaded.toLong()), + QuantityFormatter.formatBytes(progress.bytesTotal.toLong()), + progress.bytesDownloaded.toFloat() * 100.0 / progress.bytesTotal.toFloat() + ) + ) + } else { + snackbar.showText( + context.getString( + R.string.updater_download_progress_nototal, + QuantityFormatter.formatBytes(progress.bytesDownloaded.toLong()) + ) + ) + } + } + + is Updater.Progress.Failure -> { + snackbar.showText(context.getString(R.string.updater_failure, ErrorMessages[progress.error])) + delay(5.seconds) + progress.retry() + } + + is Updater.Progress.Corrupt -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.updater_corrupt_title) + .setMessage(R.string.updater_corrupt_message) + .setPositiveButton(R.string.updater_corrupt_navigate) { _, _ -> + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(progress.downloadUrl) + try { + context.startActivity(intent) + } catch (e: Throwable) { + Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show() + } + }.setCancelable(false).setOnDismissListener { + val intent = Intent(Intent.ACTION_MAIN) + intent.addCategory(Intent.CATEGORY_HOME) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + System.exit(0) + }.show() + } + } + }.launchIn(fragment.lifecycleScope) + } +} \ No newline at end of file diff --git a/ui/src/main/java/com/wireguard/android/updater/Updater.kt b/ui/src/main/java/com/wireguard/android/updater/Updater.kt new file mode 100644 index 0000000..4a3ee50 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/updater/Updater.kt @@ -0,0 +1,460 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.updater + +import android.Manifest +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build +import android.util.Base64 +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import com.wireguard.android.Application +import com.wireguard.android.BuildConfig +import com.wireguard.android.activity.MainActivity +import com.wireguard.android.util.UserKnobs +import com.wireguard.android.util.applicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.security.InvalidKeyException +import java.security.InvalidParameterException +import java.security.MessageDigest +import java.util.UUID +import kotlin.math.max +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +object Updater { + private const val TAG = "WireGuard/Updater" + private const val UPDATE_URL_FMT = "https://download.wireguard.com/android-client/%s" + private const val APK_NAME_PREFIX = BuildConfig.APPLICATION_ID + "-" + private const val APK_NAME_SUFFIX = ".apk" + private const val LATEST_FILE = "latest.sig" + private const val RELEASE_PUBLIC_KEY_BASE64 = "RWTAzwGRYr3EC9px0Ia3fbttz8WcVN6wrOwWp2delz4el6SI8XmkKSMp" + private val CURRENT_VERSION by lazy { Version(BuildConfig.VERSION_NAME) } + + private val updaterScope = CoroutineScope(Job() + Dispatchers.IO) + + private fun installer(context: Context): String = try { + val packageName = context.packageName + val pm = context.packageManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + pm.getInstallSourceInfo(packageName).installingPackageName ?: "" + } else { + @Suppress("DEPRECATION") + pm.getInstallerPackageName(packageName) ?: "" + } + } catch (_: Throwable) { + "" + } + + fun installerIsGooglePlay(context: Context): Boolean = installer(context) == "com.android.vending" + + sealed class Progress { + object Complete : Progress() + class Available(val version: String) : Progress() { + fun update() { + applicationScope.launch { + UserKnobs.setUpdaterNewerVersionConsented(version) + } + } + } + + object Rechecking : Progress() + class Downloading(val bytesDownloaded: ULong, val bytesTotal: ULong) : Progress() + object Installing : Progress() + class NeedsUserIntervention(val intent: Intent, private val id: Int) : Progress() { + + private suspend fun installerActive(): Boolean { + if (mutableState.firstOrNull() != this@NeedsUserIntervention) + return true + try { + if (Application.get().packageManager.packageInstaller.getSessionInfo(id)?.isActive == true) + return true + } catch (_: SecurityException) { + return true + } + return false + } + + fun markAsDone() { + applicationScope.launch { + if (installerActive()) + return@launch + delay(7.seconds) + if (installerActive()) + return@launch + emitProgress(Failure(Exception("Ignored by user"))) + } + } + } + + class Failure(val error: Throwable) : Progress() { + fun retry() { + updaterScope.launch { + downloadAndUpdateWrapErrors() + } + } + } + + class Corrupt(private val betterFile: String?) : Progress() { + val downloadUrl: String + get() = UPDATE_URL_FMT.format(betterFile ?: "") + } + } + + private val mutableState = MutableStateFlow(Progress.Complete) + val state = mutableState.asStateFlow() + + private suspend fun emitProgress(progress: Progress, force: Boolean = false) { + if (force || mutableState.firstOrNull()?.javaClass != progress.javaClass) + mutableState.emit(progress) + } + + private class Sha256Digest(hex: String) { + val bytes: ByteArray + + init { + if (hex.length != 64) + throw InvalidParameterException("SHA256 hashes must be 32 bytes long") + bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + } + + @OptIn(ExperimentalUnsignedTypes::class) + private class Version(version: String) : Comparable { + val parts: ULongArray + + init { + val strParts = version.split(".") + if (strParts.isEmpty()) + throw InvalidParameterException("Version has no parts") + parts = ULongArray(strParts.size) + for (i in parts.indices) { + parts[i] = strParts[i].toULong() + } + } + + override fun toString(): String { + return parts.joinToString(".") + } + + override fun compareTo(other: Version): Int { + for (i in 0 until max(parts.size, other.parts.size)) { + val lhsPart = if (i < parts.size) parts[i] else 0UL + val rhsPart = if (i < other.parts.size) other.parts[i] else 0UL + if (lhsPart > rhsPart) + return 1 + else if (lhsPart < rhsPart) + return -1 + } + return 0 + } + } + + private class Update(val fileName: String, val version: Version, val hash: Sha256Digest) + + private fun versionOfFile(name: String): Version? { + if (!name.startsWith(APK_NAME_PREFIX) || !name.endsWith(APK_NAME_SUFFIX)) + return null + return try { + Version(name.substring(APK_NAME_PREFIX.length, name.length - APK_NAME_SUFFIX.length)) + } catch (_: Throwable) { + null + } + } + + private fun verifySignedFileList(signifyDigest: String): List { + val updates = ArrayList(1) + val publicKeyBytes = Base64.decode(RELEASE_PUBLIC_KEY_BASE64, Base64.DEFAULT) + if (publicKeyBytes == null || publicKeyBytes.size != 32 + 10 || publicKeyBytes[0] != 'E'.code.toByte() || publicKeyBytes[1] != 'd'.code.toByte()) + throw InvalidKeyException("Invalid public key") + val lines = signifyDigest.split("\n", limit = 3) + if (lines.size != 3) + throw InvalidParameterException("Invalid signature format: too few lines") + if (!lines[0].startsWith("untrusted comment: ")) + throw InvalidParameterException("Invalid signature format: missing comment") + val signatureBytes = Base64.decode(lines[1], Base64.DEFAULT) + if (signatureBytes == null || signatureBytes.size != 64 + 10) + throw InvalidParameterException("Invalid signature format: wrong sized or missing signature") + for (i in 0..9) { + if (signatureBytes[i] != publicKeyBytes[i]) + throw InvalidParameterException("Invalid signature format: wrong signer") + } + if (!Ed25519.verify( + lines[2].toByteArray(StandardCharsets.UTF_8), + signatureBytes.sliceArray(10 until 10 + 64), + publicKeyBytes.sliceArray(10 until 10 + 32) + ) + ) + throw SecurityException("Invalid signature") + for (line in lines[2].split("\n").dropLastWhile { it.isEmpty() }) { + val components = line.split(" ", limit = 2) + if (components.size != 2) + throw InvalidParameterException("Invalid file list format: too few components") + /* If version is null, it's not a file we understand, but still a legitimate entry, so don't throw. */ + val version = versionOfFile(components[1]) ?: continue + updates.add(Update(components[1], version, Sha256Digest(components[0]))) + } + return updates + } + + private fun checkForUpdates(): Update? { + val connection = URL(UPDATE_URL_FMT.format(LATEST_FILE)).openConnection() as HttpURLConnection + connection.setRequestProperty("User-Agent", Application.USER_AGENT) + connection.connect() + if (connection.responseCode != HttpURLConnection.HTTP_OK) + throw IOException(connection.responseMessage) + var fileListBytes = ByteArray(1024 * 512 /* 512 KiB */) + connection.inputStream.use { + val len = it.read(fileListBytes) + if (len <= 0) + throw IOException("File list is empty") + fileListBytes = fileListBytes.sliceArray(0 until len) + } + return verifySignedFileList(fileListBytes.decodeToString()).maxByOrNull { it.version } + } + + private suspend fun downloadAndUpdate() = withContext(Dispatchers.IO) { + val receiver = InstallReceiver() + val context = Application.get().applicationContext + val pendingIntent = withContext(Dispatchers.Main) { + ContextCompat.registerReceiver(context, receiver, IntentFilter(receiver.sessionId), ContextCompat.RECEIVER_NOT_EXPORTED) + PendingIntent.getBroadcast( + context, + 0, + Intent(receiver.sessionId).setPackage(context.packageName), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + } + + emitProgress(Progress.Rechecking) + val update = checkForUpdates() + if (update == null || update.version <= CURRENT_VERSION) { + emitProgress(Progress.Complete) + return@withContext + } + + emitProgress(Progress.Downloading(0UL, 0UL), true) + val connection = URL(UPDATE_URL_FMT.format(update.fileName)).openConnection() as HttpURLConnection + connection.setRequestProperty("User-Agent", Application.USER_AGENT) + connection.connect() + if (connection.responseCode != HttpURLConnection.HTTP_OK) + throw IOException("Update could not be fetched: ${connection.responseCode}") + + var downloadedByteLen: ULong = 0UL + val totalByteLen = connection.contentLengthLong.toULong() + val fileBytes = ByteArray(1024 * 32 /* 32 KiB */) + val digest = MessageDigest.getInstance("SHA-256") + emitProgress(Progress.Downloading(downloadedByteLen, totalByteLen), true) + + val installer = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + params.setAppPackageName(context.packageName) /* Enforces updates; disallows new apps. */ + val session = installer.openSession(installer.createSession(params)) + var sessionFailure = true + try { + val installDest = session.openWrite(receiver.sessionId, 0, -1) + + installDest.use { dest -> + connection.inputStream.use { src -> + while (true) { + val readLen = src.read(fileBytes) + if (readLen <= 0) + break + + digest.update(fileBytes, 0, readLen) + dest.write(fileBytes, 0, readLen) + + downloadedByteLen += readLen.toUInt() + emitProgress(Progress.Downloading(downloadedByteLen, totalByteLen), true) + + if (downloadedByteLen >= 1024UL * 1024UL * 100UL /* 100 MiB */) + throw IOException("File too large") + } + } + } + + emitProgress(Progress.Installing) + if (!digest.digest().contentEquals(update.hash.bytes)) + throw SecurityException("Update has invalid hash") + sessionFailure = false + } finally { + if (sessionFailure) { + session.abandon() + session.close() + } + } + session.commit(pendingIntent.intentSender) + session.close() + } + + private var updating = false + private suspend fun downloadAndUpdateWrapErrors() { + if (updating) + return + updating = true + try { + downloadAndUpdate() + } catch (e: Throwable) { + Log.e(TAG, "Update failure", e) + emitProgress(Progress.Failure(e)) + } + updating = false + } + + private class InstallReceiver : BroadcastReceiver() { + val sessionId = UUID.randomUUID().toString() + + override fun onReceive(context: Context, intent: Intent) { + if (sessionId != intent.action) + return + + when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE_INVALID)) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val id = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, 0) + val userIntervention = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)!! + applicationScope.launch { + emitProgress(Progress.NeedsUserIntervention(userIntervention, id)) + } + } + + PackageInstaller.STATUS_SUCCESS -> { + applicationScope.launch { + emitProgress(Progress.Complete) + } + context.applicationContext.unregisterReceiver(this) + } + + else -> { + val id = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, 0) + try { + context.applicationContext.packageManager.packageInstaller.abandonSession(id) + } catch (_: SecurityException) { + } + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "Installation error $status" + applicationScope.launch { + val e = Exception(message) + Log.e(TAG, "Update failure", e) + emitProgress(Progress.Failure(e)) + } + context.applicationContext.unregisterReceiver(this) + } + } + } + } + + fun monitorForUpdates() { + if (BuildConfig.DEBUG) + return + + val context = Application.get() + + if (installerIsGooglePlay(context)) + return + + if (BuildConfig.BUILD_TYPE == "googleplay") { + if (installer(context).isNotEmpty()) { + applicationScope.launch { + emitProgress(Progress.Corrupt(null)) + } + } + return + } + + if (if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + } else { + context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())) + }.requestedPermissions?.contains(Manifest.permission.REQUEST_INSTALL_PACKAGES) != true + ) { + if (installer(context).isNotEmpty()) { + updaterScope.launch { + val update = try { + checkForUpdates() + } catch (_: Throwable) { + null + } + emitProgress(Progress.Corrupt(update?.fileName)) + } + } + return + } + + updaterScope.launch { + if (UserKnobs.updaterNewerVersionSeen.firstOrNull()?.let { Version(it) > CURRENT_VERSION } == true) + return@launch + + var waitTime = 15 + while (true) { + try { + val update = checkForUpdates() ?: continue + if (update.version > CURRENT_VERSION) { + Log.i(TAG, "Update available: ${update.version}") + UserKnobs.setUpdaterNewerVersionSeen(update.version.toString()) + return@launch + } + } catch (_: Throwable) { + } + delay(waitTime.minutes) + waitTime = 45 + } + } + + UserKnobs.updaterNewerVersionSeen.onEach { ver -> + if ( + ver != null && + Version(ver) > CURRENT_VERSION && + UserKnobs.updaterNewerVersionConsented.firstOrNull()?.let { Version(it) > CURRENT_VERSION } != true + ) + emitProgress(Progress.Available(ver)) + }.launchIn(applicationScope) + + UserKnobs.updaterNewerVersionConsented.onEach { ver -> + if (ver != null && Version(ver) > CURRENT_VERSION) + updaterScope.launch { + downloadAndUpdateWrapErrors() + } + }.launchIn(applicationScope) + } + + class AppUpdatedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) + return + + if (installer(context) != context.packageName) + return + + /* TODO: does not work because of restrictions placed on broadcast receivers. */ + val start = Intent(context, MainActivity::class.java) + start.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + start.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(start) + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt b/ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt new file mode 100644 index 0000000..2c23910 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/AdminKnobs.kt @@ -0,0 +1,17 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util + +import android.content.RestrictionsManager +import androidx.core.content.getSystemService +import com.wireguard.android.Application + +object AdminKnobs { + private val restrictions: RestrictionsManager? = Application.get().getSystemService() + val disableConfigExport: Boolean + get() = restrictions?.applicationRestrictions?.getBoolean("disable_config_export", false) + ?: false +} diff --git a/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt b/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt new file mode 100644 index 0000000..064ea04 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/BiometricAuthenticator.kt @@ -0,0 +1,80 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util + +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.annotation.StringRes +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.Fragment +import com.wireguard.android.R + + +object BiometricAuthenticator { + private const val TAG = "WireGuard/BiometricAuthenticator" + + // Not all devices support strong biometric auth so we're allowing both device credentials as + // well as weak biometrics. + private const val allowedAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK + + sealed class Result { + data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() + data class Failure(val code: Int?, val message: CharSequence) : Result() + object HardwareUnavailableOrDisabled : Result() + object Cancelled : Result() + } + + fun authenticate( + @StringRes dialogTitleRes: Int, + fragment: Fragment, + callback: (Result) -> Unit + ) { + val authCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + Log.d(TAG, "BiometricAuthentication error: errorCode=$errorCode, msg=$errString") + callback( + when (errorCode) { + BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { + Result.Cancelled + } + + BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE, + BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> { + Result.HardwareUnavailableOrDisabled + } + + else -> Result.Failure(errorCode, fragment.getString(R.string.biometric_auth_error_reason, errString)) + } + ) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + callback(Result.Failure(null, fragment.getString(R.string.biometric_auth_error))) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + callback(Result.Success(result.cryptoObject)) + } + } + val biometricPrompt = BiometricPrompt(fragment, { Handler(Looper.getMainLooper()).post(it) }, authCallback) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(fragment.getString(dialogTitleRes)) + .setAllowedAuthenticators(allowedAuthenticators) + .build() + if (BiometricManager.from(fragment.requireContext()).canAuthenticate(allowedAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS) { + biometricPrompt.authenticate(promptInfo) + } else { + callback(Result.HardwareUnavailableOrDisabled) + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt new file mode 100644 index 0000000..8968979 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.kt @@ -0,0 +1,37 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.util + +import android.content.ClipData +import android.content.ClipboardManager +import android.os.Build +import android.view.View +import android.widget.TextView +import androidx.core.content.getSystemService +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputEditText +import com.wireguard.android.R + +/** + * Standalone utilities for interacting with the system clipboard. + */ +object ClipboardUtils { + @JvmStatic + fun copyTextView(view: View) { + val data = when (view) { + is TextInputEditText -> Pair(view.editableText, view.hint) + is TextView -> Pair(view.text, view.contentDescription) + else -> return + } + if (data.first == null || data.first.isEmpty()) { + return + } + val service = view.context.getSystemService() ?: return + service.setPrimaryClip(ClipData.newPlainText(data.second, data.first)) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Snackbar.make(view, view.context.getString(R.string.copied_to_clipboard, data.second), Snackbar.LENGTH_LONG).show() + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt new file mode 100644 index 0000000..f78094b --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.kt @@ -0,0 +1,104 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.util + +import android.Manifest +import android.content.ContentValues +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.provider.MediaStore.MediaColumns +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import com.wireguard.android.R +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream + +class DownloadsFileSaver(private val context: ComponentActivity) { + private lateinit var activityResult: ActivityResultLauncher + private lateinit var futureGrant: CompletableDeferred + + init { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + futureGrant = CompletableDeferred() + activityResult = context.registerForActivityResult(ActivityResultContracts.RequestPermission()) { ret -> futureGrant.complete(ret) } + } + } + + suspend fun save(name: String, mimeType: String?, overwriteExisting: Boolean) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + withContext(Dispatchers.IO) { + val contentResolver = context.contentResolver + if (overwriteExisting) + contentResolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, String.format("%s = ?", MediaColumns.DISPLAY_NAME), arrayOf(name)) + val contentValues = ContentValues() + contentValues.put(MediaColumns.DISPLAY_NAME, name) + contentValues.put(MediaColumns.MIME_TYPE, mimeType) + val contentUri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + ?: throw IOException(context.getString(R.string.create_downloads_file_error)) + val contentStream = contentResolver.openOutputStream(contentUri) + ?: throw IOException(context.getString(R.string.create_downloads_file_error)) + @Suppress("DEPRECATION") var cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DATA), null, null, null) + var path: String? = null + if (cursor != null) { + try { + if (cursor.moveToFirst()) + path = cursor.getString(0) + } finally { + cursor.close() + } + } + if (path == null) { + path = "Download/" + cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DISPLAY_NAME), null, null, null) + if (cursor != null) { + try { + if (cursor.moveToFirst()) + path += cursor.getString(0) + } finally { + cursor.close() + } + } + } + DownloadsFile(context, contentStream, path, contentUri) + } + } else { + withContext(Dispatchers.Main.immediate) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + activityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + val granted = futureGrant.await() + if (!granted) { + futureGrant = CompletableDeferred() + return@withContext null + } + } + @Suppress("DEPRECATION") val path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + withContext(Dispatchers.IO) { + val file = File(path, name) + if (!path.isDirectory && !path.mkdirs()) + throw IOException(context.getString(R.string.create_output_dir_error)) + DownloadsFile(context, FileOutputStream(file), file.absolutePath, null) + } + } + } + + class DownloadsFile(private val context: Context, val outputStream: OutputStream, val fileName: String, private val uri: Uri?) { + suspend fun delete() = withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + context.contentResolver.delete(uri!!, null, null) + else + File(fileName).delete() + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt new file mode 100644 index 0000000..4157ebf --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.kt @@ -0,0 +1,158 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.util + +import android.content.res.Resources +import android.os.RemoteException +import com.google.zxing.ChecksumException +import com.google.zxing.NotFoundException +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.backend.BackendException +import com.wireguard.android.util.RootShell.RootShellException +import com.wireguard.config.BadConfigException +import com.wireguard.config.InetEndpoint +import com.wireguard.config.InetNetwork +import com.wireguard.config.ParseException +import com.wireguard.crypto.Key +import com.wireguard.crypto.KeyFormatException +import java.net.InetAddress + +object ErrorMessages { + private val BCE_REASON_MAP = mapOf( + BadConfigException.Reason.INVALID_KEY to R.string.bad_config_reason_invalid_key, + BadConfigException.Reason.INVALID_NUMBER to R.string.bad_config_reason_invalid_number, + BadConfigException.Reason.INVALID_VALUE to R.string.bad_config_reason_invalid_value, + BadConfigException.Reason.MISSING_ATTRIBUTE to R.string.bad_config_reason_missing_attribute, + BadConfigException.Reason.MISSING_SECTION to R.string.bad_config_reason_missing_section, + BadConfigException.Reason.SYNTAX_ERROR to R.string.bad_config_reason_syntax_error, + BadConfigException.Reason.UNKNOWN_ATTRIBUTE to R.string.bad_config_reason_unknown_attribute, + BadConfigException.Reason.UNKNOWN_SECTION to R.string.bad_config_reason_unknown_section + ) + private val BE_REASON_MAP = mapOf( + BackendException.Reason.UNKNOWN_KERNEL_MODULE_NAME to R.string.module_version_error, + BackendException.Reason.WG_QUICK_CONFIG_ERROR_CODE to R.string.tunnel_config_error, + BackendException.Reason.TUNNEL_MISSING_CONFIG to R.string.no_config_error, + BackendException.Reason.VPN_NOT_AUTHORIZED to R.string.vpn_not_authorized_error, + BackendException.Reason.UNABLE_TO_START_VPN to R.string.vpn_start_error, + BackendException.Reason.TUN_CREATION_ERROR to R.string.tun_create_error, + BackendException.Reason.GO_ACTIVATION_ERROR_CODE to R.string.tunnel_on_error, + BackendException.Reason.DNS_RESOLUTION_FAILURE to R.string.tunnel_dns_failure + ) + private val KFE_FORMAT_MAP = mapOf( + Key.Format.BASE64 to R.string.key_length_explanation_base64, + Key.Format.BINARY to R.string.key_length_explanation_binary, + Key.Format.HEX to R.string.key_length_explanation_hex + ) + private val KFE_TYPE_MAP = mapOf( + KeyFormatException.Type.CONTENTS to R.string.key_contents_error, + KeyFormatException.Type.LENGTH to R.string.key_length_error + ) + private val PE_CLASS_MAP = mapOf( + InetAddress::class.java to R.string.parse_error_inet_address, + InetEndpoint::class.java to R.string.parse_error_inet_endpoint, + InetNetwork::class.java to R.string.parse_error_inet_network, + Int::class.java to R.string.parse_error_integer + ) + private val RSE_REASON_MAP = mapOf( + RootShellException.Reason.NO_ROOT_ACCESS to R.string.error_root, + RootShellException.Reason.SHELL_MARKER_COUNT_ERROR to R.string.shell_marker_count_error, + RootShellException.Reason.SHELL_EXIT_STATUS_READ_ERROR to R.string.shell_exit_status_read_error, + RootShellException.Reason.SHELL_START_ERROR to R.string.shell_start_error, + RootShellException.Reason.CREATE_BIN_DIR_ERROR to R.string.create_bin_dir_error, + RootShellException.Reason.CREATE_TEMP_DIR_ERROR to R.string.create_temp_dir_error + ) + + operator fun get(throwable: Throwable?): String { + val resources = Application.get().resources + if (throwable == null) return resources.getString(R.string.unknown_error) + val rootCause = rootCause(throwable) + return when { + rootCause is BadConfigException -> { + val reason = getBadConfigExceptionReason(resources, rootCause) + val context = if (rootCause.location == BadConfigException.Location.TOP_LEVEL) { + resources.getString(R.string.bad_config_context_top_level, rootCause.section.getName()) + } else { + resources.getString(R.string.bad_config_context, rootCause.section.getName(), rootCause.location.getName()) + } + val explanation = getBadConfigExceptionExplanation(resources, rootCause) + resources.getString(R.string.bad_config_error, reason, context) + explanation + } + + rootCause is BackendException -> { + resources.getString(BE_REASON_MAP.getValue(rootCause.reason), *rootCause.format) + } + + rootCause is RootShellException -> { + resources.getString(RSE_REASON_MAP.getValue(rootCause.reason), *rootCause.format) + } + + rootCause is NotFoundException -> { + resources.getString(R.string.error_no_qr_found) + } + + rootCause is ChecksumException -> { + resources.getString(R.string.error_qr_checksum) + } + + rootCause.localizedMessage != null -> { + rootCause.localizedMessage!! + } + + else -> { + val errorType = rootCause.javaClass.simpleName + resources.getString(R.string.generic_error, errorType) + } + } + } + + private fun getBadConfigExceptionExplanation( + resources: Resources, + bce: BadConfigException + ): String { + if (bce.cause is KeyFormatException) { + val kfe = bce.cause as KeyFormatException? + if (kfe!!.type == KeyFormatException.Type.LENGTH) return resources.getString(KFE_FORMAT_MAP.getValue(kfe.format)) + } else if (bce.cause is ParseException) { + val pe = bce.cause as ParseException? + if (pe!!.localizedMessage != null) return ": ${pe.localizedMessage}" + } else if (bce.location == BadConfigException.Location.LISTEN_PORT) { + return resources.getString(R.string.bad_config_explanation_udp_port) + } else if (bce.location == BadConfigException.Location.MTU) { + return resources.getString(R.string.bad_config_explanation_positive_number) + } else if (bce.location == BadConfigException.Location.PERSISTENT_KEEPALIVE) { + return resources.getString(R.string.bad_config_explanation_pka) + } + return "" + } + + private fun getBadConfigExceptionReason( + resources: Resources, + bce: BadConfigException + ): String { + if (bce.cause is KeyFormatException) { + val kfe = bce.cause as KeyFormatException? + return resources.getString(KFE_TYPE_MAP.getValue(kfe!!.type)) + } else if (bce.cause is ParseException) { + val pe = bce.cause as ParseException? + val type = resources.getString((if (PE_CLASS_MAP.containsKey(pe!!.parsingClass)) PE_CLASS_MAP[pe.parsingClass] else R.string.parse_error_generic)!!) + return resources.getString(R.string.parse_error_reason, type, pe.text) + } + return resources.getString(BCE_REASON_MAP.getValue(bce.reason), bce.text) + } + + private fun rootCause(throwable: Throwable): Throwable { + var cause = throwable + while (cause.cause != null) { + if (cause is BadConfigException || cause is BackendException || + cause is RootShellException + ) break + val nextCause = cause.cause!! + if (nextCause is RemoteException) break + cause = nextCause + } + return cause + } +} 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 0000000..c4b4395 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/Extensions.kt @@ -0,0 +1,31 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util + +import android.content.Context +import android.util.TypedValue +import androidx.annotation.AttrRes +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import com.wireguard.android.Application +import com.wireguard.android.activity.SettingsActivity +import kotlinx.coroutines.CoroutineScope + +fun Context.resolveAttribute(@AttrRes attrRes: Int): Int { + val typedValue = TypedValue() + theme.resolveAttribute(attrRes, typedValue, true) + return typedValue.data +} + +val Any.applicationScope: CoroutineScope + get() = Application.getCoroutineScope() + +val Preference.activity: SettingsActivity + get() = context as? SettingsActivity + ?: throw IllegalStateException("Failed to resolve SettingsActivity") + +val Preference.lifecycleScope: CoroutineScope + get() = activity.lifecycleScope diff --git a/ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt b/ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt new file mode 100644 index 0000000..4ea2dc7 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/QrCodeFromFileScanner.kt @@ -0,0 +1,84 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.Reader +import com.google.zxing.Result +import com.google.zxing.common.HybridBinarizer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Encapsulates the logic of scanning a barcode from a file, + * @property contentResolver - Resolver to read the incoming data + * @property reader - An instance of zxing's [Reader] class to parse the image + */ +class QrCodeFromFileScanner( + private val contentResolver: ContentResolver, + private val reader: Reader, +) { + private fun scanBitmapForResult(source: Bitmap): Result { + val width = source.width + val height = source.height + val pixels = IntArray(width * height) + source.getPixels(pixels, 0, width, 0, 0, width, height) + + val bBitmap = BinaryBitmap(HybridBinarizer(RGBLuminanceSource(width, height, pixels))) + return reader.decode(bBitmap, mapOf(DecodeHintType.TRY_HARDER to true)) + } + + private fun doScan(data: Uri): Result { + Log.d(TAG, "Starting to scan an image: $data") + contentResolver.openInputStream(data).use { inputStream -> + var bitmap: Bitmap? = null + var firstException: Throwable? = null + for (i in arrayOf(1, 2, 4, 8, 16, 32, 64, 128)) { + try { + val options = BitmapFactory.Options() + options.inSampleSize = i + bitmap = BitmapFactory.decodeStream(inputStream, null, options) + ?: throw IllegalArgumentException("Can't decode stream for bitmap") + return scanBitmapForResult(bitmap) + } catch (e: Throwable) { + bitmap?.recycle() + System.gc() + Log.e(TAG, "Original image scan at scale factor $i finished with error: $e") + if (firstException == null) + firstException = e + } + } + throw Exception(firstException) + } + } + + /** + * Attempts to parse incoming data + * @return result of the decoding operation + * @throws NotFoundException when parser didn't find QR code in the image + */ + suspend fun scan(data: Uri) = withContext(Dispatchers.Default) { doScan(data) } + + companion object { + private const val TAG = "QrCodeFromFileScanner" + + /** + * Given a reference to a file, check if this file could be parsed by this class + * @return true if the file can be parsed, false if not + */ + fun validContentType(contentResolver: ContentResolver, data: Uri): Boolean { + return contentResolver.getType(data)?.startsWith("image/") == true + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/util/QuantityFormatter.kt b/ui/src/main/java/com/wireguard/android/util/QuantityFormatter.kt new file mode 100644 index 0000000..abac57c --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/QuantityFormatter.kt @@ -0,0 +1,63 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util + +import android.icu.text.ListFormatter +import android.icu.text.MeasureFormat +import android.icu.text.RelativeDateTimeFormatter +import android.icu.util.Measure +import android.icu.util.MeasureUnit +import android.os.Build +import com.wireguard.android.Application +import com.wireguard.android.R +import java.util.Locale +import kotlin.time.Duration.Companion.seconds + +object QuantityFormatter { + fun formatBytes(bytes: Long): String { + val context = Application.get().applicationContext + return when { + bytes < 1024 -> context.getString(R.string.transfer_bytes, bytes) + bytes < 1024 * 1024 -> context.getString(R.string.transfer_kibibytes, bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> context.getString(R.string.transfer_mibibytes, bytes / (1024.0 * 1024.0)) + bytes < 1024 * 1024 * 1024 * 1024L -> context.getString(R.string.transfer_gibibytes, bytes / (1024.0 * 1024.0 * 1024.0)) + else -> context.getString(R.string.transfer_tibibytes, bytes / (1024.0 * 1024.0 * 1024.0) / 1024.0) + } + } + + fun formatEpochAgo(epochMillis: Long): String { + var span = (System.currentTimeMillis() - epochMillis) / 1000 + + if (span <= 0L) + return RelativeDateTimeFormatter.getInstance().format(RelativeDateTimeFormatter.Direction.PLAIN, RelativeDateTimeFormatter.AbsoluteUnit.NOW) + val measureFormat = MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE) + val parts = ArrayList(4) + if (span >= 24 * 60 * 60L) { + val v = span / (24 * 60 * 60L) + parts.add(measureFormat.format(Measure(v, MeasureUnit.DAY))) + span -= v * (24 * 60 * 60L) + } + if (span >= 60 * 60L) { + val v = span / (60 * 60L) + parts.add(measureFormat.format(Measure(v, MeasureUnit.HOUR))) + span -= v * (60 * 60L) + } + if (span >= 60L) { + val v = span / 60L + parts.add(measureFormat.format(Measure(v, MeasureUnit.MINUTE))) + span -= v * 60L + } + if (span > 0L) + parts.add(measureFormat.format(Measure(span, MeasureUnit.SECOND))) + + val joined = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) + parts.joinToString() + else + ListFormatter.getInstance(Locale.getDefault(), ListFormatter.Type.UNITS, ListFormatter.Width.SHORT).format(parts) + + return Application.get().applicationContext.getString(R.string.latest_handshake_ago, joined) + } +} \ No newline at end of file diff --git a/ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt b/ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt new file mode 100644 index 0000000..18a37ef --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/TunnelImporter.kt @@ -0,0 +1,152 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import androidx.fragment.app.FragmentManager +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.fragment.ConfigNamingDialogFragment +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.config.Config +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +object TunnelImporter { + suspend fun importTunnel(contentResolver: ContentResolver, uri: Uri, messageCallback: (CharSequence) -> Unit) = withContext(Dispatchers.IO) { + val context = Application.get().applicationContext + val futureTunnels = ArrayList>() + val throwables = ArrayList() + try { + val columns = arrayOf(OpenableColumns.DISPLAY_NAME) + var name = "" + contentResolver.query(uri, columns, null, null, null)?.use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) { + name = cursor.getString(0) + } + } + if (name.isEmpty()) { + name = Uri.decode(uri.lastPathSegment) + } + var idx = name.lastIndexOf('/') + if (idx >= 0) { + require(idx < name.length - 1) { context.getString(R.string.illegal_filename_error, name) } + name = name.substring(idx + 1) + } + val isZip = name.lowercase().endsWith(".zip") + if (name.lowercase().endsWith(".conf")) { + name = name.substring(0, name.length - ".conf".length) + } else { + require(isZip) { context.getString(R.string.bad_extension_error) } + } + + if (isZip) { + ZipInputStream(contentResolver.openInputStream(uri)).use { zip -> + val reader = BufferedReader(InputStreamReader(zip, StandardCharsets.UTF_8)) + var entry: ZipEntry? + while (true) { + entry = zip.nextEntry ?: break + name = entry.name + idx = name.lastIndexOf('/') + if (idx >= 0) { + if (idx >= name.length - 1) { + continue + } + name = name.substring(name.lastIndexOf('/') + 1) + } + if (name.lowercase().endsWith(".conf")) { + name = name.substring(0, name.length - ".conf".length) + } else { + continue + } + try { + Config.parse(reader) + } catch (e: Throwable) { + throwables.add(e) + null + }?.let { + val nameCopy = name + futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(nameCopy, it) }) + } + } + } + } else { + futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(name, Config.parse(contentResolver.openInputStream(uri)!!)) }) + } + + if (futureTunnels.isEmpty()) { + if (throwables.size == 1) { + throw throwables[0] + } else { + require(throwables.isNotEmpty()) { context.getString(R.string.no_configs_error) } + } + } + val tunnels = futureTunnels.mapNotNull { + try { + it.await() + } catch (e: Throwable) { + throwables.add(e) + null + } + } + withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(tunnels, throwables, messageCallback) } + } catch (e: Throwable) { + withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(emptyList(), listOf(e), messageCallback) } + } + } + + fun importTunnel(parentFragmentManager: FragmentManager, configText: String, messageCallback: (CharSequence) -> Unit) { + try { + // Ensure the config text is parseable before proceeding… + Config.parse(ByteArrayInputStream(configText.toByteArray(StandardCharsets.UTF_8))) + + // Config text is valid, now create the tunnel… + ConfigNamingDialogFragment.newInstance(configText).show(parentFragmentManager, null) + } catch (e: Throwable) { + onTunnelImportFinished(emptyList(), listOf(e), messageCallback) + } + } + + private fun onTunnelImportFinished(tunnels: List, throwables: Collection, messageCallback: (CharSequence) -> Unit) { + val context = Application.get().applicationContext + var message = "" + for (throwable in throwables) { + val error = ErrorMessages[throwable] + message = context.getString(R.string.import_error, error) + Log.e(TAG, message, throwable) + } + if (tunnels.size == 1 && throwables.isEmpty()) + message = context.getString(R.string.import_success, tunnels[0].name) + else if (tunnels.isEmpty() && throwables.size == 1) + else if (throwables.isEmpty()) + message = context.resources.getQuantityString( + R.plurals.import_total_success, + tunnels.size, tunnels.size + ) + else if (!throwables.isEmpty()) + message = context.resources.getQuantityString( + R.plurals.import_partial_success, + tunnels.size + throwables.size, + tunnels.size, tunnels.size + throwables.size + ) + + messageCallback(message) + } + + private const val TAG = "WireGuard/TunnelImporter" +} \ No newline at end of file diff --git a/ui/src/main/java/com/wireguard/android/util/UserKnobs.kt b/ui/src/main/java/com/wireguard/android/util/UserKnobs.kt new file mode 100644 index 0000000..ca05173 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/UserKnobs.kt @@ -0,0 +1,121 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.wireguard.android.Application +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +object UserKnobs { + private val ENABLE_KERNEL_MODULE = booleanPreferencesKey("enable_kernel_module") + val enableKernelModule: Flow + get() = Application.getPreferencesDataStore().data.map { + it[ENABLE_KERNEL_MODULE] ?: false + } + + suspend fun setEnableKernelModule(enable: Boolean?) { + Application.getPreferencesDataStore().edit { + if (enable == null) + it.remove(ENABLE_KERNEL_MODULE) + else + it[ENABLE_KERNEL_MODULE] = enable + } + } + + private val MULTIPLE_TUNNELS = booleanPreferencesKey("multiple_tunnels") + val multipleTunnels: Flow + get() = Application.getPreferencesDataStore().data.map { + it[MULTIPLE_TUNNELS] ?: false + } + + private val DARK_THEME = booleanPreferencesKey("dark_theme") + val darkTheme: Flow + get() = Application.getPreferencesDataStore().data.map { + it[DARK_THEME] ?: false + } + + suspend fun setDarkTheme(on: Boolean) { + Application.getPreferencesDataStore().edit { + it[DARK_THEME] = on + } + } + + private val ALLOW_REMOTE_CONTROL_INTENTS = booleanPreferencesKey("allow_remote_control_intents") + val allowRemoteControlIntents: Flow + get() = Application.getPreferencesDataStore().data.map { + it[ALLOW_REMOTE_CONTROL_INTENTS] ?: false + } + + private val RESTORE_ON_BOOT = booleanPreferencesKey("restore_on_boot") + val restoreOnBoot: Flow + get() = Application.getPreferencesDataStore().data.map { + it[RESTORE_ON_BOOT] ?: false + } + + private val LAST_USED_TUNNEL = stringPreferencesKey("last_used_tunnel") + val lastUsedTunnel: Flow + get() = Application.getPreferencesDataStore().data.map { + it[LAST_USED_TUNNEL] + } + + suspend fun setLastUsedTunnel(lastUsedTunnel: String?) { + Application.getPreferencesDataStore().edit { + if (lastUsedTunnel == null) + it.remove(LAST_USED_TUNNEL) + else + it[LAST_USED_TUNNEL] = lastUsedTunnel + } + } + + private val RUNNING_TUNNELS = stringSetPreferencesKey("enabled_configs") + val runningTunnels: Flow> + get() = Application.getPreferencesDataStore().data.map { + it[RUNNING_TUNNELS] ?: emptySet() + } + + suspend fun setRunningTunnels(runningTunnels: Set) { + Application.getPreferencesDataStore().edit { + if (runningTunnels.isEmpty()) + it.remove(RUNNING_TUNNELS) + else + it[RUNNING_TUNNELS] = runningTunnels + } + } + + private val UPDATER_NEWER_VERSION_SEEN = stringPreferencesKey("updater_newer_version_seen") + val updaterNewerVersionSeen: Flow + get() = Application.getPreferencesDataStore().data.map { + it[UPDATER_NEWER_VERSION_SEEN] + } + + suspend fun setUpdaterNewerVersionSeen(newerVersionSeen: String?) { + Application.getPreferencesDataStore().edit { + if (newerVersionSeen == null) + it.remove(UPDATER_NEWER_VERSION_SEEN) + else + it[UPDATER_NEWER_VERSION_SEEN] = newerVersionSeen + } + } + + private val UPDATER_NEWER_VERSION_CONSENTED = stringPreferencesKey("updater_newer_version_consented") + val updaterNewerVersionConsented: Flow + get() = Application.getPreferencesDataStore().data.map { + it[UPDATER_NEWER_VERSION_CONSENTED] + } + + suspend fun setUpdaterNewerVersionConsented(newerVersionConsented: String?) { + Application.getPreferencesDataStore().edit { + if (newerVersionConsented == null) + it.remove(UPDATER_NEWER_VERSION_CONSENTED) + else + it[UPDATER_NEWER_VERSION_CONSENTED] = newerVersionConsented + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.kt b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.kt new file mode 100644 index 0000000..7f39b46 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.kt @@ -0,0 +1,86 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.viewmodel + +import android.os.Build +import android.os.Parcel +import android.os.Parcelable +import androidx.core.os.ParcelCompat +import androidx.databinding.ObservableArrayList +import androidx.databinding.ObservableList +import com.wireguard.config.BadConfigException +import com.wireguard.config.Config +import com.wireguard.config.Peer + +class ConfigProxy : Parcelable { + val `interface`: InterfaceProxy + val peers: ObservableList = ObservableArrayList() + + private constructor(parcel: Parcel) { + `interface` = ParcelCompat.readParcelable(parcel, InterfaceProxy::class.java.classLoader, InterfaceProxy::class.java) ?: InterfaceProxy() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ParcelCompat.readParcelableList(parcel, peers, PeerProxy::class.java.classLoader, PeerProxy::class.java) + } else { + parcel.readTypedList(peers, PeerProxy.CREATOR) + } + peers.forEach { it.bind(this) } + } + + constructor(other: Config) { + `interface` = InterfaceProxy(other.getInterface()) + other.peers.forEach { + val proxy = PeerProxy(it) + peers.add(proxy) + proxy.bind(this) + } + } + + constructor() { + `interface` = InterfaceProxy() + } + + fun addPeer(): PeerProxy { + val proxy = PeerProxy() + peers.add(proxy) + proxy.bind(this) + return proxy + } + + override fun describeContents() = 0 + + @Throws(BadConfigException::class) + fun resolve(): Config { + val resolvedPeers: MutableCollection = ArrayList() + peers.forEach { resolvedPeers.add(it.resolve()) } + return Config.Builder() + .setInterface(`interface`.resolve()) + .addPeers(resolvedPeers) + .build() + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(`interface`, flags) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + dest.writeParcelableList(peers, flags) + } else { + dest.writeTypedList(peers) + } + } + + private class ConfigProxyCreator : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ConfigProxy { + return ConfigProxy(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = ConfigProxyCreator() + } +} diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.kt b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.kt new file mode 100644 index 0000000..25c2fd1 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.kt @@ -0,0 +1,142 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.viewmodel + +import android.os.Parcel +import android.os.Parcelable +import androidx.databinding.BaseObservable +import androidx.databinding.Bindable +import androidx.databinding.ObservableArrayList +import androidx.databinding.ObservableList +import com.wireguard.android.BR +import com.wireguard.config.Attribute +import com.wireguard.config.BadConfigException +import com.wireguard.config.Interface +import com.wireguard.crypto.Key +import com.wireguard.crypto.KeyFormatException +import com.wireguard.crypto.KeyPair + +class InterfaceProxy : BaseObservable, Parcelable { + @get:Bindable + val excludedApplications: ObservableList = ObservableArrayList() + + @get:Bindable + val includedApplications: ObservableList = ObservableArrayList() + + @get:Bindable + var addresses: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.addresses) + } + + @get:Bindable + var dnsServers: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.dnsServers) + } + + @get:Bindable + var listenPort: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.listenPort) + } + + @get:Bindable + var mtu: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.mtu) + } + + @get:Bindable + var privateKey: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.privateKey) + notifyPropertyChanged(BR.publicKey) + } + + @get:Bindable + val publicKey: String + get() = try { + KeyPair(Key.fromBase64(privateKey)).publicKey.toBase64() + } catch (ignored: KeyFormatException) { + "" + } + + private constructor(parcel: Parcel) { + addresses = parcel.readString() ?: "" + dnsServers = parcel.readString() ?: "" + parcel.readStringList(excludedApplications) + parcel.readStringList(includedApplications) + listenPort = parcel.readString() ?: "" + mtu = parcel.readString() ?: "" + privateKey = parcel.readString() ?: "" + } + + constructor(other: Interface) { + addresses = Attribute.join(other.addresses) + val dnsServerStrings = other.dnsServers.map { it.hostAddress }.plus(other.dnsSearchDomains) + dnsServers = Attribute.join(dnsServerStrings) + excludedApplications.addAll(other.excludedApplications) + includedApplications.addAll(other.includedApplications) + listenPort = other.listenPort.map { it.toString() }.orElse("") + mtu = other.mtu.map { it.toString() }.orElse("") + val keyPair = other.keyPair + privateKey = keyPair.privateKey.toBase64() + } + + constructor() + + override fun describeContents() = 0 + + fun generateKeyPair() { + val keyPair = KeyPair() + privateKey = keyPair.privateKey.toBase64() + notifyPropertyChanged(BR.privateKey) + notifyPropertyChanged(BR.publicKey) + } + + @Throws(BadConfigException::class) + fun resolve(): Interface { + val builder = Interface.Builder() + if (addresses.isNotEmpty()) builder.parseAddresses(addresses) + if (dnsServers.isNotEmpty()) builder.parseDnsServers(dnsServers) + if (excludedApplications.isNotEmpty()) builder.excludeApplications(excludedApplications) + if (includedApplications.isNotEmpty()) builder.includeApplications(includedApplications) + if (listenPort.isNotEmpty()) builder.parseListenPort(listenPort) + if (mtu.isNotEmpty()) builder.parseMtu(mtu) + if (privateKey.isNotEmpty()) builder.parsePrivateKey(privateKey) + return builder.build() + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(addresses) + dest.writeString(dnsServers) + dest.writeStringList(excludedApplications) + dest.writeStringList(includedApplications) + dest.writeString(listenPort) + dest.writeString(mtu) + dest.writeString(privateKey) + } + + private class InterfaceProxyCreator : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): InterfaceProxy { + return InterfaceProxy(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = InterfaceProxyCreator() + } +} diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt new file mode 100644 index 0000000..15bf8a0 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.kt @@ -0,0 +1,294 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.viewmodel + +import android.os.Parcel +import android.os.Parcelable +import androidx.databinding.BaseObservable +import androidx.databinding.Bindable +import androidx.databinding.Observable +import androidx.databinding.Observable.OnPropertyChangedCallback +import androidx.databinding.ObservableList +import com.wireguard.android.BR +import com.wireguard.config.Attribute +import com.wireguard.config.BadConfigException +import com.wireguard.config.Peer +import java.lang.ref.WeakReference + +class PeerProxy : BaseObservable, Parcelable { + private val dnsRoutes: MutableList = ArrayList() + private var allowedIpsState = AllowedIpsState.INVALID + private var interfaceDnsListener: InterfaceDnsListener? = null + private var peerListListener: PeerListListener? = null + private var owner: ConfigProxy? = null + private var totalPeers = 0 + + @get:Bindable + var allowedIps: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.allowedIps) + calculateAllowedIpsState() + } + + @get:Bindable + var endpoint: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.endpoint) + } + + @get:Bindable + var persistentKeepalive: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.persistentKeepalive) + } + + @get:Bindable + var preSharedKey: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.preSharedKey) + } + + @get:Bindable + var publicKey: String = "" + set(value) { + field = value + notifyPropertyChanged(BR.publicKey) + } + + @get:Bindable + val isAbleToExcludePrivateIps: Boolean + get() = allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS || allowedIpsState == AllowedIpsState.CONTAINS_IPV4_WILDCARD + + @get:Bindable + val isExcludingPrivateIps: Boolean + get() = allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS + + private constructor(parcel: Parcel) { + allowedIps = parcel.readString() ?: "" + endpoint = parcel.readString() ?: "" + persistentKeepalive = parcel.readString() ?: "" + preSharedKey = parcel.readString() ?: "" + publicKey = parcel.readString() ?: "" + } + + constructor(other: Peer) { + allowedIps = Attribute.join(other.allowedIps) + endpoint = other.endpoint.map { it.toString() }.orElse("") + persistentKeepalive = other.persistentKeepalive.map { it.toString() }.orElse("") + preSharedKey = other.preSharedKey.map { it.toBase64() }.orElse("") + publicKey = other.publicKey.toBase64() + } + + constructor() + + fun bind(owner: ConfigProxy) { + val interfaze: InterfaceProxy = owner.`interface` + val peers = owner.peers + if (interfaceDnsListener == null) interfaceDnsListener = InterfaceDnsListener(this) + interfaze.addOnPropertyChangedCallback(interfaceDnsListener!!) + setInterfaceDns(interfaze.dnsServers) + if (peerListListener == null) peerListListener = PeerListListener(this) + peers.addOnListChangedCallback(peerListListener) + setTotalPeers(peers.size) + this.owner = owner + } + + private fun calculateAllowedIpsState() { + val newState: AllowedIpsState + newState = if (totalPeers == 1) { + // String comparison works because we only care if allowedIps is a superset of one of + // the above sets of (valid) *networks*. We are not checking for a superset based on + // the individual addresses in each set. + val networkStrings: Collection = getAllowedIpsSet() + // If allowedIps contains both the wildcard and the public networks, then private + // networks aren't excluded! + if (networkStrings.containsAll(IPV4_WILDCARD)) + AllowedIpsState.CONTAINS_IPV4_WILDCARD + else if (networkStrings.containsAll(IPV4_PUBLIC_NETWORKS)) + AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS + else + AllowedIpsState.OTHER + } else { + AllowedIpsState.INVALID + } + if (newState != allowedIpsState) { + allowedIpsState = newState + notifyPropertyChanged(BR.ableToExcludePrivateIps) + notifyPropertyChanged(BR.excludingPrivateIps) + } + } + + override fun describeContents() = 0 + + private fun getAllowedIpsSet() = setOf(*Attribute.split(allowedIps)) + + // Replace the first instance of the wildcard with the public network list, or vice versa. + // DNS servers only need to handled specially when we're excluding private IPs. + fun setExcludingPrivateIps(excludingPrivateIps: Boolean) { + if (!isAbleToExcludePrivateIps || isExcludingPrivateIps == excludingPrivateIps) return + val oldNetworks = if (excludingPrivateIps) IPV4_WILDCARD else IPV4_PUBLIC_NETWORKS + val newNetworks = if (excludingPrivateIps) IPV4_PUBLIC_NETWORKS else IPV4_WILDCARD + val input: Collection = getAllowedIpsSet() + val outputSize = input.size - oldNetworks.size + newNetworks.size + val output: MutableCollection = LinkedHashSet(outputSize) + var replaced = false + // Replace the first instance of the wildcard with the public network list, or vice versa. + for (network in input) { + if (oldNetworks.contains(network)) { + if (!replaced) { + for (replacement in newNetworks) if (!output.contains(replacement)) output.add(replacement) + replaced = true + } + } else if (!output.contains(network)) { + output.add(network) + } + } + // DNS servers only need to handled specially when we're excluding private IPs. + if (excludingPrivateIps) output.addAll(dnsRoutes) else output.removeAll(dnsRoutes) + allowedIps = Attribute.join(output) + allowedIpsState = if (excludingPrivateIps) AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS else AllowedIpsState.CONTAINS_IPV4_WILDCARD + notifyPropertyChanged(BR.allowedIps) + notifyPropertyChanged(BR.excludingPrivateIps) + } + + @Throws(BadConfigException::class) + fun resolve(): Peer { + val builder = Peer.Builder() + if (allowedIps.isNotEmpty()) builder.parseAllowedIPs(allowedIps) + if (endpoint.isNotEmpty()) builder.parseEndpoint(endpoint) + if (persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(persistentKeepalive) + if (preSharedKey.isNotEmpty()) builder.parsePreSharedKey(preSharedKey) + if (publicKey.isNotEmpty()) builder.parsePublicKey(publicKey) + return builder.build() + } + + private fun setInterfaceDns(dnsServers: CharSequence) { + val newDnsRoutes = Attribute.split(dnsServers).filter { !it.contains(":") }.map { "$it/32" } + if (allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS) { + val input = getAllowedIpsSet() + // Yes, this is quadratic in the number of DNS servers, but most users have 1 or 2. + val output = input.filter { !dnsRoutes.contains(it) || newDnsRoutes.contains(it) }.plus(newDnsRoutes).distinct() + // None of the public networks are /32s, so this cannot change the AllowedIPs state. + allowedIps = Attribute.join(output) + notifyPropertyChanged(BR.allowedIps) + } + dnsRoutes.clear() + dnsRoutes.addAll(newDnsRoutes) + } + + private fun setTotalPeers(totalPeers: Int) { + if (this.totalPeers == totalPeers) return + this.totalPeers = totalPeers + calculateAllowedIpsState() + } + + fun unbind() { + if (owner == null) return + val interfaze: InterfaceProxy = owner!!.`interface` + val peers = owner!!.peers + if (interfaceDnsListener != null) interfaze.removeOnPropertyChangedCallback(interfaceDnsListener!!) + if (peerListListener != null) peers.removeOnListChangedCallback(peerListListener) + peers.remove(this) + setInterfaceDns("") + setTotalPeers(0) + owner = null + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(allowedIps) + dest.writeString(endpoint) + dest.writeString(persistentKeepalive) + dest.writeString(preSharedKey) + dest.writeString(publicKey) + } + + private enum class AllowedIpsState { + CONTAINS_IPV4_PUBLIC_NETWORKS, CONTAINS_IPV4_WILDCARD, INVALID, OTHER + } + + private class InterfaceDnsListener constructor(peerProxy: PeerProxy) : OnPropertyChangedCallback() { + private val weakPeerProxy: WeakReference = WeakReference(peerProxy) + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + val peerProxy = weakPeerProxy.get() + if (peerProxy == null) { + sender.removeOnPropertyChangedCallback(this) + return + } + // This shouldn't be possible, but try to avoid a ClassCastException anyway. + if (sender !is InterfaceProxy) return + if (!(propertyId == BR._all || propertyId == BR.dnsServers)) return + peerProxy.setInterfaceDns(sender.dnsServers) + } + } + + private class PeerListListener(peerProxy: PeerProxy) : ObservableList.OnListChangedCallback>() { + private val weakPeerProxy: WeakReference = WeakReference(peerProxy) + override fun onChanged(sender: ObservableList) { + val peerProxy = weakPeerProxy.get() + if (peerProxy == null) { + sender.removeOnListChangedCallback(this) + return + } + peerProxy.setTotalPeers(sender.size) + } + + override fun onItemRangeChanged( + sender: ObservableList, + positionStart: Int, itemCount: Int + ) { + // Do nothing. + } + + override fun onItemRangeInserted( + sender: ObservableList, + positionStart: Int, itemCount: Int + ) { + onChanged(sender) + } + + override fun onItemRangeMoved( + sender: ObservableList, + fromPosition: Int, toPosition: Int, + itemCount: Int + ) { + // Do nothing. + } + + override fun onItemRangeRemoved( + sender: ObservableList, + positionStart: Int, itemCount: Int + ) { + onChanged(sender) + } + } + + private class PeerProxyCreator : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): PeerProxy { + return PeerProxy(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = PeerProxyCreator() + private val IPV4_PUBLIC_NETWORKS = setOf( + "0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", + "64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", + "172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", + "176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", + "192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", + "193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4" + ) + private val IPV4_WILDCARD = setOf("0.0.0.0/0") + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.kt b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.kt new file mode 100644 index 0000000..548760c --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.kt @@ -0,0 +1,49 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.widget + +import android.text.InputFilter +import android.text.SpannableStringBuilder +import android.text.Spanned +import com.wireguard.crypto.Key + +/** + * InputFilter for entering WireGuard private/public keys encoded with base64. + */ +class KeyInputFilter : InputFilter { + override fun filter( + source: CharSequence, + sStart: Int, sEnd: Int, + dest: Spanned, + dStart: Int, dEnd: Int + ): CharSequence? { + var replacement: SpannableStringBuilder? = null + var rIndex = 0 + val dLength = dest.length + for (sIndex in sStart until sEnd) { + val c = source[sIndex] + val dIndex = dStart + (sIndex - sStart) + // Restrict characters to the base64 character set. + // Ensure adding this character does not push the length over the limit. + if ((dIndex + 1 < Key.Format.BASE64.length && isAllowed(c) || + dIndex + 1 == Key.Format.BASE64.length && c == '=') && + dLength + (sIndex - sStart) < Key.Format.BASE64.length + ) { + ++rIndex + } else { + if (replacement == null) replacement = SpannableStringBuilder(source, sStart, sEnd) + replacement.delete(rIndex, rIndex + 1) + } + } + return replacement + } + + companion object { + private fun isAllowed(c: Char) = Character.isLetterOrDigit(c) || c == '+' || c == '/' + + @JvmStatic + fun newInstance() = KeyInputFilter() + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.kt b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.kt new file mode 100644 index 0000000..9b3ec40 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.kt @@ -0,0 +1,49 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.RelativeLayout +import com.wireguard.android.R + +class MultiselectableRelativeLayout @JvmOverloads constructor( + context: Context? = null, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : RelativeLayout(context, attrs, defStyleAttr, defStyleRes) { + private var multiselected = false + + override fun onCreateDrawableState(extraSpace: Int): IntArray { + if (multiselected) { + val drawableState = super.onCreateDrawableState(extraSpace + 1) + View.mergeDrawableStates(drawableState, STATE_MULTISELECTED) + return drawableState + } + return super.onCreateDrawableState(extraSpace) + } + + fun setMultiSelected(on: Boolean) { + if (!multiselected) { + multiselected = true + refreshDrawableState() + } + isActivated = on + } + + fun setSingleSelected(on: Boolean) { + if (multiselected) { + multiselected = false + refreshDrawableState() + } + isActivated = on + } + + companion object { + private val STATE_MULTISELECTED = intArrayOf(R.attr.state_multiselected) + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt new file mode 100644 index 0000000..93b77ba --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt @@ -0,0 +1,48 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.widget + +import android.text.InputFilter +import android.text.SpannableStringBuilder +import android.text.Spanned +import com.wireguard.android.backend.Tunnel + +/** + * InputFilter for entering WireGuard configuration names (Linux interface names). + */ +class NameInputFilter : InputFilter { + override fun filter( + source: CharSequence, + sStart: Int, sEnd: Int, + dest: Spanned, + dStart: Int, dEnd: Int + ): CharSequence? { + var replacement: SpannableStringBuilder? = null + var rIndex = 0 + val dLength = dest.length + for (sIndex in sStart until sEnd) { + val c = source[sIndex] + val dIndex = dStart + (sIndex - sStart) + // Restrict characters to those valid in interfaces. + // Ensure adding this character does not push the length over the limit. + if (dIndex < Tunnel.NAME_MAX_LENGTH && isAllowed(c) && + dLength + (sIndex - sStart) < Tunnel.NAME_MAX_LENGTH + ) { + ++rIndex + } else { + if (replacement == null) replacement = SpannableStringBuilder(source, sStart, sEnd) + replacement.delete(rIndex, rIndex + 1) + } + } + return replacement + } + + companion object { + private fun isAllowed(c: Char) = Character.isLetterOrDigit(c) || "_=+.-".indexOf(c) >= 0 + + @JvmStatic + fun newInstance() = NameInputFilter() + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt new file mode 100644 index 0000000..c69e6d8 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.kt @@ -0,0 +1,175 @@ +/* + * Copyright © 2018 The Android Open Source Project + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.widget + +import android.animation.ObjectAnimator +import android.content.res.ColorStateList +import android.graphics.* +import android.graphics.drawable.Drawable +import android.os.Build +import android.util.FloatProperty +import androidx.annotation.ColorInt +import androidx.annotation.IntRange +import androidx.annotation.RequiresApi + +class SlashDrawable(private val mDrawable: Drawable) : Drawable() { + private val mPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val mPath = Path() + private val mSlashRect = RectF() + private var mAnimationEnabled = true + + // Animate this value on change + private var mCurrentSlashLength = 0f + private var mRotation = 0f + private var mSlashed = false + + override fun draw(canvas: Canvas) { + canvas.save() + val m = Matrix() + val width = bounds.width() + val height = bounds.height() + val radiusX = scale(CORNER_RADIUS, width) + val radiusY = scale(CORNER_RADIUS, height) + updateRect( + scale(LEFT, width), + scale(TOP, height), + scale(RIGHT, width), + scale(TOP + mCurrentSlashLength, height) + ) + mPath.reset() + // Draw the slash vertically + mPath.addRoundRect(mSlashRect, radiusX, radiusY, Path.Direction.CW) + // Rotate -45 + desired rotation + m.setRotate(mRotation + DEFAULT_ROTATION, width / 2f, height / 2f) + mPath.transform(m) + canvas.drawPath(mPath, mPaint) + + // Rotate back to vertical + m.setRotate(-mRotation - DEFAULT_ROTATION, width / 2f, height / 2f) + mPath.transform(m) + + // Draw another rect right next to the first, for clipping + m.setTranslate(mSlashRect.width(), 0f) + mPath.transform(m) + mPath.addRoundRect(mSlashRect, 1f * width, 1f * height, Path.Direction.CW) + m.setRotate(mRotation + DEFAULT_ROTATION, width / 2f, height / 2f) + mPath.transform(m) + @Suppress("DEPRECATION") + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + canvas.clipPath(mPath, Region.Op.DIFFERENCE) else canvas.clipOutPath(mPath) + mDrawable.draw(canvas) + canvas.restore() + } + + override fun getIntrinsicHeight() = mDrawable.intrinsicHeight + + override fun getIntrinsicWidth() = mDrawable.intrinsicWidth + + @Deprecated("Deprecated in API level 29") + override fun getOpacity() = PixelFormat.OPAQUE + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + mDrawable.bounds = bounds + } + + private fun scale(frac: Float, width: Int) = frac * width + + override fun setAlpha(@IntRange(from = 0, to = 255) alpha: Int) { + mDrawable.alpha = alpha + mPaint.alpha = alpha + } + + fun setAnimationEnabled(enabled: Boolean) { + mAnimationEnabled = enabled + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + mDrawable.colorFilter = colorFilter + mPaint.colorFilter = colorFilter + } + + private fun setDrawableTintList(tint: ColorStateList?) { + mDrawable.setTintList(tint) + } + + fun setRotation(rotation: Float) { + if (mRotation == rotation) return + mRotation = rotation + invalidateSelf() + } + + fun setSlashed(slashed: Boolean) { + if (mSlashed == slashed) return + mSlashed = slashed + val end = if (mSlashed) SLASH_HEIGHT / SCALE else 0f + val start = if (mSlashed) 0f else SLASH_HEIGHT / SCALE + if (mAnimationEnabled) { + val anim = ObjectAnimator.ofFloat(this, mSlashLengthProp, start, end) + anim.addUpdateListener { _ -> invalidateSelf() } + anim.duration = QS_ANIM_LENGTH + anim.start() + } else { + mCurrentSlashLength = end + invalidateSelf() + } + } + + override fun setTint(@ColorInt tintColor: Int) { + super.setTint(tintColor) + mDrawable.setTint(tintColor) + mPaint.color = tintColor + } + + override fun setTintList(tint: ColorStateList?) { + super.setTintList(tint) + setDrawableTintList(tint) + mPaint.color = tint?.defaultColor ?: 0 + invalidateSelf() + } + + override fun setTintMode(tintMode: PorterDuff.Mode?) { + super.setTintMode(tintMode) + mDrawable.setTintMode(tintMode) + } + + private fun updateRect(left: Float, top: Float, right: Float, bottom: Float) { + mSlashRect.left = left + mSlashRect.top = top + mSlashRect.right = right + mSlashRect.bottom = bottom + } + + companion object { + private const val CENTER_X = 10.65f + private const val CENTER_Y = 11.869239f + private val CORNER_RADIUS = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) 0f else 1f + + // Draw the slash washington-monument style; rotate to no-u-turn style + private const val DEFAULT_ROTATION = -45f + private const val QS_ANIM_LENGTH: Long = 350 + private const val SCALE = 24f + private const val SLASH_HEIGHT = 28f + + // These values are derived in un-rotated (vertical) orientation + private const val SLASH_WIDTH = 1.8384776f + + // Bottom is derived during animation + private const val LEFT = (CENTER_X - SLASH_WIDTH / 2) / SCALE + private const val RIGHT = (CENTER_X + SLASH_WIDTH / 2) / SCALE + private const val TOP = (CENTER_Y - SLASH_HEIGHT / 2) / SCALE + private val mSlashLengthProp: FloatProperty = object : FloatProperty("slashLength") { + override fun get(obj: SlashDrawable): Float { + return obj.mCurrentSlashLength + } + + override fun setValue(obj: SlashDrawable, value: Float) { + obj.mCurrentSlashLength = value + } + } + } + +} diff --git a/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.kt b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.kt new file mode 100644 index 0000000..9b79706 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.kt @@ -0,0 +1,44 @@ +/* + * Copyright © 2013 The Android Open Source Project + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.widget + +import android.content.Context +import android.os.Parcelable +import android.util.AttributeSet +import com.google.android.material.materialswitch.MaterialSwitch + +class ToggleSwitch @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : MaterialSwitch(context, attrs) { + private var isRestoringState = false + private var listener: OnBeforeCheckedChangeListener? = null + override fun onRestoreInstanceState(state: Parcelable) { + isRestoringState = true + super.onRestoreInstanceState(state) + isRestoringState = false + } + + override fun setChecked(checked: Boolean) { + if (checked == isChecked) return + if (isRestoringState || listener == null) { + super.setChecked(checked) + return + } + isEnabled = false + listener!!.onBeforeCheckedChanged(this, checked) + } + + fun setCheckedInternal(checked: Boolean) { + super.setChecked(checked) + isEnabled = true + } + + fun setOnBeforeCheckedChangeListener(listener: OnBeforeCheckedChangeListener?) { + this.listener = listener + } + + interface OnBeforeCheckedChangeListener { + fun onBeforeCheckedChanged(toggleSwitch: ToggleSwitch?, checked: Boolean) + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/TvCardView.kt b/ui/src/main/java/com/wireguard/android/widget/TvCardView.kt new file mode 100644 index 0000000..de30131 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/widget/TvCardView.kt @@ -0,0 +1,44 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import com.google.android.material.card.MaterialCardView +import com.wireguard.android.R + +class TvCardView(context: Context?, attrs: AttributeSet?) : MaterialCardView(context, attrs) { + var isUp: Boolean = false + set(value) { + field = value + refreshDrawableState() + } + var isDeleting: Boolean = false + set(value) { + field = value + refreshDrawableState() + } + + override fun onCreateDrawableState(extraSpace: Int): IntArray { + if (isUp || isDeleting) { + val drawableState = super.onCreateDrawableState(extraSpace + (if (isUp) 1 else 0) + (if (isDeleting) 1 else 0)) + if (isUp) { + View.mergeDrawableStates(drawableState, STATE_IS_UP) + } + if (isDeleting) { + View.mergeDrawableStates(drawableState, STATE_IS_DELETING) + } + return drawableState + } + return super.onCreateDrawableState(extraSpace) + } + + companion object { + private val STATE_IS_UP = intArrayOf(R.attr.state_isUp) + private val STATE_IS_DELETING = intArrayOf(R.attr.state_isDeleting) + } +} \ No newline at end of file diff --git a/ui/src/main/res/anim/scale_down.xml b/ui/src/main/res/anim/scale_down.xml new file mode 100644 index 0000000..76e7386 --- /dev/null +++ b/ui/src/main/res/anim/scale_down.xml @@ -0,0 +1,15 @@ + + + + diff --git a/ui/src/main/res/anim/scale_up.xml b/ui/src/main/res/anim/scale_up.xml new file mode 100644 index 0000000..044975c --- /dev/null +++ b/ui/src/main/res/anim/scale_up.xml @@ -0,0 +1,15 @@ + + + + diff --git a/ui/src/main/res/color/tv_list_item_tint.xml b/ui/src/main/res/color/tv_list_item_tint.xml new file mode 100644 index 0000000..8528cc3 --- /dev/null +++ b/ui/src/main/res/color/tv_list_item_tint.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file 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 0000000..438a5c9 --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_add_white.xml @@ -0,0 +1,14 @@ + + + + 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 0000000..d04a98b --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_delete.xml @@ -0,0 +1,14 @@ + + + + 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 0000000..a8acd95 --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_edit.xml @@ -0,0 +1,14 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_action_generate.xml b/ui/src/main/res/drawable/ic_action_generate.xml new file mode 100644 index 0000000..bf26813 --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_generate.xml @@ -0,0 +1,14 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_action_open.xml b/ui/src/main/res/drawable/ic_action_open.xml new file mode 100644 index 0000000..d689b9d --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_open.xml @@ -0,0 +1,14 @@ + + + + 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 0000000..5851624 --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_save.xml @@ -0,0 +1,14 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_action_scan_qr_code.xml b/ui/src/main/res/drawable/ic_action_scan_qr_code.xml new file mode 100644 index 0000000..98c446d --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_scan_qr_code.xml @@ -0,0 +1,14 @@ + + + + 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 0000000..7c0c526 --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_select_all.xml @@ -0,0 +1,14 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_action_share_white.xml b/ui/src/main/res/drawable/ic_action_share_white.xml new file mode 100644 index 0000000..34d88f2 --- /dev/null +++ b/ui/src/main/res/drawable/ic_action_share_white.xml @@ -0,0 +1,14 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_arrow_back.xml b/ui/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..8f84762 --- /dev/null +++ b/ui/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,14 @@ + + + + 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 0000000..044d2e0 --- /dev/null +++ b/ui/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + 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 0000000..4fe131c --- /dev/null +++ b/ui/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,14 @@ + + + + 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 0000000..a8a3058 --- /dev/null +++ b/ui/src/main/res/drawable/ic_tile.xml @@ -0,0 +1,28 @@ + + + + + + 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 0000000..cb540dd --- /dev/null +++ b/ui/src/main/res/drawable/list_item_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/tv_logo_banner.xml b/ui/src/main/res/drawable/tv_logo_banner.xml new file mode 100644 index 0000000..ef61caf --- /dev/null +++ b/ui/src/main/res/drawable/tv_logo_banner.xml @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..ef1d405 --- /dev/null +++ b/ui/src/main/res/layout-sw600dp/main_activity.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + 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 0000000..0ad1ef2 --- /dev/null +++ b/ui/src/main/res/layout/add_tunnels_bottom_sheet.xml @@ -0,0 +1,81 @@ + + + + + + + + + + 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 0000000..98ee2b0 --- /dev/null +++ b/ui/src/main/res/layout/app_list_dialog_fragment.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..63a43ad --- /dev/null +++ b/ui/src/main/res/layout/app_list_item.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..32d556a --- /dev/null +++ b/ui/src/main/res/layout/config_naming_dialog_fragment.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/layout/log_viewer_activity.xml b/ui/src/main/res/layout/log_viewer_activity.xml new file mode 100644 index 0000000..15925c0 --- /dev/null +++ b/ui/src/main/res/layout/log_viewer_activity.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/ui/src/main/res/layout/log_viewer_entry.xml b/ui/src/main/res/layout/log_viewer_entry.xml new file mode 100644 index 0000000..3df73b3 --- /dev/null +++ b/ui/src/main/res/layout/log_viewer_entry.xml @@ -0,0 +1,32 @@ + + + + + + + + + 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 0000000..ab3b7e6 --- /dev/null +++ b/ui/src/main/res/layout/main_activity.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/ui/src/main/res/layout/tunnel_creator_activity.xml b/ui/src/main/res/layout/tunnel_creator_activity.xml new file mode 100644 index 0000000..82273db --- /dev/null +++ b/ui/src/main/res/layout/tunnel_creator_activity.xml @@ -0,0 +1,18 @@ + + + + + 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 0000000..aae3e39 --- /dev/null +++ b/ui/src/main/res/layout/tunnel_detail_fragment.xml @@ -0,0 +1,324 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..3cba9f0 --- /dev/null +++ b/ui/src/main/res/layout/tunnel_detail_peer.xml @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..f25d283 --- /dev/null +++ b/ui/src/main/res/layout/tunnel_editor_fragment.xml @@ -0,0 +1,295 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..b879c0d --- /dev/null +++ b/ui/src/main/res/layout/tunnel_editor_peer.xml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..1786078 --- /dev/null +++ b/ui/src/main/res/layout/tunnel_list_fragment.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..2b5ecec --- /dev/null +++ b/ui/src/main/res/layout/tunnel_list_item.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/layout/tv_activity.xml b/ui/src/main/res/layout/tv_activity.xml new file mode 100644 index 0000000..f42808b --- /dev/null +++ b/ui/src/main/res/layout/tv_activity.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/layout/tv_file_list_item.xml b/ui/src/main/res/layout/tv_file_list_item.xml new file mode 100644 index 0000000..84a3a43 --- /dev/null +++ b/ui/src/main/res/layout/tv_file_list_item.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/layout/tv_tunnel_list_item.xml b/ui/src/main/res/layout/tv_tunnel_list_item.xml new file mode 100644 index 0000000..08336e0 --- /dev/null +++ b/ui/src/main/res/layout/tv_tunnel_list_item.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..1d847c9 --- /dev/null +++ b/ui/src/main/res/menu/config_editor.xml @@ -0,0 +1,13 @@ + +

+ + diff --git a/ui/src/main/res/menu/log_viewer.xml b/ui/src/main/res/menu/log_viewer.xml new file mode 100644 index 0000000..925a397 --- /dev/null +++ b/ui/src/main/res/menu/log_viewer.xml @@ -0,0 +1,12 @@ + + + + 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 0000000..d9032e1 --- /dev/null +++ b/ui/src/main/res/menu/main_activity.xml @@ -0,0 +1,14 @@ + + + + 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 0000000..a564ecc --- /dev/null +++ b/ui/src/main/res/menu/tunnel_detail.xml @@ -0,0 +1,13 @@ + + + + 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 0000000..76b403b --- /dev/null +++ b/ui/src/main/res/menu/tunnel_list_action_mode.xml @@ -0,0 +1,19 @@ + + + + + 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 0000000..e6dd195 --- /dev/null +++ b/ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + 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 0000000..e6dd195 --- /dev/null +++ b/ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,9 @@ + + + + + + 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 0000000000000000000000000000000000000000..45daa9a1e39f9d117b4c510ffdd8fe011bf6c07e GIT binary patch literal 4990 zcmV-^6M^iBP)5SD%Bg08Z5d@CVRex!SJ!uXtab(rGg=~ z=73KqE_Sil;AF6_Qg2f!82$G@F#OlwVB{Zv!ORvdVe^DvjlAINC3=O0#3^k{@^Mq_f&Xs+p3 zsHgeFl8A@HD|Rv2@V~Bv!vrV61mqwX_{CQ+ze_jRI(`D|U$hwZ%$Wm6*R6-b{Cv1H zg?V{!WX)Qb-?<9}WD2k)CX;fc5=Cwf_qdBMn&?%it9RAwQ=*^cVFL9GmqvRbP{#6O zK&4)eqLArAU);swz-2hsxQAepau5s*s;9vS6ROvM%#$a9YVxzQVdL--_<1Cxgd{9` zVsf!Pg^}qN2v@nZhJWMY;$Ft_d!TWm(VlOo(GVe%xtCuo$#=0i7z_#LYWLvV2Y>q= ztnASfc21jy;xrZ(T5T}4Vr7`xuo3KEyabAiilC$^E-ZwcGiR`~)}pTJXGw#QN(~`} z3VQ1F;6(3ZjZegKq%sP_4kp#A z1N#;(ghJFWw=%is&!ZTQhpb2h(JF@Qj0`xwc{3bczaAoc_Q3LB2vF2WNXS*iPBv$Y zL$0j)HH-Cn)amyP4nd>6=q+9DH@`%7INCGOh`6IrNUq! zFEbO0kdsOn?5xF37lwlsmjD=4O6qyHRPL)Sk-UFDb}G8%!w+9|>h=V4H&$M6qz zm3v4)@zDL#c(5iWXW99}&~HrUdl=F^fb(1jlS$)~$PSXRBBMpwJu&VCf9XVuG&t9;|W=AVQS6I-kJ1jy_*7ofOC$?^dQD~R4C8d-I2*Dv` zO-RghiKQ;xd#u;*wu_!^pj0yba>Ysvg%kxGELMzr`jtzAz4PWpy9XETjV9~#X&CX_ z?|3;xx@!MHB}@uKA(cP@2xK4mRXMLfe42C(6&zBr(L%zH^>F#%yw6%-DL%N3)`l48F z4i6?)uL*}&twvFb;4K*q<&wBz*f8j+H-J4k6#+`RZ9v_k`uNl6{RQp#qCe4hyzNDj z9(}S^sjTnj@C#kH0*Csrf&_$Ig!=^@$-~Is|HQ{x7mghViov~gaM>~#{cm~PM=@}) z5^fq0RtmAa3PGga?jgyuHANz(Qy^X+3x|FCa7Yp0NFjw^bI7;fLuA)3Y9~cE=tzX@ zB}(^T#r99PYXEiqH2y*x2D>V88!n>9(c)h2ES1)Dad??mfVmAgWVB-BVz9_UN04xQ z!Wv!lI1Lvx8p^GC{oo-GkSlH>Ua^3>|HsNX0Lc*rVK~M~E1hH8wr$(CZQHhO+qP}n zcKu_O&1wRpj4UKzWN`59 zDVej1rB$kmt(`iHeM5!`8kE5zwsq|$R@AC3rV*fN*>i})k*6HG57K z>yf(`)NB<)gMuHo4NaKWo{V5cu2#%#lcdNoCV8rd3><)n44lasGXn%w&ot6_VJ{Lf z=lS*hJHDnM4It02TouQrPNkZuuvpu;3EB~jLK5u;O%mkR!vV6`e)Ub1WFS4Whl_xa z_Tmpro3=Y;tzWLgNvsNIe5y2JQ@i%4h<{6qLnYW?E=1{_AT_1d;+9seL;-#f+>@<+ z)ZjP(4Iv#I8g?eNmzUAix+rF1t5rWLRoX+0D@nj8aNtg-=Py8=+DLrp=fwM?sL@ zZ|>Ylv+KIz-Gc}0uUXf;g)H_kfJjXi(K}(nZuIPEKcw!}FMY;#axg3$)HoDJ5L^C( zz%@_LnM)1oNb%#ddUm_Ga-{?i3kNzNICNd8pP!#Bo5cwC_xBxxG)UfASQylmbT< zoSgxgtPdN8hGw;AaLxGBC0p+7qY@{56iG0OE~#70KoFr5PgEP388`c9rCd>PdR%-(UY7Ic~`=`y& zd~}jzA61scNB~F>05ZslY_3K(TVI3Z11 zabxQ?bn3MK!nXAMvSr*x6&AawT!pAu4|J%>^1G|iEVrOuAz>p3#BT+N0!5~9ANn*W z2~J*Kzb+P+uc(YnNu6iQuoik~Di;=E3UKexA+FknMbVZ7f4>`n**VSs>S{0zmICS? z5;l>{^9qq-b*|WuGaxaYr z4PE?%)M)_5eb-Xb{I?D!OHUaf@6Ms2-A05Z_$mRyDT4aN6)W)oTGPPiR4*NwFp*AJ zvWZ#w3u66qeCjlDo7Tx6o;`z$X$j4U$QmmEjO$s8*Fh%Br#2>2YiXKq8x+!Fcv!-Z zkp(jXir6CN7cVK^-oLNaoul{K+`c0gi0>Xf!h{h}LhSn2V}IoX`<8F z*Fc_HfIOO8tyPAHhP~o|@L&{01-@s%K&nqZ{Nom%UcAID4^0dCj-MZ~68HyVW9v3l z;U{K$E#7)~9cZ#Vtriea2(1QKFs(*KvpMh3(6GmHAexFJg`?CrML1A^>l_(B0qsI0 zXGMU0bJuQhnB0=A4yP{EmP8efqWw+g+u8m7Q%alFGYo>ma+8wI_bcSG@!LDsDI>vD2mz6;tCaM%`<{B+6vtCY;4sA?j`|))J;1v z?9O)d=t+g=87+%LOj=l`EUd^)IH-Xt7z~w>;pd z6k}*=h)qkyK~=Qf&!TE-Pq6f!@g} zPGE;lrzE)HITVU`e0`MCk7$w(23~*G)NXs z7LKBr`7j$+Oc4lTWP|h9mU`nb%G_3_{^8H=s39>lFj?9S2@HA90}2*1a_2>yAq&D- zg3>OoG-i@z&}9e>(Td@%9ToRNb{o#@uvFRy1n=8>_Nwv02SOnrQh<6>aahdPw=g}g zZ=}y(NWIc|`lab<3p~zUagmaHWu*(T8gMh32jSO5JVBXPq9m+JrD4U4nE|6#A>;3( z+K9V|MW%HbkkS0RiOw*V`ielNy^vcGsgde~`hx?5KLZG(fWcgLQH(*=tCF5^0FFvn z3I_^zq}jB3J@nuKJ{(s8bv2kT2gZq$+8%E;g0tL;;zf%V-r8uMOBWD7|Ahf7(Nd)7 zcMl(`#-Js<2 z+m10-!mBk9O)I@MsO}bY;yb@&Y00<(ia&36zX6D6YI1z~3>U{5=I_mP`YDFEaT8&K zA-5j+F%_w`f?q)We%8Q8u7YtW_FTuNPUB`hI3}m-GyGbn;RG~#GZBM-rlG~9tSc== zN4;TR^7!$y*jp;e*`2Fa1j62SSF52jbnR!gy<~uJ>6(hj+LZd4@u_2Ywxn_u1X)0V zLNQxf)2Oj{cImQU1Zy8^QDm0$tCPWWB%7ya5qlx*PGHvT@=5)ulS@=xr|Z(sLXad- zB4j26S9(W7vlpWqX}r+8s(yVjt3W~Q*yt@p?9=$!G^_`ALkQ5k<^1lXHyz96;Zwry z4WyouwC;913LvLVTvey*-q&n>JX8WkQMgD+6p&JJ6q{J{$c38R^JqgfEayiDgYif< z508@c?++yPibC9xU*41Y(-(8Jfba@er11q-$8HFBXN1^;n5EXG z8%%GT`Rf;?iWe^r{kv^SP?pA>s9TahX$bwr1smz~^ZS{tpS9p9bquYtk4~6~_f?}P z*to83+!%kpnoD)n8}3)|_U&Yd9XqX~Q}=Waj{7mmo776QQWWv>YTChIKH1-FeLsY* z*CRr4&P*c-(&2Fv5LYB;!EC&uCKs=eJJ&q}$(?!`OivmG=ocr56)zwC`@%`}qbOd8xgoP+(tEkpVaU6lqU@rv%W5>>%+sms; zjevkLZS;oIy-b#G1J%z>#Dr97a43Fz?_NwEIiYC)?xh!3tb`jQY$1$h(am6b*)%}6 zwXCmi=hSgMi~7fjlhP500~}TpE2?m!|Ih!(#Nm4J@Q)+)jUC%NEPCvq@IUR_w@w5> z490QWn}(b-l-m>D{}Cv!qa@CW%;pIgWC@dM;wSy&{(n>6n*3lXNq6T6%Ytw=Mat8s zum~AONGQvax$Q07+}*{+vV>x8HUdC+uw&2vSabY+>u@)=edj{k#9*(z8=bk~@W{E* zaogG+pay_ObZlUr1qNRm=zXX}V&$S>dCuWxvTSmPXk|W4S7(V94JPvi!DNDH2$PKD z*G3}WN8dRY>XtjzKHlC(e`z~!E&8jQVR#N9dbv(`76p8#Kzbx}FfCC?Ak&NL6?kP} zP6;B)Rr{WS^iaIKFu4jUDvqK8h^Tk|m;e9( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..41c38a153336f1769246708260f4f2de445f4f41 GIT binary patch literal 5988 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%0AB9H(f232^m)Ww%&aq zKPLx%E^cqz4p&yL1Qw^0Q*?TN2G2pRu2nUKk>RMoz^0mnv(;vK{RT7w{8F5Zi>HPr z0EXHPegy={t7N>-QKo<~_KxE<4SdEk`~#y!M6=0C_n)6VCl)HyWzVM0fxA0*(PI{w zpFhKi=$Id$`}CQ5gAVVL0h8?P*KwgPWX6q{I8fX-fMSsMU-~WO`r`9}x6Sn{b{q%M0=K#S47i zjS)|z=RVxK2f*Xm>C#)gfIcyaF&?HtljtEHF$R#<^lOh;u&&FS9m-5|u< zn>ShU!g+@_HvB|Nqcf8mRLwMcU-qK`xKb)ktC#`S8b$NMdBSDf60)0=cwc`}@z~@r zNk383!U}{v;{gbf{_Z{X9Btlb$!Qfe0EMc)izcxmbcJ2mby=LnCWWt)XyK%ZqG^;k z9u-Y95R1c8r~M-F(`CwBjRwqebx#u)P4TTJoietn=i=NjN$)5*{5n z!ZkX2P7GhL?52c8u;R6OpEy~jxWN@1F$2tSSUM#ln%PMaY0Q3p?wqhZ(ya63_;JvM z*H^B>h7KL6bm|HnmSbh|ov5`KX=YGltfR|vwU=-7$f9@2Dsch@dpv48A15~z!#rHXxQ1!5Dq*kC({I19~(bOTE5rdzzK_4G84|Ggh!(XX`wDj{xKl{64MT?`CZ~ikU(+LQ=xatmHI% z$FC%uCkbZHZIFj1PXPg_<6^7}g|khH@0s zV3EoqCW>+a&q(P?O-g7uU3PkS=rFLzNzZ`P@CY&SsxDSGQK&=7G_GG{L=kHf6Q2Q6 z`NkQHXd&^elPU=dzT479Mgz!5k%pDs*1~E67X`@QbnT3cK)STDuo^!}uJ}r#MG|yH zgN7JN>F`T{oti>YSZzkzN*`7!TC^qprh*m!Ta1t(<$e-LrM}kE4U^@{Occ$=I9(?C z^6VMt0QHIs3l?%^S}lB>RQ9feg;lJjo=E_|VKHXJ|7IhM_&V(9E0I*`Y-yc5!d7|* z=W~^C`LTpjQhs^<99ZOMXXB>l0C!SBq9;k^*+Z>@q65mGQLb2{Zg;%|N0dnFB$O$nG)$fJ!eeDe*gXg zqzakOGe^KY{<#7q{AK@gz!+1AAG(SV={`MY${T0M82I1CPjrV-345-zSpWb407*qo IM6N<$g2ImDE&u=k literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..064296e76c6e0628386e1a1e389e80775891e1ef GIT binary patch literal 3132 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 AEC2ui literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4010a271f0a5ca72280f4247829f4185cceb1684 GIT binary patch literal 11777 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1569994a983e547ab1c88ce2345163b773074b81 GIT binary patch literal 15449 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 P00000NkvXXu0mjfGFJD8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d208e7a24da8a6015d41274311aac395b5568d1b GIT binary patch literal 16030 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 YUo_NBgBDaHAOHXW07*qoM6N<$g2zxw*#H0l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1d4cc6d020ed0e602851e3a115a9c3c86f92756a GIT binary patch literal 20741 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�XHq;RCt8stpnLnPkNk(Rpdi4=Y5RxplAk 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 + + + غير قادر على حذف %d النفق: %s + غير قادر على حذف %d النفق: %s + غير قادر على حذف %d النفق: %s + غير قادر على حذف %d أنفاق: %s + غير قادر على حذف %d أنفاق: %s + غير قادر على حذف %d أنفاق: %s + + + تم حذف %d من الأنفاق بنجاح + تم حذف %d من الأنفاق بنجاح + تم حذف %d من الأنفاق بنجاح + تم حذف %d من الأنفاق بنجاح + تم حذف %d من الأنفاق بنجاح + تم حذف %d من الأنفاق بنجاح + + + تم تحديد %d من الأنفاق + تم تحديد %d من الأنفاق + تم تحديد %d من الأنفاق + تم تحديد %d من الأنفاق + تم تحديد %d من الأنفاق + تم تحديد %d من الأنفاق + + + تم استيراد %1$d من %2$d من الأنفاق + تم استيراد %1$d من %2$d من الأنفاق + تم استيراد %1$d من %2$d من الأنفاق + تم استيراد %1$d من %2$d من الأنفاق + تم استيراد %1$d من %2$d من الأنفاق + تم استيراد %1$d من %2$d من الأنفاق + + + تم استيراد %d نفق + تم استيراد %d نفق + تم استيراد نفقين + تم استيراد %d أنفاق + تم استيراد %d أنفاق + تم استيراد %d أنفاق + + + %d تطبيقات مستبعدة + %d تطبيق مستبعد + تطبيقين مستبعدين + %d تطبيقات مستبعدة + %d تطبيقات مستبعدة + %d تطبيقات مستبعدة + + + %d تطبيق مضمن + %d تطبيق مضمن + تطبيقين مضمننين + %d تطبيقات مضمنة + %d تطبيقات مضمنة + %d تطبيقات مضمنة + + + تم استبعاد %d + تم استبعاد %d + تم استبعاد %d + تم استبعاد %d + تم استبعاد %d + تم استبعاد %d + + + تم تضمين %d + تم تضمين %d + تم تضمين %d + تم تضمين %d + تم تضمين %d + تم تضمين %d + + جميع التطبيقات + استبعاد + تضمين فقط + + تضمين %d من التطبيقات + تضمين تطبيق %d + تضمين %d من التطبيقات + تضمين %d من التطبيقات + تضمين %d من التطبيقات + تضمين %d من التطبيقات + + + استبعاد %d تطبيقات + استبعاد %d تطبيقات + استبعاد طبيقين + استبعاد %d تطبيقات + استبعاد %d تطبيقات + استبعاد %d تطبيقات + + + كل %d ثانية + كل ثانية + كل ثانيتين + كل %d ثوانٍ + كل %d ثوانٍ + كل %d ثوانٍ + + + لا ثانية + ثانية + ثنيتين + ثواني + ثواني + ثوان + + استخدام جميع التطبيقات + إضافة ند + العناوين + التطبيقات + لا يمكن للتطبيقات الخارجية تبدل الأنفاق (مستحسن) + يمكن للتطبيقات الخارجية تبدل الأنفاق (مستحسن) + السماح بتطبيقات التحكم عن بعد + عناوين بروتوكول الإنترنت (IP) المسموح بها + %2$s الخاص بـ %1$s + %s + %1$s في %2$s + : يجب أن يكون موجباً و ألا يتجاوز 65535 + يجب أن يكون إيجابياً + : يجب أن يكون رقم منفذ حزم بيانات المستخدم (UDP) صالح + مفتاح غير صالح + رَقَم غير صالح + قيمة غير صالحة + سمة مفقودة + قسم مفقود + خطأ في التركيب + سمة مجهولة + قسم مجهول + قيمة خارج النطاق + يجب أن يكون تنسيق الملف .conf أو .zip + لم يتم العثور على رمز الاستجابة السريعة (QR) في الصورة + فشل التحقق من مجموعة رمز الاستجابة السريعة (QR) + إلغاء + لا يمكن حذف ملف التكوين %s + التكوين لـ \"%s\" موجود بالفعل + ملفّ التكوين لـ \"%s\" موجود بالفعل + ملف التكوين “%s” غير موجود + لا يمكن إعادة تسمية ملف التكوين \"%s\" + لا يمكن حفظ التكوين لـ \"%1$s\": %2$s + تم حفظ التكوين ل \"%s\" بنجاح + إنشاء نفق وايرجارد + لا يمكن إنشاء الدليل الثنائي المحلي + لا يمكن إنشاء ملف في دليل التنزيلات + الإنشاء من الصفر + استيراد من ملف أو أرشيف + مسح من رمز الاستجابة السريعة (QR) + لا يمكن إنشاء دليل الإخراج + لا يمكن إنشاء دليل مؤقت محلي + إنشاء نفق + %s نسخ إلى الحافظة + المظهر الفاتح مستخدم حالياً + المظهر الغامق مستخدم حالياً + إستخدام المظهر الغامق + حذف + اختر نفقاً لحذفه + تحديد محرك تخزين + الرجاء تثبيت مدير ملفات لتتمكن من تصفح الملفات + أضف نفقاً لتبدأ + ♥️ تبرع لمشروع وايرجارد + كل مساهمة تساعد + شكرًا لك على دعم مشروع\n\nWireGuard! للأسف، نظرًا لسياسات Google، لا يُسمح لنا بالارتباط بجزء من صفحة الويب الخاصة بالمشروع حيث يمكنك التبرع. نأمل أن تتمكن من معرفة ذلك!\n\nشكرًا مرة أخرى على مساهمتك. + تعطيل تصدير التكوين + تعطيل تصدير الإعدادات يجعل المفاتيح الخاصة غير متاحة + خوادم DNS + البحث عن النطاقات + تعديل + نقطة نهاية + خطأ في فصل النفق:%s + خطأ في جلب قائمة التطبيقات: %s + الرجاء الحصول على صلاحيات الروت وحاول مرة أخرى + خطأ في تحضير النفق: %s + خطأ خلال إنشاء النفق: %s + استبعاد عناوين بروتوكول الإنترنت (IP) الخاصة + توليد مفتاح خاص جديد + خطأ \"%s\" غير معروف + (تلقائي) + (توليد) + (إختياري) + (اختياري، غير مستحسن) + (عشوائي) + اسم الملف غير مسموح به \"%s\" + غير قادر على استيراد النفق: %s + استيراد نفق من رمز الاستجابة السريعة (QR) + تم استيراد \"%s\" + الواجهة + أحرف سيئة في المفتاح + طول المفتاح غير صحيح + : مفاتيح وايرجارد في الأساس 64 (base64) يجب أن تكون 44 حرفاً (32 بايت) + : مفاتيح وايرجارد يجب أن تكون 32 بايت + : مفاتيح وايرجارد في نظام عد ستة عشري (hexadecimal) يجب أن تكون 64 حرفاً (32 بايت) + أحدث مصافحة + منذ %s + منفذ الاستماع + غير قادر على تصدير السجل: %s + ملف سجل اندرويد وايرجارد + تم الحفظ في \"%s\" + تصدير ملف السجل + حفظ السجل + قد تساعد السجلات في تصحيح الأخطاء + عرض سجل التطبيق + سجل + غير قادر على تشغيل logcat: + يمكن لوحدة النواة التجريبية أن تحسن الأداء + تمكين خلفية وحدة النواة + خلفية مساحة المستخدم البطيئة قد تحسن الاستقرار + تعطيل خلفية وحدة النواة + حدث خطأ ما. يرجى المحاولة مرة أخرى + يمكن لوحدة النواة التجريبية أن تحسن الأداء + لا توجد وحدات متوفرة لجهازك + تحميل وتثبيت الوحدة للنواة (Kernel Module) + جارٍ التنزيل والتثبيت… + غير قادر على تحديد إصدار وحدة النواة + وحدة النقل العظمى (MTU) + سيؤدي تشغيل نفق واحد إلى إيقاف تشغيل الأنفاق الأخرى + يمكن تشغيل أنفاق متعددة في نفس الوقت + السماح بأنفاق متعددة متزامنة + إسم + محاولة جلب نفق بدون تكوين + لم يتم العثور على تكوينات + لا توجد أنفاق + سلسلة + عنوان بروتوكول الإنترنت (IP) + نقطة نهاية + شبكة بروتوكول الإنترنت (IP) + رقم + لا يمكن تحليل %1$s \"%2$s\" + ند + التحكم في أنفاق WireGuard، وتمكين الأنفاق وتعطيلها حسب الرغبة، مما قد يؤدي إلى تضليل حركة مرور الإنترنت + التحكم في أنفاق وايرجارد + الحفاظ المستمر + مفتاح مسبق التشارك (Pre-shared key) + مفعّل + مفتاح خاص + مفتاح عام + نصيحة: إنشاء بواسطة `qrencode -t ansiutf8 < tunnel.conf`. + إضافة بلاطة إلى لوحة الإعدادات السريعة + بلاطة الاختصار تستبدل أحدث نفق + تعذر إضافة لوحة الاختصار: خطأ %d + تبديل النفق + لن تجلب الأنفاق المفعلة عند التمهيد + سوف تجلب الأنفاق المفعلة عند التمهيد + الإستعادة عند تشغيل الجهاز + حفظ + اختيار الكلّ + الإعدادات + لا يمكن للقشرة (shell) قراءة حالة الخروج + القشرة متوقعة 4 علامات، استلمت %d + فشل تشغيل القشرة: %d + تم بنجاح. ستتم الآن إعادة تشغيل التطبيق… + تبديل الكل + خطأ في تبديل نفق وايرجارد: %s + تم بالفعل تثبيت wg و wg-quick + غير قادر على تثبيت أدوات سطر الأوامر (لا يوجد root؟) + تثبيت الأدوات الاختيارية للبرمجة النصية (scripting) + تثبيت الأدوات الاختيارية للبرمجة النصية كوحدة ماجيسك (Magisk Module) + تثبيت الأدوات الاختيارية للبرمجة النصية في قسم النظام + wg و wg-quick ثبتا كوحدة ماجيسك (إعادة التشغيل مطلوبة) + wg و wg-quick ثبتا في قسم النظام + تثبيت أدوات سطر الأوامر + تثبيت wg وwg-quick + الأدوات المطلوبة غير متوفرة + تحويل + %d بايت + %.2f جيبي بايت + %.2f كيبيبايت + %.2f مبيبايت + rx: %1$s, tx: %2$s + %.2f تيبي بايت + غير قادر على إنشاء جهاز tun + غير قادر على تكوين النفق (wg-quick أعاد %d) + غير قادر على إنشاء النفق: %s + تم إنشاء نفق \"%s\" بنجاح + النفق \"%s\" موجود بالفعل + إسم غير صالح + أضف نفق باستخدام الزر أدناه + إسم النفق + غير قادر على تشغيل النفق (wgTurnOn أعاد %d) + غير قادر على حل اسم مضيف نظام أسماء النطاقات (DNS hostname): \"%s\" + غير قادر على إعادة تسمية النفق: %s + تمت إعادة تسمية النفق بنجاح إلى \"%s\" + انتقل مساحة المستخدمين + وحدة النواة + خطأ غير معروف + يتوفر تحديث للتطبيق. الرجاء التحديث الآن. + تنزيل & تحديث + جارِ جلب تحديث البيانات الوصفية… + جارِ تنزيل التحديث: %1$s / %2$s (%3$.2f%%) + جارِ تنزيل التحديث: %s + جارِ تثبيت التحديث… + فشل التحديث: %s. ستتم إعادة المحاولة للحظات… + التطبيق تالف + هذا التطبيق تالف. يرجى إعادة تنزيل APK من موقع الويب المرتبط أدناه. بعد ذلك، قم بإلغاء تثبيت هذا التطبيق، وأعِد تثبيته من ملف APK الذي تم تنزيله. + فتح الموقع + %1$s خلفية %2$s + التحقق من إصدار خلفية %s + إصدار %s غير معروف + وايرجارد لأندويد النسخة %s + خدمة شبكة خاصة افتراضية (VPN) غير مسموح بها من قبل المستخدم + غير قادر على تشغيل خدمة الشبكة الخاصة الافتراضية (VPN) لنظام أندرويد + غير قادر على تصدير الأنفاق: %s + تم الحفظ في \"%s\" + سيتم حفظ ملف Zip في مجلد التنزيلات + تصدير الأنفاق إلى ملف zip + تحتاج للاستيثاق لتصدير الأنفاق + تحتاج للاستيثاق لعرض المفتاح الخاص + فشل في المصادقة + فشل في المصادقة: %s + diff --git a/ui/src/main/res/values-ca-rES/strings.xml b/ui/src/main/res/values-ca-rES/strings.xml new file mode 100644 index 0000000..f2fe06c --- /dev/null +++ b/ui/src/main/res/values-ca-rES/strings.xml @@ -0,0 +1,259 @@ + + + + No s\'han pogut esborrar els túnels: + No s\'han pogut esborrar %d túnels: %s + + + El túnel %d s\'ha eliminat correctament + Els túnels %d s\'han eliminat correctament + + + %d túnel seleccionat + %d túnels seleccionats + + + Túnel %1$d de %2$d importat + Túnels %1$d de %2$d importats + + + %d túnel importat + %d túnels importats + + + %d Aplicació exclosa + %d Aplicacins excloses + + + %d Aplicació inclosa + %d Aplicacions incloses + + + %d exclòs + %d exclosos + + + %d inclòs + %d inclosos + + Totes les aplicacions + Exclou + Inclou només + + Inclou %d aplicació + Inclou %d aplicacions + + + Exclou %d aplicació + Exclou %d aplicacions + + + cada segon + cada %d segons + + + segon + segons + + Utilitza totes les aplicacions + Afegir parell + Adreces + Aplicacions + Aplicacions externes no poden cambiar túnels (recomanat) + Aplicacions externes poden cambiar túnels (avançat) + Permet aplicacions de control remot + IPs permeses + %1$s de %2$s + %s + %1$s en %2$s + : Ha de ser positiu i no superior a 65535 + : Ha de ser positiu + : Ha de ser un nombre vàlid de port UDP + Clau no vàlida + Número no vàlid + Valor no vàlid + Falta atribut + Falta secció + Error de sintaxi + Atribut desconegut + Secció desconeguda + Valor fora de rang + El fitxer ha de ser .conf o .zip + No s\'ha trobat cap codi QR en la imatge + Ha fallat la verificació de la suma de comprovació del codi QR + Cancel·la + No es pot esborrar fitxer de configuració %s + Configuració per \"%s\" ja existeix + Fitxer de configuració \"%s\" ja existeix + No s\'ha trobat el fitxer de configuració \"%s\" + No es pot cambiar el nom del fitxer de configuració \"%s\" + No es pot guardar la configuraxió per \"%1$s\": %2$s + La configuració per \"%s\" s\'ha guadat correctament + Crear túnel WireGuard + No s\'ha pogut crear la carpeta local pels binaris + No es pot crear arxiu en la carpeta de descàrregues + Crea des de zero + Importa des de fitxer o arxiu + Escaneja codi QR + Incapaç de crear directori de sortida + No s’ha pogut crear el directori local temporal + Crear túnel + S\'ha copiat %s al portaretalls + Actualment fent servir el tema clar (dia) + Actualment fent servir el tema fosc (nit) + Utilitza tema fosc + Elimina + Slecciona túnel a esborrar + Seleccioneu un disc d\'emmagatzematge + Si us plau, instsleu un gestor de fitxers per navegar pels arxius + Afegeix un túnel per començar + ♥ Donar al Projecte WireGuard + Totes les contribucions ajuden + Gràcies per donar suport al Projecte WireGuard!\n\nMalauradament, a causa de les polítiques de Google, no estem permesos a enllaçar a la part del web del projecte on pots realitzar una donació. Esperem que puguis trobar-ho!\n\nGràcies de nou per la teva contribució. + Desactiva l\'exportació de configuracions + Desactivar l\'exportacio de configuracions fa que les claus privades siguin menys accessibles + Servidors DNS + Cercar dominis + Edita + Extrem + Error desactivant el túnel: %s + Error obtenint llista d\'aplicacions: %s + Si us plau, obteniu accés root i torneu a intentar + Error preparant el túnel: %s + Error activant túnel: %s + Exclou IPs privades + Genera nova clau privada + Error “%s” desconegut + (automàtic) + (generat) + (opcional) + (opcional, no recomanat) + (aleatori) + Nom de fitxer no vàlid \"%s\" + No s\'ha pogut importar el túnel: %s + Importa túnel desde codi QR + Importat “%s” + Interfície + Caràcters incorrectes en la clau + Longitud de clau incorrecta + : Les claus en base64 a WireGuard han de tenir 44 caràcters (32 bytes) + : Les claus WireGuard han de ser de 32 bytes + : Les claus en hexadecimal a WireGuard han de tenir 64 caràcters (32 bytes) + Últim handshake + fa %s + Port d\'escolta + No s\'ha estat capaç d\'exportar el registre: %s + WireGuard Android Log File + Guardat a \"%s\" + Exporta el registre + Guarda registre + El registre pot ajudar a solucionar errors + Mostra el registre d\'aplicació + Registre + No es pot executar logcat: + El mòdul experimental del kernel pot millorar el rendiment + Activa el backend del mòdul del kernel + El backend més lent de l\'espai d\'usuari pot millorar l\'estabilitat + Desactiva el backend del mòdul del kernel + Alguna cosa ha anat malament. Si us plau, prova de nou + El mòdul experimental del kernel pot millorar el rendiment + El vostre dispositiu no té mòduls disponibles + Descàrega i instala el mòdul kernel + Descarregant i instalant… + No s\'ha pogut determinar la versió del mòdul del kernel + MTU + Activar un túnel desactivarà els altres + Múltiples túnels es podran activar a la vegada + Permet múltiples túnels simultanis + Nom + Intentant activar un túnel que no té configuració + No s\'ha trobat cap configuració + No existeixen túnels + cadena + Adreça IP + extrem + Xarxa IP + número + No es pot analitzar %1$s \"%2$s\" + Parell + controla els túnels de WireGuard, habilitant i deshabilitant túnels a discreció, potencialment desviant el trànsit + controla els túnels WireGuard + Missatge de persistència + Clau precompartida + activat + Clau privada + Clau pública + Consell: generar amb `qrencode -t ansiutf8 < tunnel.conf`. + Afegir mosaic al tauler de configuració ràpida + L\'accés directe al mosaic alterna el túnel més recent + No s\'ha pogut afegir el mosaic de l\'accés directe: error %d + Alternar túnel + No s\'activaran els túnels seleccionats al arrancar el sistema + S\'activaran els túnels seleccionats al arrancar el sistema + Restableix a l\'inici + Guarda + Selecciona-ho tot + Configuració + La shell no pot llegit l\'estat de sortida + La shell esperava 4 marcadors, n\'ha rebut %d + No s\'ha pogut iniciar: %d + Èxit. Ara l\'aplicació es reiniciarà… + Canviar tot + Error al canviar el túnel WireGuard: %s + wg i wg-quick ja estan instalats + No s\'ha pogut instalar les eines de linia de comandes (ets root?) + Instal·lar eines opcionals per l\'scripting + Instal·lar eines opcionals per l\'scripting com el mòdul Magisk + Instal·lar eines opcionals per l\'scripting a la partició del sistema + wg i wg-quick instalats com a mòdul de Magisk (es necessita reinicar) + wg i wg-quick instalats a la partició de sistema + Instala les eines de la línia d\'ordres + Instalant wg i wg-quick + Eines necessàries no disponibles + Transferir + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + No s\'ha pogut crear el dispositiu túnel + No s\'ha pigut confirmgurar el túnel (wg-quick ha tornat %d) + No s\'ha pogut crear túnel: %s + El túnel \"%s\" s\'ha creat correctament + El túnel \"%s\" ja existeix + Nom no vàlid + Afegiu un túnel utilitzant el botó inferior + Nom del túnel + No s\'ha estat capaç d\'activar el túnel (wgTurnOn ha retornat %d) + Impossible resoldre el nom del domini \"%s\" + No s\'ha pogut renombrar el túnel: %s + El nom del túnel s\'ha canviat correctament a \"%s\" + Anar a l\'espai d\'usuari + Mòdul del kernel + Error desconegut + Hi ha una actualització disponible. Si us plau, actualitzi ara. + Descarregar & Actualitzar + Obtenint metadades de l\'actualització… + Descarregant actualització: %1$s / %2$s (%3$.2f%%) + Descarregant actualització: %s + Instal·lant actualització… + Error en actualitzar: %s. S\'intentarà de nou en uns instants… + Aplicació corrupta + L\'aplicació és corrupta. Si us plau, descarregui de nou l\'APK des del lloc web enllaçat a continuació. A continuació, desinstal·li i reinstal·li-la de l\'APK descarregat. + Obrir el lloc web + %1$s backend %2$s + Comprovant la versió de backend %s + Versió de %s desconeguda + WireGuard per a Android v%s + Servei de VPN no autoritzat per l\'usuari + No s\'ha pogut iniciar el servei de VPN de Android + No s\'han pogut exportar els túnels: %s + Guardat a \"%s\" + El fitxer zip es guardarà a la carpeta de descàrregues + Exporta els túnels a un fitxer zip + Autentifiqueu-vos per exportar els túnels + Autentifiqueu-vos per veure la clau privada + Error d\'autenticació + Error d\'autenticació: %s + diff --git a/ui/src/main/res/values-cs-rCZ/strings.xml b/ui/src/main/res/values-cs-rCZ/strings.xml new file mode 100644 index 0000000..24568a8 --- /dev/null +++ b/ui/src/main/res/values-cs-rCZ/strings.xml @@ -0,0 +1,285 @@ + + + + %d tunel nelze smazat: %s + %d tunely nelze smazat: %s + %d tunelů nelze smazat: %s + %d tunelů nelze smazat: %s + + + %d tunel byl smazán + %d tunely byly smazány + %d tunelů bylo smazáno + %d tunelů bylo smazáno + + + Vybrán %d tunel + Vybrány %d tunely + Vybráno %d tunelů + Vybráno %d tunelů + + + Importován %1$d z %2$d tunelů + Importovány %1$d z %2$d tunelů + Importováno %1$d z %2$d tunelů + Importováno %1$d z %2$d tunelů + + + Importován %d tunel + Importovány %d tunely + Importováno %d tunelů + Importováno %d tunelů + + + %d vyloučená aplikace + %d vyloučené aplikace + %d vyloučených aplikací + %d vyloučených aplikací + + + %d zahrnutá aplikace + %d zahrnuté aplikace + %d zahrnutých aplikací + %d zahrnutých aplikací + + + %d vyloučena + %d vyloučeny + %d vyloučeno + %d vyloučeno + + + %d zahrnuta + %d zahrnuty + %d zahrnuto + %d zahrnuto + + Všechny aplikace + Vyloučit + Zahrnout pouze + + Zahrnout %d aplikaci + Zahrnout %d aplikace + Zahrnout %d aplikací + Zahrnout %d aplikací + + + Vyloučit %d aplikaci + Vyloučit %d aplikace + Vyloučit %d aplikací + Vyloučit %d aplikací + + + každou sekundu + každé %d sekundy + každých %d sekund + každých %d sekund + + + sekunda + sekundy + sekund + sekund + + Použít všechny aplikace + Přidat peera + Adresy + Aplikace + Externí aplikace nemohou přepínat tunely (doporučeno) + Externí aplikace mohou přepínat tunely (pokročilé) + Povolit aplikace vzdálené správy + Povolené IP + %2$s položky %1$s + %s + %1$s v %2$s + : Musí být kladný a maximálně 65535 + : Musí být kladný + : Musí být platné číslo UDP portu + Neplatný klíč + Neplatné číslo + Neplatná hodnota + Chybějící atribut + Chybějící sekce + Syntaktická chyba + Neznámý atribut + Neznámá sekce + Hodnota je mimo rozsah + Soubor musí být .conf nebo .zip + V obrázku nebyl nalezen QR kód + Ověření kontrolního součtu QR kódu selhalo + Zrušit + Nelze smazat konfigurační soubor %s + Konfigurace pro „%s“ již existuje + Konfigurační soubor „%s“ již existuje + Konfigurační soubor „%s“ nebyl nalezen + Konfigurační soubor „%s“ nelze přejmenovat + Nelze uložit konfiguraci pro „%1$s“: %2$s + Konfigurace pro „%s“ byla úspěšně uložena + Vytvořit WireGuard tunel + Nelze vytvořit místní adresář pro binární soubory + Nelze vytvořit soubor ve složce Stažené + Vytvořit od začátku + Importovat ze souboru nebo archivu + Skenovat z QR kódu + Nelze vytvořit výstupní adresář + Nelze vytvořit místní dočasný adresář + Vytvořit tunel + %s zkopírováno do schránky + Aktuálně používáte světlé (denní) téma + Aktuálně používáte tmavé (noční) téma + Použít tmavé téma + Smazat + Vyberte tunel k odstranění + Vyberte úložný disk + Prosím nainstalujte nástroj pro správu souborů pro procházení souborů + Přidejte tunel, abyste mohli začít + ♥ Přispět na projekt WireGuard + Každý příspěvek pomáhá + Děkujeme, že podporujete projekt WireGuard!\n\nBohužel, kvůli pravidlům Googlu, nám není dovoleno odkazovat na část webové stránky projektu, kde můžete provést dar. Doufáme, že to zvládnete nějakým způsobem!\n\nJeště jednou děkujeme za váš příspěvek. + Zakázat export konfigurace + Zakázání exportu konfigurace ztíží přístup k privátním klíčům + DNS servery + Hledat domény + Upravit + Endpoint + Chyba při vypínání tunelu: %s + Chyba při načítání seznamu aplikací: %s + Prosím získejte root přístup a zkuste to znovu + Chyba při přípravě tunelu: %s + Chyba při zapínání tunelu: %s + Vyloučit soukromé IP adresy + Generovat nový privátní klíč + Neznámá chyba „%s“ + (auto) + (vygenerováno) + (volitelné) + (volitelné, nedoporučuje se) + (náhodné) + Neplatný název souboru „%s“ + Nelze importovat tunel: %s + Importovat tunel z QR kódu + Importováno „%s“ + Rozhraní + Špatné znaky v klíči + Nesprávná délka klíče + : Klíče WireGuard ve formátu base64 musí mít 44 znaků (32 bajtů) + : Klíče WireGuard musí mít 32 bajtů + : Klíče WireGuard ve formátu hex musí mít 64 znaků (32 bajtů) + Poslední handshake + před %s + Port pro naslouchání + Nelze exportovat protokol: %s + Soubor protokolu Android WireGuard + Uloženo do „%s“ + Exportovat soubor protokolu + Uložit protokol + Logy mohou pomoci s debuggingem + Zobrazit log aplikace + Log + Nelze spustit logcat: + Experimentální kernel modul může zlepšit výkon + Povolit backend kernel modulu + Pomalejší uživatelský prostor může zlepšit stabilitu + Vypnout backend kernel modulu + Něco se pokazilo. Zkuste to prosím znovu + Experimentální kernel modul může zlepšit výkon + Pro toto zařízení nejsou dostupné žádné moduly + Stáhnout a nainstalovat kernel modul + Stahování a instalace… + Nelze určit verzi kernel modulu + MTU + Zapnutí jednoho tunelu vypne ostatní tunely + Více tunelů může být zapnuto najednou + Povolit více simultánních tunelů + Název + Snažím se zapnout tunel bez konfigurace + Nenalezeny žádné konfigurace + Žádné tunely neexistují + řetězec + IP adresa + koncový bod + IP síť + číslo + Nelze analyzovat %1$s “%2$s“ + Peer + ovládání tunelů WireGuard, libovolné povolování a zakazování tunelů, což může vést k nesprávnému směrování internetového provozu + spravovat WireGuard tunely + Udržování spojení + Předsdílený klíč + povoleno + Privátní klíč + Veřejný klíč + Tip: generujte pomocí `qrencode -t ansiutf8 < tunel.conf`. + Přidat dlaždici do panelu rychlého nastavení + Dlaždice zkratek přepíná nejnovější tunel + Nelze přidat dlaždici zástupce: chyba %d + Přepnout tunel + Při spuštění systému se nezapnou povolené tunely + Při spuštění systému se zapnou povolené tunely + Obnovit při spuštění + Uložit + Vybrat vše + Nastavení + Shell nemůže přečíst stav ukončení + Shell očekával 4 značky, obdržel %d + Shell se nepodařilo spustit: %d + Úspěch. Aplikace se nyní restartuje… + Přepnout vše + Chyba při přepínání tunelu WireGuard: %s + wg a wg-quick jsou již nainstalovány + Nelze nainstalovat nástroje příkazového řádku (bez roota?) + Instalace volitelných nástrojů pro skriptování + Instalace volitelných nástrojů pro skriptování jako modul Magisk + Instalovat volitelné nástroje pro skriptování do systémového oddílu + wg a wg-quick nainstalované jako modul Magisk (nutný restart) + wg a wg-quick nainstalovány do systémového oddílu + Nainstalujte nástroje příkazového řádku + Instaluji wg a wg-quick + Požadované nástroje nejsou k dispozici + Přenos + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Nelze vytvořit tun zařízení + Nelze nakonfigurovat tunel (wg-quick vrátil %d) + Nelze vytvořit tunel: %s + Tunel „%s“ byl úspěšně vytvořen + Tunel „%s“ již existuje + Neplatný název + Přidejte tunel pomocí tlačítka níže + Název tunelu + Nelze zapnout tunel (wgTurnOn vrátil %d) + Nelze vyřešit DNS hostname: „%s“ + Nelze přejmenovat tunel: %s + Tunel byl úspěšně přejmenován na „%s“ + Přejít do uživatelského prostoru + Modul jádra + Neznámá chyba + Aktualizace aplikace je k dispozici. Aktualizujte nyní. + Stáhnout & Aktualizovat + Načítání metadat pro aktualizaci… + Stahování aktualizace: %1$s / %2$s (%3$.2f%%) + Stahování aktualizace: %s + Instalování aktualizace… + Chyba při aktualizaci: %s. Pokusím se znovu za chvíli… + Aplikace je poškozená + Tato aplikace je poškozená. Prosím, znovu stáhněte APK soubor z webové stránky uvedené níže. Poté odinstalujte tuto aplikaci a znovu ji nainstalujte z nově staženého APK souboru. + Otevřít webovou stránku + %1$s backend %2$s + Kontroluji verzi %s backendu + Neznámá verze %s + WireGuard pro Android v%s + Služba VPN není autorizována uživatelem + Nelze spustit službu VPN pro Android + Nelze exportovat tunely: %s + Uloženo do „%s“ + Zip soubor bude uložen do složky stahování + Exportovat tunely do souboru zip + Ověřte se pro export tunelů + Ověřte se pro zobrazení privátního klíče + Ověření selhalo + Chyba ověřování: %s + diff --git a/ui/src/main/res/values-da-rDK/strings.xml b/ui/src/main/res/values-da-rDK/strings.xml new file mode 100644 index 0000000..7b9b449 --- /dev/null +++ b/ui/src/main/res/values-da-rDK/strings.xml @@ -0,0 +1,259 @@ + + + + Kan ikke slette %d tunnel: %s + Ikke i stand til at slette %d tunneler: %s + + + Slettede %d tunnel + %d tunneller blev slettet + + + %d tunnel valgt + %d tunneler valgt + + + Importeret %1$d ud af %2$d tunneler + Importeret %1$d ud af %2$d tunneler + + + Importeret %d tunnel + Importeret %d tunneler + + + %d Ekskluderet Applikation + %d Ekskluderet Applikationer + + + %d Inkluderet Applikation + %d Inkluderet Applikationer + + + %d ekskluderet + %d ekskluderet + + + %d inkluderet + %d inkluderet + + Alle Applikationer + Ekskludér + Inkludér kun + + Inkludér %d app + Inkludér %d apps + + + Ekskludér %d app + Ekskludér %d apps + + + hvert sekund + hver %d. sekund + + + sekund + sekunder + + Brug alle apps + Tilføj modpart + Adresser + Applikationer + Eksterne apps kan ikke slå tunneler til/fra (Anbefales) + Eksterne apps må slå tunneler til/fra (Avanceret) + Tillad fjernstyring fra eksterne apps + Tilladte IP-adresser + %1$s\'s %2$s + %s + %1$s i %2$s + : Skal være positiv og højst 65535 + : Skal være positiv + : Skal være et gyldigt UDP portnummer + Ugyldig nøgle + Ugyldigt nummer + Ugyldig værdi + Manglende attribut + Mangler sektion + Syntaksfejl + Ukendt egenskab + Ukendt sektion + Værdi udenfor området + Filen skal være .conf eller .zip + QR-kode ikke fundet i billede + QR kode checksum verifikation fejlede + Annullér + Kunne ikke slette konfigurationsfilen %s + Konfiguration for \"%s\" findes allerede + Konfigurationsfilen \"%s\" findes allerede + Konfigurationsfilen \"%s\" blev ikke fundet + Kan ikke omdøbe konfigurationsfilen \"%s\" + Kan ikke gemme konfigurationen for \"%1$s\": %2$s + Konfiguration for \"%s\" blev gemt + Opret WireGuard tunnel + Kan ikke oprette lokal binær mappe + Kan ikke oprette fil i download-mappen + Opret fra ny + Importér fra fil eller arkiv + Scan fra QR-kode + Kan ikke oprette output mappe + Kan ikke oprette lokal midlertidig mappe + Opret Tunnel + %s kopieret til udklipsholder + Bruger lige nu lyst (dag) tema + Bruger lige nu mørkt (nat) tema + Brug mørkt tema + Slet + Vælg tunnel du vil slette + Vælg et lagerdrev + Installér venligst et filhåndteringsværktøj til at gennemse filer + Tilføj en tunnel for at komme i gang + ♥ Donér til WireGuard projektet + Et hvert bidrag hjælper + Tak fordi du støttede WireGuard-projektet!\n\nDesværre, på grund af Googles politikker, har vi ikke tilladelse til at linke til den del af projektets webside, hvor du kan donere. Forhåbentlig kan du finde frem til dette!\n\nVi takker igen for dit bidrag. + Deaktivér eksportering af konfiguration + Deaktivering af config eksport gør private nøgler mindre tilgængelige + DNS-servere + DNS-søgedomæner + Redigér + Slutpunkt + Fejl ved nedbringelse af tunnel: %s + Fejl under hentning af app-liste: %s + Få root-adgang og prøv igen + Fejl ved forberedelse af tunnel: %s + Fejl under aktivering af tunnel: %s + Eksludér private IP-adresser + Generér ny privat nøgle + Ukendt \"%s\" fejl + (auto) + (genereret) + (valgfri) + (valgfri, ikke anbefalet) + (tilfældig) + Ugyldigt filnavn \"%s\" + Kunne ikke importere tunnel: %s + Importér tunnel fra QR-kode + Importeret \"%s\" + Grænseflade + Ugyldige tegn i nøgle + Forkert længde på nøgle + : WireGuard base64 nøgler skal være 44 tegn (32 bytes) + : WireGuard nøgler skal være 32 bytes + : WireGuard hex-nøgler skal være 64 tegn (32 bytes) + Seneste håndtryk + %s siden + Lytteport + Kunne ikke eksportere log: %s + WireGuard Android Log-fil + Gemt til \"%s\" + Eksportér log-fil + Gem log + Logfiler kan hjælpe ved fejlsøgning + Vis applikationslog + Log + Kunne ikke køre logcat: + Det eksperimentelle kerne modul kan forbedre ydeevnen + Aktiver kernemodul backend + Det langsommere brugerområde backend kan forbedre stabiliteten + Deaktiver Kerne Module bacnkend + Noget gik galt. Forsøg venligst igen + Det eksperimentelle kerne modul kan forbedre ydeevnen + Ingen moduler er tilgængelige for din enhed + Hent og installér kerne-modul + Henter og installerer… + Kan ikke afgøre kernemodul version + MTU + Aktivering af en tunnel vil lukke andre tunneler + Flere tunneler kan tændes samtidigt + Tillad flere samtidige tunneler + Navn + Forsøger at skabe en tunnel uden konfiguration + Ingen konfigurationer fundet + Ingen tilgængelige tunneler + streng + IP adresse + slutpunkt + IP netværk + nummer + Kan ikke parse %1$s “%2$s” + Modpart + kontrollere WireGuard-tunnellerne, ved at aktivere og deaktivere tunneller, kan det potentielt vildlede internettrafikken + håndtér WireGuard-tunneler + Vedvarende keepalive + Forhåndsdelt nøgle + aktiveret + Privat nøgle + Offentlig nøgle + Tip: generere med `qrencode -t ansiutf8 < tunnel.conf`. + Tilføj genvej til hurtig-indstillingspanelet + Genvejstasten aktivere den seneste tunnel + Kan ikke tilføje genvej: fejl %d + Slå tunnel til/fra + Vil ikke aktivere tændte tunneler ved opstart + Vil tænde aktiverede tunneler ved opstart + Gendan ved opstart + Gem + Vælg alle + Indstillinger + Shell kan ikke læse exit-status + Shell forventede 4 markører, modtog %d + Shell kunne ikke startes: %d + Succes. Applikationen vil nu genstarte… + Vælg/Fravælg Alle + Fejl under aktivering af WireGuard-tunnel: %s + wg og wg-quick er allerede installeret + Kan ikke installere kommandolinjeværktøjer (ingen root?) + Installér valgfrie værktøjer til scripting + Installér valgfrie værktøjer til scripting som Magisk modul + Installér valgfrie værktøjer til scripting i systempartitionen + wg og wg-quick installeret som et Magisk modul (genstart påkrævet) + wg og wg-quick installeret i systempartitionen + Installér kommandolinjeværktøjer + Installerer wg og wg-quick + Påkrævede værktøjer er ikke tilgængelige + Overførsel + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Kunne ikke oprette tun enhed + Kunne ikke konfigurere tunnel (wg-quick returnerede %d) + Ikke i stand til at oprette tunnel: %s + Tunnelen blev succesfuldt oprettet \"%s\" + Tunnel \"%s\" eksisterer allerede + Ugyldigt navn + Tilføj en tunnel ved hjælp af knappen nedenfor + Tunnel Navn + Kan ikke aktivere tunnelen (wgTurnOn returnerede %d) + Kunne ikke opslå DNS adresse: \"%s\" + Kan ikke omdøbe tunnel: %s + Tunnel blev succesfuldt omdøbt til \"%s\" + Go userspace + Kerne modul + Ukendt fejl + En applikationsopdatering er tilgængelig. Opdatér venligst nu. + Hent & Opdatér + Henter opdateringsmetadata… + Henter opdatering: %1$s / %2$s (%3$.2f%%) + Henter opdatering: %s + Installerer opdatering… + Opdateringsfejl: %s. Vil prøve igen om lidt… + Applikation Korrupt + Denne applikation er korrupt. Download venligst APK fra websted linket nedenfor. Afinstaller denne applikation, og geninstaller den fra den downloadede APK. + Åbn webside + %1$s backend %2$s + Kontrollerer %s backend version + Ukendt %s version + WireGuard for Android v%s + VPN-tjeneste ikke godkendt af bruger + Kan ikke starte Android VPN-tjeneste + Ikke i stand til at eksportere tunneler: %s + Gemt til \"%s\" + Zip-filen vil blive gemt i download-mappen + Eksportér tunneler til zip-fil + Godkend til eksport af tunneller + Godkend for at se privat nøgle + Fejl ved godkendelse + Fejl ved godkendelse: %s + diff --git a/ui/src/main/res/values-de/strings.xml b/ui/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..9e0aa97 --- /dev/null +++ b/ui/src/main/res/values-de/strings.xml @@ -0,0 +1,259 @@ + + + + %d Tunnel konnte nicht gelöscht werden: %s + %d Tunnel konnten nicht gelöscht werden: %s + + + %d Tunnel erfolgreich gelöscht + %d Tunnel erfolgreich gelöscht + + + %d Tunnel ausgewählt + %d Tunnel ausgewählt + + + %1$d von %2$d Tunnel importiert + %1$d von %2$d Tunneln importiert + + + %d Tunnel importiert + %d Tunnel importiert + + + %d ausgeschlossene Anwendung + %d ausgeschlossene Anwendungen + + + %d eingeschlossene Anwendung + %d eingeschlossene Anwendungen + + + %d ausgeschlossen + %d ausgeschlossen + + + %d eingeschlossen + %d einbezogen + + Alle Anwendungen + Ausschließen + Nur einschließen + + %d App einschließen + %d Apps einschließen + + + %d App ausschließen + %d Apps ausschließen + + + jede Sekunde + alle %d Sekunden + + + Sekunde + seconds + + Alle Apps verwenden + Gegenüber hinzufügen + Adressen + Anwendungen + Externe Apps dürfen keine Tunnel umschalten (empfohlen) + Externe Apps dürfen Tunnel umschalten (erweitert) + Erlaube Steuerung über externe Apps + Erlaubte IPs + %2$s des %1$s + %s + %1$s in %2$s + : Muss positiv und nicht größer als 65535 sein + : Muss positiv sein + : Muss eine gültige UDP-Portnummer sein + Ungültiger Schlüssel + Ungültige Zahl + Ungültiger Wert + Fehlendes Attribut + Fehlender Abschnitt + Syntaxfehler + Unbekanntes Attribut + Unbekannter Abschnitt + Wert ist außerhalb des gültigen Bereichs + Dateiendung muss .conf oder .zip sein + Es wurde kein QR-Code im Bild gefunden + Die Prüfsummenkontrolle des QR-Codes ist fehlgeschlagen + Abbrechen + Konfigurationsdatei %s kann nicht gelöscht werden + Konfiguration für „%s“ existiert bereits + Konfigurationsdatei „%s“ existiert bereits + Konfigurationsdatei „%s“ nicht gefunden + Konfigurationsdatei „%s“ kann nicht umbenannt werden + Konfiguration für „%1$s“ kann nicht gespeichert werden: %2$s + Konfiguration für „%s” erfolgreich gespeichert + WireGuard-Tunnel erstellen + Lokales Binärverzeichnis kann nicht erstellt werden + Datei konnte im Download-Verzeichnis nicht erstellt werden + Von Grund auf erstellen + Aus Datei oder Archiv importieren + Von QR-Code scannen + Konnte das Ausgabeverzeichnis nicht erstellen + Lokales temporäres Verzeichnis konnte nicht erstellt werden + Tunnel erstellen + %s in die Zwischenablage kopiert + Helles (Tag) Design in Verwendung + Dunkles (Nacht) Design in Verwendung + Dunkles Farbschema verwenden + Entfernen + Einen Tunnel zum Löschen auswählen + Speicherlaufwerk auswählen + Bitte installieren Sie ein Dateiverwaltungsprogramm, um Dateien zu durchsuchen + Fügen Sie einen Tunnel hinzu, um loszulegen + ♥ Spende an das WireGuard-Projekt + Jeder Beitrag hilft + Vielen Dank für Ihre Unterstützung des WireGuard-Projekts!\n\nLeider ist es uns aufgrund der Google-Richtlinien nicht gestattet, den Abschnitt der Projekt-Webseite zu verlinken, wo Sie hätten spenden können. Hoffentlich finden Sie das heraus!\n\nNochmals vielen Dank für Ihren Beitrag. + Konfigurationsexport deaktivieren + Durch Deaktivieren des Konfigurationsexports werden private Schlüssel unzugänglicher + DNS-Server + Suchdomäne + Bearbeiten + Endpunkt + Fehler beim Abschalten des Tunnels: %s + Fehler beim Abrufen der App-Liste: %s + Bitte root-Zugriff anfordern und erneut versuchen + Fehler beim Vorbereiten des Tunnels: %s + Fehler beim Starten des Tunnels: %s + Private IPs ausschließen + Neuen privaten Schlüssel generieren + Unbekannter „%s“ Fehler + (auto) + (generiert) + (optional) + (optional, nicht empfohlen) + (zufällig) + Unzulässiger Dateiname „%s“ + Kann Tunnel nicht importieren: %s + Tunnel aus QR-Code importieren + „%s” importiert + Interface + Unzulässige Zeichen im Schlüssel + Ungültige Schlüssellänge + : WireGuard base64-Schlüssel müssen 44 Zeichen enthalten (32 Bytes) + : WireGuard-Schlüssel müssen 32 Bytes groß sein + : WireGuard Hex-Schlüssel müssen 64 Zeichen (32 Bytes) groß sein + Letzter Handshake + vor %s + Eingangsport + Konnte Protokoll nicht exportieren: %s + WireGuard Android Protokolldatei + Gespeichert in „%s” + Logdatei exportieren + Protokoll speichern + Protokolle könnten beim Debuggen helfen + Anwendungs-Protokoll anzeigen + Protokoll + Konnte logcat nicht ausführen: + Das experimentelle Kernelmodul kann die Leistung verbessern + Kernelmodul-Backend aktivieren + Das langsamere Userspace-Backend kann die Stabilität verbessern + Kernelmodul-Backend deaktivieren + Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut + Das experimentelle Kernelmodul kann die Leistung verbessern + Für Ihr Gerät sind keine Module verfügbar + Kernelmodul herunterladen und installieren + Lade herunter und installiere… + Konnte Version des Kernel-Moduls nicht ermitteln + MTU + Beim Einschalten eines Tunnels werden andere ausgeschaltet + Mehrere Tunnel können gleichzeitig eingeschaltet sein + Erlaube mehrere Tunnel gleichzeitig + Name + Es wurde versucht, einen Tunnel ohne Konfiguration zu starten + Keine Konfigurationen gefunden + Keine Tunnel vorhanden + String (Zeichenkette) + IP-Adresse + Endpunkt + IP-Netzwerk + Zahl + Kann %1$s “%2$s” nicht verarbeiten + Peer + WireGuard-Tunnel steuern, Tunnel aktivieren und nach Belieben deaktivieren, wodurch der Internetverkehr möglicherweise falsch geleitet wird + WireGuard-Tunnel steuern + Dauerhaftes Keepalive + Vorab geteilter Schlüssel + aktiviert + Privater Schlüssel + Öffentlicher Schlüssel + Tipp: Mit `qrencode -t ansiutf8 < tunnel.conf` generieren. + Kachel zu Schnelleinstellungen hinzufügen + Die Verknüpfung schaltet den letzten Tunnel um + Verknüpfung kann nicht hinzugefügt werden: Fehler %d + Tunnel umschalten + Aktivierte Tunnel beim Systemstart nicht automatisch starten + Aktivierte Tunnel beim Systemstart automatisch wieder starten + Beim Neustart wiederherstellen + Speichern + Alle auswählen + Einstellungen + Shell kann den Exit-Status nicht auslesen + Die Shell erwartete 4 Marker, erhielt aber %d + Shell konnte nicht gestartet werden: %d + Erfolgreich. Die Anwendung wird nun neu starten… + Alle umschalten + Fehler beim Umschalten des WireGuard-Tunnels: %s + wg und wg-quick sind bereits installiert + Kommandozeilenwerkzeuge konnten nicht installiert werden (kein Root?) + Optionale Hilfsprogramme für Skripte installieren + Optionale Werkzeuge für das Skripten als Magisk-Modul installieren + Optionale Hilfsprogramme für Skripte auf der Systempartition installieren + wg und wg-quick als Magisk-Modul installiert (Neustart erforderlich) + wg und wg-quick auf der Systempartition installiert + Hilfsprogramme für Kommandozeile installieren + Installiere wg und wg-quick + Erforderliche Hilfsprogramme nicht verfügbar + Übertragung + %d B + %.2f GiB + %.2f KiB + %.2f MiB + empfangen: %1$s, gesendet: %2$s + %.2f TiB + Konnte kein tun-Gerät erstellen + Tunnel konnte nicht konfiguriert werden (wg-quick gab %d zurück) + Kann Tunnel nicht erstellen: %s + Tunnel „%s“ erfolgreich erstellt + Tunnel „%s“ existiert bereits + Ungültiger Name + Füge einen Tunnel über die Schaltfläche unten hinzu + Tunnelname + Tunnel kann nicht eingeschaltet werden (wgTurnOn gab %d zurück) + DNS-Hostname kann nicht aufgelöst werden: „%s“ + Kann Tunnel nicht umbenennen: %s + Tunnel erfolgreich in „%s“ umbenannt + Go userspace + Kernelmodul + Unbekannter Fehler + Ein Anwendungsupdate ist verfügbar. Bitte jetzt aktualisieren. + Herunterladen und aktualisieren + Rufe Update-Metadaten ab… + Update wird heruntergeladen: %1$s / %2$s (%3$.2f%%) + Update wird heruntergeladen: %s + Installiere Update… + Fehler beim Aktualisieren: %s. Es wird in Kürze erneut versucht… + Anwendung beschädigt + Diese Anwendung ist beschädigt. Bitte laden Sie die APK erneut von der unten verlinkten Website herunter. Deinstallieren Sie danach diese Anwendung und installieren Sie sie mit der heruntergeladenen APK neu. + Webseite aufrufen + %1$s Backend %2$s + Überprüfe %s Backend-Version + Unbekannte %s Version + WireGuard für Android v%s + VPN-Dienst durch Nutzer nicht autorisiert + Android VPN-Dienst konnte nicht gestartet werden + Kann Tunnel nicht exportieren: %s + Gespeichert unter “%s” + Zip-Datei wird im Download-Verzeichnis gespeichert + Tunnel als Zip-Datei exportieren + Authentifizieren, um Tunnel zu exportieren + Authentifizieren, um privaten Schlüssel anzuzeigen + Authentifizierungsfehler + Authentifizierungsfehler: %s + diff --git a/ui/src/main/res/values-el-rGR/strings.xml b/ui/src/main/res/values-el-rGR/strings.xml new file mode 100644 index 0000000..8c7f5f0 --- /dev/null +++ b/ui/src/main/res/values-el-rGR/strings.xml @@ -0,0 +1,144 @@ + + + + Δεν είναι δυνατή η διαγραφή του %d tunnel: %s + Δεν είναι δυνατή η διαγραφή %d tunnels: %s + + + Το %d tunnel διαγράφηκε με επιτυχία + Διαγράφηκαν επιτυχώς τα %d tunnels + + + %d επιλεγμένο tunnel + %d επιλεγμένα tunnels + + + Έγινε εισαγωγή %1$d από %2$d tunnels + Έγινε εισαγωγή %1$d από %2$d tunnels + + + Εισαγωγή tunnel %d + Εισαγωγή tunnels %d + + + %d Εξαιρούμενη εφαρμογή + %d Εξαιρούμενες εφαρμογές + + + %d Συμπεριλαμβανόμενη εφαρμογή + %d Συμπεριλαμβανόμενες εφαρμογές + + + εξαιρέθηκε %d + εξαιρέθηκε %d + + + περιλαμβάνεται %d + περιλαμβάνεται %d + + Όλες οι εφαρμογές + Εξαίρεση + Συμπεριλάβετε μόνο + + Συμπερίληψη %d app + Συμπερίληψη %d apps + + + Εξαίρεση %d app + Εξαίρεση %d apps + + + κάθε δευτερόλεπτο + κάθε %d δευτερόλεπτα + + + δευτερόλεπτο + δευτερόλεπτα + + Χρησιμοποίησε όλες τις εφαρμογές + Προσθήκη peer + Διευθύνσεις + Εφαρμογές + Οι εξωτερικές εφαρμογές δεν θα μπορούν να αλλάζουν την κατάσταση των tunnel (συνιστάται) + Οι εξωτερικές εφαρμογές θα μπορούν να αλλάζουν την κατάσταση των tunnel (για προχωρημένους) + Επιτρέψτε τον έλεγχο από άλλες εφαρμογές + Επιτρεπόμενες IP + %1$s του %2$s + %s + %1$s στο %2$s + : Πρέπει να είναι θετικό και μικρότερο από 65535 + : Πρέπει να είναι θετικό + : Πρέπει να είναι έγκυρος αριθμός θύρας UDP + Μη έγκυρο κλειδί + Μη έγκυρος αριθμός + Μη έγκυρη τιμή + Σφάλμα σύνταξης + Άγνωστη ιδιότητα + Τιμή εκτός εύρους + Το αρχείο πρέπει να είναι .conf ή .zip + Δεν βρέθηκε κωδικός QR στην εικόνα + Αποτυχία επαλήθευσης checksum κωδικού QR + Ακύρωση + Δημιουργία διόδου WireGuard + Δημιουργία από την αρχή + Εισαγωγή από αρχείο ή αρχειοθήκη + Σάρωση από κωδικό QR + Δημιουργία διόδου + Το %s αντιγράφηκε στο πρόχειρο + Χρήση σκούρου θέματος + Διαγραφή + Επιλέξτε δίοδο προς διαγραφή + Διακομιστές DNS + Αναζήτηση τομέων + Επεξεργασία + Εξαίρεση ιδιωτικών IP + Δημιουργία νέου ιδιωτικού κλειδιού + Άγνωστο σφάλμα «%s» + (αυτόματο) + (προαιρετικό) + (τυχαίο) + Μη έγκυροι χαρακτήρες στο κλειδί + Εσφαλμένο μήκος κλειδιού + : Τα κλειδιά του WireGuard πρέπει να είναι 32 bytes + πριν από %s + Θύρα ακρόασης + Εξαγωγή αρχείου καταγραφής + Αποθήκευση αρχείου καταγραφής + Αρχείο καταγραφής + MTU + Όνομα + Διεύθυνση IP + Δίκτυο IP + αριθμός + Peer + Ιδιωτικό κλειδί + Δημόσιο κλειδί + Επαναφορά κατά την εκκίνηση + Αποθήκευση + Επιλογή όλων + Ρυθμίσεις + Τα wg και wg-quick έχουν ήδη εγκατασταθεί + Εγκατάσταση εργαλείων γραμμής εντολών + Εγκατάσταση των wg και wg-quick + Τα απαραίτητα εργαλεία δεν είναι διαθέσιμα + Μεταφορά + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Μη έγκυρο όνομα + Όνομα διόδου + Άγνωστο σφάλμα + Λήψη και ενημέρωση + Λήψη ενημέρωσης: %1$s / %2$s (%3$.2f%%) + Λήψη ενημέρωσης: %s + Εγκατάσταση ενημέρωσης… + Κατεστραμμένη εφαρμογή + Άνοιγμα ιστοτόπου + Άγνωστη έκδοση %s + WireGuard για Android v%s + Αποτυχία ελέγχου ταυτότητας + Αποτυχία ελέγχου ταυτότητας: %s + diff --git a/ui/src/main/res/values-es-rES/strings.xml b/ui/src/main/res/values-es-rES/strings.xml new file mode 100644 index 0000000..1376ac3 --- /dev/null +++ b/ui/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,259 @@ + + + + Imposible eliminar %d túnel: %s + Imposible eliminar %d túneles: %s + + + Túnel eliminado correctamente %d + Túneles eliminados correctamente %d + + + %d túnel seleccionado + %d túneles seleccionados + + + Importados %1$d de %2$d túneles + Importados %1$d de %2$d túneles + + + %d túnel importado + %d túneles importados + + + %d Aplicación Excluida + %d Aplicaciones Excluidas + + + %d Aplicación Incluida + %d Aplicaciones Incluidas + + + %d excluida + %d excluidas + + + %d Incluida + %d incluidas + + Todas las Aplicaciones + Excluir + Sólo inclusión + + Incluir %d aplicación + Incluir %d aplicaciones + + + Excluir %d aplicación + Excluir %d aplicaciones + + + cada segundo + cada %d segundos + + + segundo + segundos + + Usar todas las aplicaciones + Añadir par + Direcciones + Aplicaciones + Las aplicaciones externas no pueden cambiar el estado de los túneles (recomendado) + Las aplicaciones externas pueden cambiar el estado de los túneles (avanzado) + Permitir aplicaciones de control remoto + IPs permitidas + %1$s de %2$s + %s + %1$s en %2$s + : Debe ser positivo y no más de 65535 + : Debe ser positivo + : Debe ser un número válido de puerto UDP + Clave inválida + Número inválido + Valor inválido + Falta atributo + Sección faltante + Error de sintaxis + Atributo desconocido + Sección desconocida + Valor fuera de rango + El archivo debe ser .conf o .zip + Código QR no encontrado en la imagen + Falló la verificación de la suma de comprobación del código QR + Cancelar + No se puede eliminar el archivo de configuración %s + La configuración para “%s” ya existe + El archivo de configuración “%s” ya existe + Archivo de configuración “%s” no encontrado + No se puede renombrar el archivo de configuración “%s” + No se puede guardar la configuración para “%1$s”: %2$s + Configuración guardada correctamente para “%s” + Crear túnel WireGuard + No se puede crear el directorio binario local + No se puede crear archivo en el directorio de descargas + Crear de cero + Importar desde archivo + Escanear desde código QR + No se puede crear el directorio de salida + No se puede crear la carpeta temporal + Crear túnel + %s copiado al portapapeles + Actualmente usando tema claro (día) + Actualmente usando tema oscuro (noche) + Usar tema oscuro + Eliminar + Seleccione el túnel para eliminar + Selecciones un dispositivo de almacenamiento + Por favor, instale una herramienta de gestión de archivos para navegar por archivos + Agregue un túnel para empezar + ♥ Donar al Proyecto WireGuard + Todas las contribuciones ayudan + ¡Gracias por apoyar el proyecto WireGuard!\n\nLamentablemente, debido a las políticas de Google, no estamos autorizados a enlazar a la parte de la página web del proyecto donde puedes hacer una donación. ¡Esperemos que puedas averiguar esto!\n\nGracias de nuevo por tu contribución. + Inhabilitar la exportación de configuración + Desactivar la exportación de configuración hace que las claves privadas sean menos accesibles + Servidores DNS + Buscar dominios + Editar + Endpoint + Error al bajar el túnel: %s + Error al obtener la lista de aplicaciones: %s + Por favor, obtén acceso root y vuelve a intentarlo + Error al preparar el túnel: %s + Error al abrir el túnel: %s + Excluir direcciones privadas + Generar nueva clave privada + Error desconocido “%s” + (auto) + (generado) + (opcional) + (opcional, no recomendado) + (aleatorio) + Nombre de archivo no válido “%s” + No se puede importar túnel: %s + Importar túnel desde código QR + Importado “%s” + Interfaz + Caracteres incorrectos en la clave + Longitud de clave incorrecta + Las claves base64 de WireGuard deben tener 44 caracteres (32 bytes) + : Las claves WireGuard deben tener 32 bytes + : Las claves hexadecimales de Wirex deben tener 64 caracteres (32 bytes) + Última comunicación + hace %s + Puerto de escucha + No se pudo exportar el registro: %s + Archivo de registro WireGuard Android + Guardado en “%s” + Exportar archivo de registro + Guardar registro + Los registros pueden ayudar con la depuración + Ver registro de la aplicación + Registro + No se puede ejecutar logcat: + El módulo experimental del kernel puede mejorar el rendimiento + Habilitar backend del módulo del kernel + El backend más lento del espacio de usuario puede mejorar la estabilidad + Desactivar backend del módulo del kernel + Ocurrió un error. Intente de nuevo + El módulo experimental del kernel puede mejorar el rendimiento + No hay módulos disponibles para tu dispositivo + Descargar e instalar el módulo del kernel + Descargando e instalando… + No se puede determinar la versión del módulo del kernel + MTU + Activar un túnel apagará los demás + Múltiples túneles pueden ser activados simultáneamente + Permitir múltiples túneles simultáneos + Nombre + Intentando abrir un túnel sin configuración + No se encontraron configuraciones + No existen túneles + cadena + Dirección IP + Endpoint + Red IP + número + No se puede analizar %1$s “%2$s” + Pares + controlar túneles de WireGuard, habilitando y desactivando túneles a su antojo, lo que podría conducir mal al tráfico de Internet + controlar túneles de WireGuard + Keepalive persistente + Clave precompartida + activado + Clave privada + Clave pública + Consejo: generar con `qrencode -t ansiutf8 < tunnel.conf`. + Añadir mosaico al panel de configuración rápida + El acceso directo de mosaico alterna al túnel más reciente + No se puede agregar el título del acceso directo: error %d + Alternar túnel + No mostrará túneles habilitados al arrancar + Mostrará túneles habilitados al arrancar + Restaurar al arrancar + Guardar + Seleccionar todo + Preferencias + Shell no puede leer el estado de salida + Shell esperaba 4 marcadores, recibió %d + No se pudo iniciar Shell: %d + Éxito. La aplicación se reiniciará ahora… + Cambiar Todos + Error al cambiar el túnel WireGuard: %s + wg y wg-quick ya están instalados + No se puede instalar herramientas de línea de comandos (sin root?) + Instalar herramientas opcionales para el scripting + Instalar herramientas opcionales para el scripting como módulo Magisk + Instalar herramientas opcionales para el scripting en la partición del sistema + wg y wg-quick instalados como un módulo Magisk (requiere reinicio) + wg y wg-quick instalados en la partición del sistema + Instalar herramientas de línea de comandos + Instalando wg y wg-quick + Herramientas requeridas no disponibles + Transferir + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + No se puede crear el dispositivo túnel + No se puede configurar el túnel (wg-quick devuelto %d) + No se puede crear el dispositivo túnel: %s + Túnel creado con éxito “%s” + Túnel “%s” ya existe + Nombre inválido + Añadir un túnel usando el botón azul de abajo + Nombre del túnel + No se puede activar el túnel (wgTurnOn devolvió %d) + No se puede resolver el nombre de host DNS: “%s” + No se puede renombrar túnel: %s + Túnel renombrado con éxito a “%s” + Ir al espacio de usuario + Módulo Kernel + Error desconocido + Una actualización de la aplicación está disponible. Por favor, actualice ahora. + Descargar & Actualizar + Obteniendo metadatos de actualización… + Descargando actualización: %1$s / %2$s (%3$.2f%%) + Descargando actualización: %s + Instalando actualización… + Error de actualización: %s. Se volverá a intentar en un momento… + Aplicación dañada + Esta aplicación está dañada. Por favor, vuelva a descargar el APK desde el sitio web enlazado a continuación. Después, desinstale esta aplicación y vuelva a instalarla desde el APK descargado. + Abrir sitio web + %1$s backend %2$s + Comprobando versión de backend %s + Versión %s desconocida + WireGuard para Android -%s + Servicio VPN no autorizado por el usuario + No se puede iniciar el servicio VPN Android + No se pueden exportar túneles: %s + Guardado en “%s” + El archivo Zip se guardará en la carpeta de descargas + Exportar túneles a archivo zip + Autenticar para exportar túneles + Autenticar para ver la clave privada + Error de autenticación + Error de autenticación: %s + diff --git a/ui/src/main/res/values-et-rEE/strings.xml b/ui/src/main/res/values-et-rEE/strings.xml new file mode 100644 index 0000000..9beaafd --- /dev/null +++ b/ui/src/main/res/values-et-rEE/strings.xml @@ -0,0 +1,257 @@ + + + + %d tunnelit ei saa kustutada: %s + %d tunnelit ei saa kustutada: %s + + + %d tunnel kustutatud + %d tunnelit kustutatud + + + %d tunnel valitud + %d tunnelit valitud + + + Imporditud %1$d tunnel %2$d-st + Imporditud %1$d tunnelit %2$d-st + + + Imporditud %d tunnel + Imporditud %d tunnelit + + + %d välistatud rakendus + %d välistatud rakendust + + + %d kaasatud rakendus + %d kaasatud rakendust + + + %d välistatud + %d välistatud + + + %d kaasatud + %d kaasatud + + Kõik rakendused + Välista + Kaasa ainult + + Kaasa %d rakendus + Kaasa %d rakendust + + + Välista %d rakendus + Välista %d rakendust + + + iga sekund + iga %d sekundi järel + + + sekund + sekundit + + Kasuta kõiki rakendusi + Lisa partner + Aadressid + Rakendused + Välised rakendused ei saa tunneleid lülitada (soovituslik) + Välised rakendused saavad tunneleid lülitada (edasijõudnud) + Luba kaugjuhtimise rakendused + Lubatud IP\'d + sektsiooni %1$s asukohas %2$s + %s + %1$s asukohas %2$s + : Peab olema positiivne ja mitte suurem kui 65535 + : Peab olema positiivne + : Peab olema korrektne UDP pordi number + Vigane võti + Vigane arv + Vigane väärtus + Puudub atribuut + Puudub sektsioon + Süntaksiviga + Tundmatu atribuut + Tundmatu sektsioon + Väärtus lubatud vahemikust väljas + Faililaiend peab olema .conf või .zip + QR-koodi ei leitud pildilt + QR-koodi kontrollsumma verifitseerimine ebaõnnestus + Tühista + Seadistusfaili %s kustutamine ebaõnnestus + \"%s\" seadistus on juba olemas + Seadistusfail \"%s\" on juba olemas + Seadistusfaili \"%s\" ei leitud + Seadistusfaili \"%s\" ümbernimetamine ebaõnnestus + \"%1$s\" seadistuse salvestamine ebaõnnestus: %2$s + \"%s\" seadistus salvestatud + Loo WireGuard tunnel + Lokaalse programmfaili kataloogi tekitamine ebaõnnestus + Faili loomine allalaadimiste kataloogis ebaõnnestus + Sisesta käsitsi + Impordi failist või arhiivist + Skaneeri QR-koodist + Väljundkataloogi tekitamine ebaõnnestus + Ajutise kataloogi tekitamine ebaõnnestus + Loo uus tunnel + %s kopeeritud lõikelauale + Hetkel kasutusel hele (päevane) teema + Hetkel kasutusel tume (öine) teema + Kasuta tumedat teemat + Kustuta + Vali tunnel, mida kustutada + Vali salvestusseade + Failide vaatamiseks paigalda failide haldusvahend + Alustamiseks lisa uus tunnel + ♥ Anneta WireGuard\'i projektile + Iga panus aitab + Aitäh, et toetad WireGuard\'i projekti!\n\nKahjuks ei saa me Google\'i eeskirjade tõttu linkida projekti veebilehele, kus saab annetusi teha. Loodetavasti leiad selle ise!\n\n +Aitäh veelkord sinu panuse eest. + Keela seadistuste eksportimine + Seadistuste eksportimise keelamine teeb privaatvõtmetele ligipääsu keerulisemaks + DNS-serverid + DNS-i otsingudomeenid + Muuda + Lõpp-punkt + Viga tunneli väljalülitamisel: %s + Viga rakenduste nimekirja pärimisel: %s + Hangi juurkasutaja õigused ja proovi uuesti + Viga tunneli ettevalmistamisel: %s + Viga tunneli sisselülitamisel: %s + Keela privaatsed IP\'d + Tekita uus privaatvõti + Tundmatu \"%s\" viga + (automaatne) + (genereeritud) + (valikuline) + (valikuline, mittesoovituslik) + (juhuslik) + Lubamatu failinimi \"%s\" + Tunneli importimine ebaõnnestus: %s + Impordi tunnel QR-koodist + Imporditud \"%s\" + Liides + Lubamatud sümbolid võtmes + Sobimatu võtme pikkus + : WireGuard base64 võti peab olema 44 sümbolit (32 baiti) pikk + : WireGuard võtmed peavad olema 32 baiti + : WireGuard hex võti peab olema 64 sümbolit (32 baiti) pikk + Viimane kätlus + %s tagasi + Kuulamisport + Logifaili eksportimine ebaõnnestus: %s + WireGuard Android logifail + Salvestatud faili \"%s\" + Ekspordi logifail + Salvesta logi + Logid võivad aidata vigade uurimisel + Vaata rakenduse logi + Logi + Viga logcat käivitamisel: + Eksperimentaalne tuumamoodul võib jõudlust parandada + Luba tuumamooduli kasutamine + Aeglasem kasutajamaa moodul võib stabiilsust parandada + Keela tuumamooduli kasutamine + Midagi läks valesti. Palun proovi uuesti + Eksperimentaalne tuumamoodul võib jõudlust parandada + Sinu seadme jaoks ei ole mooduleid saadaval + Laadi alla ja paigalda tuumamoodul + Allalaadimine ja paigaldamine… + Tuumamooduli versiooni tuvastamine ebaõnnestus + MTU + Ühe tunneli sisselülitamine lülitab ülejäänud välja + Mitu tunnelit saavad olla samaaegselt sisselülitatud + Luba mitu samaaegset tunnelit + Nimi + Üritan käivitada tunnelit ilma konfiguratsioonita + Seadistusi ei leitud + Tunneleid ei ole + string + IP-aadress + lõpp-punkt + IP võrk + number + Ei saa töödelda %1$s “%2$s” + Partner + kontrollida WireGuard tunneleid, neid sisse ja välja lülitades, potentsiaalselt võrguliiklust kõrvale juhtides + kontrollida WireGuard tunneleid + Püsiv ühendushoidik + Eeljagatud võti + lubatud + Privaatvõti + Avalik võti + Vihje: tekita käsuga `qrencode -t ansiutf8 < tunnel.conf`. + Lülita tunnel + Ei lülita seadme käivitumisel lubatud tunneleid sisse + Lülitab seadme käivitumisel lubatud tunnelid sisse + Taasta seadme käivitumisel + Salvesta + Vali kõik + Seaded + Kest ei saa lugeda väljundstaatust + Kest ootas 4 markerit, saadi %d + Kesta käivitumine ebaõnnestus: %d + Tegevus õnnestus. Rakendus taaskäivitub… + Vaheta kõik + WireGuard tunneli lülitamine ebaõnnestus: %s + wg ja wg-quick on juba paigaldatud + Käsurea tööriistade paigaldamine ebaõnnestus (juurkasutaja õigused puuduvad?) + Paigalda täiendavad tööriistad skriptimiseks + Paigalda täiendavad tööriistad skriptimiseks Magisk moodulina + Paigalda täiendavad tööriistad skriptimiseks süsteemipartitsioonile + wg ja wg-quick paigaldatud Magisk moodulina (vajalik taaskäivitus) + wg ja wg-quick paigaldatud süsteemipartitsioonile + Paigalda käsurea tööriistad + Paigaldatakse wg ja wg-quick + Vajalikud tööriistad ei ole saadaval + Andmemaht + %d B + %.2f GiB + %.2f KiB + %.2f MiB + sisse: %1$s, välja: %2$s + %.2f TiB + Tunnelit ei saa luua + Tunneli seadistamine ebaõnnestus (wg-quick tagastas %d) + Tunneli loomine ebaõnnestus: %s + Tunnel \"%s\" lisatud + Tunnel \"%s\" on juba olemas + Sobimatu nimi + Lisa tunnel alloleva nupu abil + Tunneli nimi + Tunneli sisselülitamine ebaõnnestus (wgTurnOn tagastas %d) + Ei saa lahendada DNS hostinime: \"%s\" + Tunneli ümbernimetamine ebaõnnestus: %s + Tunnel edukalt ümbernimetatud \"%s\" -iks + Go kasutajamaa + Tuumamoodul + Tundmatu viga + Rakenduse uuendus on saadaval. Palun uuenda nüüd. + Laadi alla ja uuenda + Uuenduse andmete laadimine… + Uuenduse allalaadimine: %1$s / %2$s (%3$.2f%%) + Uuenduse allalaadimine: %s + Uuenduse paigaldamine… + Uuendamine ebaõnnestus: %s. Uus katse hetke pärast… + Rakendus rikutud + See rakendus on rikutud. Palun laadi APK uuesti allpool lingitud veebilehelt. Pärast seda desinstalli rakendus ja installi allalaaditud APK uuesti. + Ava veebileht + %1$s taustsüsteem %2$s + Kontrollin %s taustsüsteemi versiooni + Tundmatu %s versioon + WireGuard Androidile v%s + Kasutaja ei lubanud VPN teenust + Androidi VPN teenuse käivitamine ebaõnnestus + Tunnelite eksportimine ebaõnnestus: %s + Salvestatud faili \"%s\" + Zip fail salvestatakse allalaadimiste kausta + Ekspordi tunnelid zip-faili + Autendi tunnelite eksportimiseks + Autendi privaatvõtme vaatamiseks + Autentimine ebaõnnestus + Autentimine ebaõnnestus: %s + diff --git a/ui/src/main/res/values-fa-rIR/strings.xml b/ui/src/main/res/values-fa-rIR/strings.xml new file mode 100644 index 0000000..03f01cb --- /dev/null +++ b/ui/src/main/res/values-fa-rIR/strings.xml @@ -0,0 +1,261 @@ + + + + حذف %d تونل‌ امکان‌پذیر نیست: %s + حذف %d تونل‌ها امکان‌پذیر نیست: %s + + + %d تونل با موقیت حذف شد + %d تونل‌ با موفقیت حذف شد + + + %d تونل انتخاب شد + %d تونل‌ها انتخاب شدند + + + %1$d از %2$d تونل اضافه شد + %1$d از %2$d تونل اضافه شد + + + %d تونل اضافه شد + %d از تونل اضافه شد + + + %d برنامه استثنا + %d برنامه‌های استثنا + + + %d برنامه مشمول + %d برنامه‌های مشمول + + + %d استثنا + %d استثناها + + + شامل %d + شامل %d + + همه‌ برنامه‌ها + جدا کردن + تنها شامل + + شامل %d برنامه + شامل %d برنامه + + + جداکردن %d برنامه + جداکردن %d برنامه + + + هر ثانیه + هر %d ثانیه + + + ثانیه + ثانیه + + از همه برنامه‌ها استفاده کن + افزودن همتا + نشانی‌ها + برنامه‌ها + برنامه‌های بیرونی تونل ها را عوض نکنند +(توصیه می‌شود) + برنامه‌های بیرونی تونل‌ها را عوض کنند (پیشرفته) + اجازه به برنامه‌های کنترل از راه‌دور + IPهای مجاز + %1$s\'s %2$s + %s + %1$s در %2$s + : باید مثبت و بیشتر از ۶۵۵۳۵ نباشد + : باید مثبت باشد + : باید یک شماره پورت UDP معتبر باشد + کلید نامعتبر است + شماره نامعتبر است + مقدار نامعتبر است + مشخصه موجود نیست + بخش موجود نیست + خطای نحوی + مشخصهٔ نامعلوم + بخش نامعلوم + مقدار خارج از محدوده + پرونده باید .conf یا .zip باشد + کد QR در تصویر یافت نشد + بررسی checksum کد QR ناموفق بود + لغو + نمی‌توان پرونده پیکربندی %s را حذف کرد + پیکربندی برای ”%s” در حال حاضر وجود دارد + فایل پیکربندی ”%s” در حال حاضر وجود دارد + پرونده پیکربندی “%s” یافت نشد + نمی‌توان نام پرونده پیکربندی “%s” را تغییر داد + نمی‌توان پیکربندی برای “%1$s”: %2$s را ذخیره کرد + پیکربندی برای “%s” با موفقیت ذخیره شد + ساخت تونل WireGuard + نمی‌توان دایرکتوری باینری محلی را ایجاد کرد + نمی‌توان در مسیر بارگیری پرونده‌ای ساخت + ساختن از ابتدا + واردکردن از طریق پرونده یا آرشیو + اسکن از کد QR + نمی‌توان دایرکتوری خروجی را ایجاد کرد + نمی‌توان دایرکتوری موقت محلی را ساخت + ساختن تونل + %s در کلیپ‌بورد کپی شد + اکنون از پوسته روشن(روز) استفاده می‌شود + اکنون از پوسته تاریک(شب) استفاده می‌شود + استفاده از پوسته تاریک + حذف + موارد را برای حذف انتخاب کنید + حافظه ذخیره سازی موردنظر را انتخاب کنید + برای مدیریت پوشه های حافظه، یک برنامه مدیریت فایل نصب کنید + یک پروفایل تونل برای شروع انتخاب کنید + ♥ کمک مالی به پروژه WireGuard + هر کمک شما تاثیرگذار است + از اینکه از پروژه WireGuard حمایت می‌کنید متشکریم!\n\nمتأسفانه، به دلیل خط‌مشی‌های Google، ما مجاز به پیوند دادن به بخشی از صفحه وب پروژه که می‌توانید در آن کمک مالی کنید، نداریم. امیدواریم بتوانید این را درک کنید!\n\nباز هم از مشارکت شما سپاسگزاریم. + غیرفعال سازی خروجی گرفتن از پیکربندی ها + غیرفعال سازی خروجی گرفتن، دسترسی به کلیدهای خصوصی را کم می کند + سرورهای DNS + جستوجوی دامنه‌ها + ویرایش + نقطه پایان + خطا هنگام بستن تونل: %s + خطا هنگام واکشی فهرست برنامه‌ها: %s + لطفا دسترسی روت را فراهم‌کرده و دوباره تلاش کنید + خطا هنگام راه‌اندازی تونل: %s + خطا هنگام راه‌اندازی تونل: %s + مستثنی کردن IPهای خصوصی + تولید کلید خصوصی جدید + خطای “%s” ناشناخته + (خودکار) + (تولید شده) + (دلخواه) + (اختیاری، پیشنهاد نمی‌شود) + (تصادفی) + نام پرونده “%s” غیرمجاز است + نمی‌توان تونل را وارد کرد: %s + وارد کردن تونل از کد QR + “%s” وارد شد + رابط + در کلید نویسه‌های بد وجود دارد + طول کلید نادرست است + : کلیدهای WireGuard base64 باید دارای ۴۴ نویسه باشند ( ۳۲ بایت) + : کلیدهای WireGuard باید ۳۲ بایت باشند + : کلیدهای هگز WireGuard باید دارای ۶۴ نویسه باشند ( ۳۲ بایت) + آخرین handshake + %s پیش + شنود پورت + نمی‌توان گزارش رویداد را برون‌برد: %s + پرونده گزارش رویداد WireGuard اندروید + ذخیره شد در “%s” + برون‌برد پرونده گزارش رویداد + ذخیره گزارش رویداد + گزارش رویداد شاید به اشکال زدایی کمک کند + نمایش گزارش رویداد برنامه + گزارش رویداد + نمی‌توان logcat را اجرا کرد: + ماژول آزمایشی‌ کرنل می تواند کارایی را افزایش دهد + فعال‌سازی kernel module backend + کند بودن userspace backend میتواند پایداری را بهبود ببخشد + غیرفعال‌سازی kernel module backend + مشکلی پیش آمد. لطفا دوباره تلاش کنید + ماژول آزمایشی‌ کرنل می تواند کارایی را افزایش دهد + هیچ ماژولی برای دستگاه شما در دسترس نیست + واحد هسته را بارگیری و نصب کن + در حال بارگیری و نصب… + نمی‌توان نگارش واحد هسته را مشخص کرد + MTU + روشن کردن یک تونل ، تونل های دیگر را خاموش خواهد کرد + تونل های چندتایی ممکن است استفاده همزمان را فعال کند + تونل چندگانه همزمان + نام + تلاش برای فعالسازی تونل بدون تنظیمات + هیچ پیکربندی یافت نشد + هیچ تونلی وجود ندارد + رشته + نشانی IP + نقطه پایان + شبکه IP + شماره + نمی‌توان %1$s “%2$s” تجزیه کرد + همتا + کنترل تونل های وایرگارد، فعال و غیرفعال کردن تونل ها، و حتی تغییر مسیر ترافیک اینترنت + کنترل تونل‌های WireGuard + زنده نگه‌داشتن پیوسته + کلید از پیش تقسیم شده + فعال شده + کلید خصوصی + کلید عمومی + نکته: با `qrencode -t ansiutf8 < tunnel.conf` تولید کنید. + افزودن ایکون برنامه به نوار اطلاع رسانی گوشی + ایکون برنامه در نوار اطلاع رسانی، جدید ترین تونل را فعال یا غیرفعال می‌کند + خطا در اضافه کردن ایکون برنامه در نوار اطلاع رسانی + فعال یا غیرفعال کردن تونل + تونل های فعال در لحظه بالا آمدن سیستم، روشن نخواهند شد + تونل های فعال در لحظه بالا آمدن سیستم، روشن خواهند شد + بازگردانی در بوت + ذخیره + انتخاب همه + تنظیمات + پوسته نمی تواند وضعیت خروجی را بخواند + شل انتظار داشت 4 نشانگر دریافت شود %d + آغاز پوسته شکست خورد: %d + موفقیت. برنامه اکنون دوباره راه‌اندازی خواهد شد… + معکوس کردن همه + خطا در ضامن تونل WireGuard: %s + wg و wg-quick قبلاً نصب شده اند + ابزارهای خط-فرمان نصب نمی‌شود (روت نیستید؟) + ابزارهای اختیاری را برای اسکریپت نویسی نصب کنید + ابزار اختیاری را برای اسکریپت نویسی به عنوان ماژول Magisk نصب کنید + ابزارهای اختیاری را برای اسکریپت نویسی در پارتیشن سیستم نصب کنید + wg و wg-quick به عنوان پودمان Magisk نصب شدند +( نیاز به راه‌اندازی دوباره) + wg و wg-fast در پارتیشن سیستم نصب شده است + ابزارهای خط فرمان را نصب کنید + در حال نصب wg و wg-quick + ابزارهای لازم در دسترس نیست + انتقال + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + نمی‌توان تونل را ساخت + ناتوان در کانفیگ و ساخت تونل (wg-quick returned %d) + نمی‌توان تونل را ساخت: %s + تونل “%s” با موفقیت ساخته شد + تونل “%s” از قبل وجود دارد + نام نامعتبر + به‌وسیله دکمه زیر تونلی بیفزایید + نام تونل + روشن کردن تونل امکان پذیر نیست (wgTurnOn برگشت %d ) + ناتوان در یافتن DNS نام میزبان: \"%s\" + ناتوان در تغییر نام تونل: %s + نام تونل با موفقیت تغییر یافت به “%s” + رفتن به فضای کاربر + واحد هسته + خطای نامشخص + یک بروزرسانی برای برنامه موجود است. لطفا اکنون بروزرسانی کنید. + دانلود & بروزرسانی + در حال دریافت فراداده ی بروزرسانی… + در حال دانلود بروزرسانی %1$s از %2$s (%3$.2f%%) + در حال دانلود بروزرسانی: %s + در حال نصب بروزسانی … + خطا در بروزرسانی: %s. مجددا تلاش خواهد شد… + برنامه خراب + این برنامه خراب است. لطفا مجدد فایل نصبی APK برنامه را از وبسایت های زیر دانلود کنید. سپس، این برنامه را حذف، و از طریق فایل APK برنامه سالم را نصب کنید. + باز کردن تارنما + %1$s بک اند %2$s + در حال بررسی نگارش پس‌زمینه %s + نگارش %s ناشناخته + WireGuard برای اندروید نگارش %s + کاربر به سرویس VPN اجازه نداد + نمی‌توان سرویس VPN اندروید را آغاز کرد + نمی‌توان تونل‌ها را برون‎برد: %s + ذخیره شد در “%s” + پرونده زیپ در پوشه بارگیری‌ها ذخیره خواهد شد + برون‌بری تونل‌ها در پرونده زیپ + برای برون‌بری تونل‌ها، هویت خود را تایید کنید + برای دیدن کلید خصوصی، هویت خود را تایید کنید + شکست در تایید هویت + شکست در تایید هویت: %s + diff --git a/ui/src/main/res/values-fi-rFI/strings.xml b/ui/src/main/res/values-fi-rFI/strings.xml new file mode 100644 index 0000000..d1a714e --- /dev/null +++ b/ui/src/main/res/values-fi-rFI/strings.xml @@ -0,0 +1,90 @@ + + + + Tuotiin %d tunneli + Tuotiin %d tunnelia + + + joka sekunti + %d sekunnin välein + + + sekunti + sekuntia + + Käytä kaikkia sovelluksia + Lisää toinen osapuoli + Osoitteet + Sovellukset + Sallitut IP-osoitteet + %s + : Oltava positiivinen ja enintään 65535 + : Oltava positiivinen + : Oltava kelvollinen UDP portin numero + Virheellinen avain + Virheellinen luku + Virheellinen arvo + Attribuutti puuttuu + Osio puuttuu + Syntaksivirhe + Tuntematon määrite + Tuntematon osio + Arvo alueen ulkopuolella + Tiedoston on oltava .conf tai .zip + Peruuta + Asetustiedostoa %s ei voi poistaa + Asetustiedostoa “%s” ei löydy + Asetustiedostoa \"%s\" ei voi nimetä uudelleen + Asetustiedosto \"%s\" tallennettu onnistuneesti + Luo WireGuard Tunnel + Käytä tummaa teemaa + Poista + Valitse poistettava tunneli + DNS palvelimet + Hakudomaini + Muokkaa + Päätepiste + Jätä pois yksityiset IP-osoitteet + Luo uusi yksityinen avain + Tuntematon ”%s” virhe + (oletus) + (generoitu) + (valinnainen) + (valinnainen, ei suositeltava) + (satunnainen) + Virheellinen tiedostonimi “%s” + Tunnelia \"%s\" ei voitu tuoda + Tuo tunneli QR-koodista + Tuotu ”%s” + Virheellinen merkki avaimessa + Virheellinen avaimen pituus + : WireGuardin base64-avainten pituus on oltava 44 merkkiä (32 tavua) + : WireGuard-avainten on oltava 32 tavua + : WireGuardin base64-avainten pituus on oltava 64 merkkiä (32 tavua) + Kuuntele porttia + Loki + Jokin meni pieleen. Yritä uudelleen + MTU + Nimi + Asetuksia ei löydy + Tunneleita ei ole + merkkijono + IP-osoite + IP-verkko + Osapuoli + käytössä + Yksityinen avain + Julkinen avain + Tallenna + Asetukset + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Tunnelin nimi + Tuntematon %s versio + Varmennusvirhe + Varmennusvirhe: %s + diff --git a/ui/src/main/res/values-fr/strings.xml b/ui/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..d4d4b95 --- /dev/null +++ b/ui/src/main/res/values-fr/strings.xml @@ -0,0 +1,259 @@ + + + + Impossible de supprimer %d tunnel : %s + Impossible de supprimer %d tunnels : %s + + + Suppression réussie du tunnel %d + %d tunnels supprimés avec succès + + + %d tunnel sélectionné + %d tunnels sélectionnés + + + %1$d de %2$d tunnels importés + %1$d de %2$d tunnels importés + + + %d tunnel importé + %d tunnels importés + + + %d application exclue + %d applications exclues + + + %d application incluse + %d applications incluses + + + %d exclue + %d exclues + + + %d incluse + %d incluses + + Toutes les Applications + Exclure + Inclure uniquement + + Inclure %d application + Inclure %d applications + + + Exclure %d application + Exclure %d applications + + + chaque seconde + tous les %d secondes + + + seconde + secondes + + Utiliser toutes les applications + Ajouter un pair + Adresses + Applications + Les applications externes ne peuvent pas activer les tunnels (recommandé) + Les applications externes peuvent activer les tunnels (avancés) + Autoriser les applications de contrôle à distance + Adresses IP autorisées + %1$s de %2$s + %s + %1$s dans %2$s + : Doit être positif et ne pas dépasser 65535 + : Doit être positif + : Doit être un numéro de port UDP valide + Clef invalide + Numéro invalide + Valeur invalide + Attribut manquant + Section manquante + Erreur de syntaxe + Attribut inconnu + Section inconnue + Valeur hors limite + Le fichier doit être .conf ou .zip + Le code QR est introuvable dans l’image + La vérification de la somme de contrôle du QR code a échoué + Annuler + Impossible de supprimer le fichier de configuration %s + La configuration de « %s » existe déjà + Le fichier de configuration « %s » existe déjà + Fichier de configuration « %s » introuvable + Impossible de renommer le fichier de configuration « %s » + Impossible d’enregistrer la configuration pour « %1$s » : %2$s + Configuration enregistrée avec succès pour « %s » + Créer un tunnel WireGuard + Impossible de créer le répertoire binaire local + Impossible de créer le fichier dans le répertoire des téléchargements + Créer à partir de zéro + Importer depuis un fichier ou une archive + Importer depuis un QR code + Impossible de créer le répertoire de sortie + Impossible de créer le répertoire temporaire local + Créer un tunnel + %s copié dans le presse-papier + Utilisation en cours du thème clair (jour) + Utilisation en cours du thème sombre (nuit) + Utilisez le thème sombre + Supprimer + Sélectionner le tunnel à supprimer + Sélectionner un disque de stockage + Veuillez installer un utilitaire de gestion de fichiers pour parcourir les fichiers + Ajouter un tunnel pour commencer + ♥ Faire un don au projet WireGuard + Chaque contribution aide + Merci de votre soutien au projet WireGuard !\n\nMalheureusement, en raisons des politiques de Google, nous ne pouvons pas vous rediriger vers la page vous permettant de faire un don. Heureusement, vous pouvez le trouver par vous-même !\n\nMerci encore pour votre soutien. + Désactiver l\'export de configuration + La désactivation de l\'export de configuration rend les clés privées moins accessibles + Serveurs DNS + Domaines de recherche DNS + Modifier + Point de terminaison + Erreur lors de la désactivation du tunnel : %s + Erreur lors de la récupération de la liste d\'applications : %s + Veuillez obtenir l\'accès root et essayez à nouveau + Erreur lors de la préparation du tunnel : %s + Erreur lors de la mise en place du tunnel : %s + Exclure les IPs privées + Générer une nouvelle clé privée + Erreur inconnue « %s » + (auto) + (généré) + (facultatif) + (facultatif, non recommandé) + (aléatoire) + Nom de fichier invalide “%s” + Impossible d\'importer le tunnel : %s + Importer un tunnel depuis un QR-code + Importé «%s » + Interface + Mauvais caractères dans la clé + Longueur de la clé incorrecte + : Les clés base64 WireGuard doivent comporter 44 caractères (32 octets) + : Les clés WireGuard doivent comporter 32 octets + : Les clés hexadécimales WireGuard doivent comporter 64 caractères (32 octets) + Dernière liaison + Il y a %s + Port d\'écoute + Impossible d\'exporter le journal : %s + Fichier journal d\'Android WireGuard + Enregistré dans « %s » + Exporter le fichier journal + Enregistrer le journal + Les journaux peuvent aider au débogage + Afficher le journal de l\'application + Journal + Impossible d\'exécuter logcat : + Le module expérimental du noyau peut améliorer les performances + Activer le backend du module du noyau + Le backend plus lent de l\'espace utilisateur peut améliorer la stabilité + Désactiver le backend du module du noyau + Une erreur est survenue. Veuillez réessayer + Le module expérimental du noyau peut améliorer les performances + Aucun module n\'est disponible pour votre appareil + Télécharger et installer le module du noyau + Téléchargement et installation en cours… + Impossible de déterminer la version du module noyau + MTU + Activer un tunnel éteindra les autres + Plusieurs tunnels peuvent être activés simultanément + Autoriser plusieurs tunnels simultanés + Nom + Tentative d\'établir un tunnel sans configuration + Aucune configuration trouvée + Aucun tunnel existant + chaîne de caractères + Adresse IP + point de terminaison + Réseau IP + nombre + Impossible d\'analyser %1$s “%2$s” + Pair + contrôler les tunnels WireGuard, activer et désactiver les tunnels à volonté, potentiellement détourner le trafic Internet + contrôler les tunnels WireGuard + Maintien de connexion permanente + Clé pré-partagée + activée + Clé privée + Clé publique + Astuce : générez avec \"qrencode -t ansiutf8 < tunnel.conf\". + Ajouter une bascule au volet des paramètres + Cette bascule active le dernier tunnel utilisé + Impossible d\'ajouter la bascule : erreur %d + Activer le tunnel + N\'affichera pas les tunnels activés au démarrage + Les tunnels activés seront affichés au démarrage + Restaurer au démarrage + Enregistrer + Tout sélectionner + Paramètres + L\'interpréteur de commande ne peut pas lire l\'état de sortie + L\'interpréteur de commandes attendait 4 marqueurs, %d reçus + L\'interpréteur de commande n\'a pas pu démarrer : %d + Succès. L\'application va redémarrer maintenant… + Tout basculer + Erreur lors de l\'activation du tunnel WireGuard : %s + wg et wg-quick sont déjà installés + Impossible d\'installer les outils en ligne de commande (pas de root?) + Installer les outils optionnels pour le script + Installer les outils optionnels pour le script en tant que module Magisk + Installer les outils optionnels pour le script dans la partition système + wg et wg-quick installés en tant que module Magisk (redémarrage requis) + wg et wg-quick installés dans la partition système + Installer les outils de ligne de commande + Installation de wg et wg-quick + Outils requis indisponibles + Données transférées + %d Octets + %.2f Go + %.2f Ko + %.2f Mo + rx: %1$s, tx: %2$s + %.2f To + Impossible de créer le périphérique tun + Impossible de configurer le tunnel (wg-quick a retourné %d) + Impossible de créer le tunnel : %s + Tunnel «%s » créé avec succès + Le tunnel « %s » existe déjà + Nom invalide + Ajoutez un tunnel en utilisant le bouton ci-dessous + Nom du tunnel + Impossible d\'activer le tunnel (wgTurnOn a retourné %d) + Impossible de résoudre le nom d\'hôte DNS: “%s” + Impossible de renommer le tunnel : %s + Tunnel renommé avec succès en «%s » + Implémentation Go en espace utilisateur + Module noyau + Erreur inconnue + Une mise à jour est disponible. Veuillez mettre l\'application à jour. + Télécharger & Mettre à jour + Récupération des métadonnées de la mise à jour… + Téléchargement de la mise à jour : %1$s / %2$s (%3$.2f%%) + Téléchargement de la mise à jour : %s + Installation de la mise à jour… + Erreur lors de la mise à jour : %s. Nous réessaierons dans un instant… + Application corrompue + Cette application est corrompue. Veuillez réinstaller le fichier APK depuis le site ci-dessous. Ensuite, désinstallez cette application puis réinstallez-la à l\'aide du fichier APK téléchargé. + Accéder au site internet + %1$s backend %2$s + Vérification de la version %s du backend + Version %s inconnue + WireGuard pour Android v%s + Service VPN non autorisé par l\'utilisateur + Impossible de démarrer le service VPN Android + Impossible d\'exporter les tunnels : %s + Enregistré dans « %s » + L\'archive zip sera sauvegardée dans le dossier de téléchargement + Exporter les tunnels vers le fichier zip + S\'authentifier pour exporter des tunnels + S\'authentifier pour voir la clé privée + Échec de l\'authentification + Échec de l\'authentification : %s + diff --git a/ui/src/main/res/values-hi-rIN/strings.xml b/ui/src/main/res/values-hi-rIN/strings.xml new file mode 100644 index 0000000..737f83a --- /dev/null +++ b/ui/src/main/res/values-hi-rIN/strings.xml @@ -0,0 +1,219 @@ + + + + %d टनल हटाने में असमर्थ: %s + %d टनलस को हटाने में असमर्थ: %s + + + %d टनल को सफलतापूर्वक हटा दिया गया + %d टनलस को सफलतापूर्वक हटा दिया गया + + + %d टनल चयनित + %d टनलस का चयन किया गया + + + आयातित %d %d टनल + आयातित %d %d टनलस + + + आयातित %d टनल + आयातित %d टनलस + + + %d बहिष्कृत अनुप्रयोग + %d बहिष्कृत अनुप्रयोग + + + %d ऐप्स शामिल + %d ऐप्स शामिल किये गए + + + %d अपवर्जित + %d अपवर्जित + + + %d शामिल + %d शामिल + + सभी एप्लीकेशन + वर्जित + केवल शामिल करें + + %d ऐप शामिल करें + %d ऐप्स शामिल करें + + + %d ऐप को बाहर करें + %d ऐप्स को बाहर करें + + + हर सेकंड + हर %d सेकंड्‌स + + + सेकंड + सेकंड्‌स + + सभी ऐप्स का उपयोग करें + पीयर जोड़ें + एड्रेससैस + ऍप्लिकेशन्स + बाहरी ऐप्स टनल्स को चालू नहीं कर सकते (अनुशंसित) + बाहरी ऐप्स टनल्स को चालू कर सकते है (एडवांस्ड) + रिमोट कंट्रोल ऐप्स की अनुमति दें + अनुमत आईपी + : सकारात्मक होना चाहिए और 65535 से अधिक नहीं होना चाहिए + : सकारात्मक होना चाहिए + : एक वैध यूडीपी पोर्ट नंबर होना चाहिए + अमान्य चाबी + अमान्य संख्या + अमान्य मूल्य + गुम विशेषता + छूटा हुआ भाग + वक्य रचना त्रुटि + अज्ञात एट्रिब्यूट + अज्ञात एट्रिब्यूट + मूल्य सीमा से बाहर + फ़ाइल .conf या .zip होनी चाहिए + छवि में क्यूआर कोड नहीं मिला + रद्द + कॉन्फ़िगरेशन फ़ाइल %s को नहीं हटा सकता + “%s” के लिए कॉन्फ़िगरेशन पहले से मौजूद है + कॉन्फ़िगरेशन फ़ाइल “%s” पहले से मौजूद है + कॉन्फ़िगरेशन फ़ाइल “%s” नहीं मिली + कॉन्फ़िगरेशन फ़ाइल “%s” का नाम नहीं बदल सकता + “%1$s” के लिए कॉन्फ़िगरेशन को नहीं बचा सकता: %2$s + “%s” के लिए सफलतापूर्वक सहेजा गया कॉन्फ़िगरेशन + वायरगार्ड टनल बनाएं + स्थानीय बाइनरी निर्देशिका नहीं बना सकते + डाउनलोड निर्देशिका में फ़ाइल नहीं बना सकते + शुरू से बनाएँ + फ़ाइल या संग्रह से आयात करें + QR कोड स्कैन करें + आउटपुट निर्देशिका नहीं बना सकता + स्थानीय अस्थायी निर्देशिका नहीं बना सकते + टनल बनाए + अभी प्रकाश (दिन) थीम का उपयोग कर रहे हैं + अभी डार्क (रात) थीम का उपयोग कर रहे हैं + डार्क थीम का इस्तेमाल करें + हटाएं + डीएनएस सर्वर + संपादित करें + अंतिम + टनल को लाने में त्रुटि: %s + ऐप्स सूची लाने में त्रुटि: %s + कृपया रूट एक्सेस प्राप्त करें और पुनः प्रयास करें + टनल को लाने में त्रुटि: %s + निजी आईपी को छोड़ दें + नई प्राइवेट की उत्पन्न करें + अज्ञात “%s” त्रुटि + (ऑटो) + (उत्पन्न) + (ऐच्छिक) + (वैकल्पिक, अनुशंसित नहीं) + (क्रमरहित) + अवैध फ़ाइल नाम “%s” + टनल को आयात करने में असमर्थ: %s + क्यूआर कोड से टनल को आयात करें + आयातित “%s” + इंटरफेस + चाबी में खराब वर्ण + चाबी की लम्बाई गलत + : वायरगार्ड बेस 64 कीज़ में 44 अक्षर (32 बाइट्स) होने चाहिए + : वायरगार्ड कीज 32 बाइट होनी चाहिए + : वायरगार्ड हेक्स कीज़ 64 अक्षरों की होनी चाहिए (32 बाइट्स) + पोर्ट सूने + लॉग निर्यात करने में असमर्थ: %s + WireGuard एंड्राइड लॉग फ़ाइल + “%s” में सहेजा गया + लॉग फ़ाइल निर्यात करें + लॉग सहेजे + लॉग डीबगिंग में सहायता कर सकते हैं + एप्लिकेशन लॉग देखें + लॉग + लॉगकैट चलाने में असमर्थ: + प्रयोगात्मक कर्नेल मॉड्यूल प्रदर्शन में सुधार कर सकता है + कर्नेल मॉड्यूल बैकएंड सक्षम करें + धीमे यूजरस्पेस बैकएंड में स्थिरता में सुधार हो सकता है + कर्नेल मॉड्यूल बैकएंड को अक्षम करें + कुछ गलत हो गया। कृपया पुन: प्रयास करें + प्रयोगात्मक कर्नेल मॉड्यूल प्रदर्शन में सुधार कर सकता है + आपके डिवाइस के लिए कोई मॉड्यूल उपलब्ध नहीं हैं + कर्नेल मॉड्यूल डाउनलोड और इंस्टॉल करें + डाउनलोड कर रहा है और स्थापित कर रहा है… + कर्नेल मॉड्यूल संस्करण निर्धारित करने में असमर्थ + MTU + एक टनल को चालू करने से अन्य बंद हो जाएंगे + एक साथ कई टनलस को चालू किया जा सकता है + एक साथ कई टनलस को अनुमति दें + नाम + बिना किसी कॉन्फ़िगरेशन के एक टनल को लाने की कोशिश करना + कोई कॉन्फ़िगरेशन नहीं मिला + कोई टनल मौजूद नहीं है + पाठ + आईपी पता + समाप्त + आईपी नेटवर्क + संख्या + %1$s “%2$s” को पार्स नहीं कर सकता + पीयर + वायरगार्ड टनल्स को नियंत्रित करना, टनल्स को सक्षम और अक्षम करना, संभवतः इंटरनेट ट्रैफ़िक को गलत तरीके से अक्षम करना है + वायरगार्ड टनलस को नियंत्रित करें + लगातार जिंदा रहो + प्री-शेयर्ड कीस + सक्षम + निजी कीस + सार्वजनिक कीस + सुझाव: `qrencode -t ansiutf8 < tunnel.conf` के साथ उत्पन्न करो + बूट पर सक्षम टनलस को नहीं लाएगा + बूट पर सक्षम टनलस को लाएगा + बूट पर पुनर्स्थापित करें + सहेजें + सभी का चयन करे + सेटिंग्स + शेल बाहर निकलने की स्थिति नहीं पढ़ सकता + शेल ने 4 मार्करों की अपेक्षा की, %d प्राप्त किया + शेल शुरू करने में विफल: %d + सफलता। एप्लीकेशन अब पुनः आरंभ होगा… + सबको स्विच करे + वायरगार्ड टनल टॉगल करने में त्रुटि: %s + wg और wg-quick पहले से इंस्टॉल हैं + कमांड-लाइन टूल स्थापित करने में असमर्थ (कोई रूट नहीं) + स्क्रिप्टिंग के लिए वैकल्पिक उपकरण स्थापित करें + Magisk मॉड्यूल के रूप में स्क्रिप्टिंग के लिए वैकल्पिक उपकरण स्थापित करें + सिस्टम विभाजन में स्क्रिप्टिंग के लिए वैकल्पिक उपकरण स्थापित करें + wg और wg-quick को मैजिक मॉड्यूल के रूप में स्थापित किया गया है (रिबूट आवश्यक) + wg और wg-quick सिस्टम विभाजन में स्थापित है + कमांड लाइन उपकरण स्थापित करें + Wg और wg-quick इंस्टॉल करना + आवश्यक उपकरण अनुपलब्ध हैं + स्थानांतरण + ट्यून डिवाइस बनाने में असमर्थ + टनल को कॉन्फ़िगर करने में असमर्थ (wg-quick लौटा %d) + टनल बनाने में असमर्थ: %s + सफलतापूर्वक बनाया गया टनल “%s” + टनल “%s” पहले से मौजूद है + गलत नाम + टनल का नाम + टनल चालू करने में असमर्थ (wgTurnOn लौटा %d) + टनल का नाम बदलने में असमर्थ: %s + सफलतापूर्वक टनल का नाम बदलकर “%s” कर दिया गया + userspace पे जाए + कर्नेल मॉड्यूल + अज्ञात त्रुटि + %1$s बैकएंड %2$s + %s बैकएंड संस्करण की जाँच कर रहा है + अज्ञात %s संस्करण + WireGuard for Android v%s + वीपीएन सेवा उपयोगकर्ता द्वारा अधिकृत नहीं है + एंड्रॉयड वीपीएन सेवा प्रारंभ करने में असमर्थ + टनल का निर्यात करने में असमर्थ: %s + “%s” पर सहेजा गया + ज़िप फ़ाइल को डाउनलोड फ़ोल्डर में सहेजा जाएगा + जिप फाइल के लिए टनल को एक्सपोर्ट करें + टनल्स के निर्यात के लिए प्रमाणित करें + प्राइवेट की देखने के लिए प्रमाणित करें + प्रमाणीकरण विफलता + प्रमाणीकरण विफल: %s + 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 0000000..1566d7a --- /dev/null +++ b/ui/src/main/res/values-hi/strings.xml @@ -0,0 +1,176 @@ + + + + %d टनल हटाने में असमर्थ: %s + %d टनलस को हटाने में असमर्थ: %s + + + %d टनल को सफलतापूर्वक हटा दिया गया + %d टनलस को सफलतापूर्वक हटा दिया गया + + + %d टनल चयनित + %d टनलस का चयन किया गया + + + आयातित %d %d टनल + आयातित %d %d टनलस + + + आयातित %d टनल + आयातित %d टनलस + + + %d बहिष्कृत अनुप्रयोग + %d बहिष्कृत अनुप्रयोग + + पीयर जोड़ें + एड्रेससैस + अनुमत आईपी + WireGuard + %1$s\'s %2$s + %s + %1$s in %2$s + : सकारात्मक होना चाहिए और 65535 से अधिक नहीं होना चाहिए + : सकारात्मक होना चाहिए + : एक वैध यूडीपी पोर्ट नंबर होना चाहिए + अमान्य चाबी + अमान्य संख्या + अमान्य मूल्य + गुम विशेषता + छूटा हुआ भाग + वक्य रचना त्रुटि + अज्ञात एट्रिब्यूट + अज्ञात एट्रिब्यूट + मूल्य सीमा से बाहर + फ़ाइल .conf या .zip होनी चाहिए + रद्द + कॉन्फ़िगरेशन फ़ाइल %s को नहीं हटा सकता + “%s” के लिए कॉन्फ़िगरेशन पहले से मौजूद है + कॉन्फ़िगरेशन फ़ाइल “%s” पहले से मौजूद है + कॉन्फ़िगरेशन फ़ाइल “%s” नहीं मिली + कॉन्फ़िगरेशन फ़ाइल “%s” का नाम नहीं बदल सकता + “%1$s” के लिए कॉन्फ़िगरेशन को नहीं बचा सकता: %2$s + “%s” के लिए सफलतापूर्वक सहेजा गया कॉन्फ़िगरेशन + वायरगार्ड टनल बनाएं + स्थानीय बाइनरी निर्देशिका नहीं बना सकते + शुरू से बनाएँ + आउटपुट निर्देशिका नहीं बना सकता + डाउनलोड निर्देशिका में फ़ाइल नहीं बना सकते + स्थानीय अस्थायी निर्देशिका नहीं बना सकते + टनल बनाए + अभी प्रकाश (दिन) थीम का उपयोग कर रहे हैं + अभी डार्क (रात) थीम का उपयोग कर रहे हैं + डार्क थीम का इस्तेमाल करें + हटाएं + सबको स्विच करे + डीएनएस सर्वर + संपादित करें + अंतिम + टनल को लाने में त्रुटि: %s + ऐप्स सूची लाने में त्रुटि: %s + कृपया रूट एक्सेस प्राप्त करें और पुनः प्रयास करें + टनल को लाने में त्रुटि: %s + निजी आईपी को छोड़ दें + अज्ञात “%s” त्रुटि + (ऑटो) + (उत्पन्न) + (ऐच्छिक) + (क्रमरहित) + अवैध फ़ाइल नाम “%s” + टनल को आयात करने में असमर्थ: %s + क्यूआर कोड से टनल को आयात करें + आयातित “%s” + इंटरफेस + : वायरगार्ड बेस 64 कीज़ में 44 अक्षर (32 बाइट्स) होने चाहिए + : वायरगार्ड कीज 32 बाइट होनी चाहिए + : वायरगार्ड हेक्स कीज़ 64 अक्षरों की होनी चाहिए (32 बाइट्स) + पोर्ट सूने + लॉग निर्यात करने में असमर्थ: %s + “%s” में सहेजा गया + लॉग फ़ाइल निर्यात करें + लॉगकैट चलाने में असमर्थ: + कर्नेल मॉड्यूल संस्करण निर्धारित करने में असमर्थ + आपके डिवाइस के लिए कोई मॉड्यूल उपलब्ध नहीं हैं + प्रयोगात्मक कर्नेल मॉड्यूल प्रदर्शन में सुधार कर सकता है + कर्नेल मॉड्यूल डाउनलोड और इंस्टॉल करें + डाउनलोड कर रहा है और स्थापित कर रहा है… + कुछ गलत हो गया। कृपया पुन: प्रयास करें + कर्नेल मॉड्यूल बैकएंड सक्षम करें + प्रयोगात्मक कर्नेल मॉड्यूल प्रदर्शन में सुधार कर सकता है + कर्नेल मॉड्यूल बैकएंड को अक्षम करें + धीमे यूजरस्पेस बैकएंड में स्थिरता में सुधार हो सकता है + MTU + एक साथ कई टनलस को अनुमति दें + एक साथ कई टनलस को चालू किया जा सकता है + एक टनल को चालू करने से अन्य बंद हो जाएंगे + नाम + बिना किसी कॉन्फ़िगरेशन के एक टनल को लाने की कोशिश करना + कोई कॉन्फ़िगरेशन नहीं मिला + कोई टनल मौजूद नहीं है + पाठ + आईपी पता + समाप्त + आईपी नेटवर्क + संख्या + %1$s “%2$s” को पार्स नहीं कर सकता + पीयर + वायरगार्ड टनलस को नियंत्रित करें + लगातार जिंदा रहो + प्री-शेयर्ड कीस + निजी कीस + सार्वजनिक कीस + सुझाव: `qrencode -t ansiutf8 < tunnel.conf` के साथ उत्पन्न करो + बूट पर सक्षम टनलस को लाएगा + बूट पर सक्षम टनलस को नहीं लाएगा + बूट पर पुनर्स्थापित करें + सहेजें + सभी का चयन करे + सेटिंग्स + शेल बाहर निकलने की स्थिति नहीं पढ़ सकता + शेल ने 4 मार्करों की अपेक्षा की, %d प्राप्त किया + शेल शुरू करने में विफल: %d + वायरगार्ड टनल टॉगल करने में त्रुटि: %s + wg और wg-quick पहले से इंस्टॉल हैं + कमांड-लाइन टूल स्थापित करने में असमर्थ (कोई रूट नहीं) + स्क्रिप्टिंग के लिए वैकल्पिक उपकरण स्थापित करें + Magisk मॉड्यूल के रूप में स्क्रिप्टिंग के लिए वैकल्पिक उपकरण स्थापित करें + सिस्टम विभाजन में स्क्रिप्टिंग के लिए वैकल्पिक उपकरण स्थापित करें + wg और wg-quick को मैजिक मॉड्यूल के रूप में स्थापित किया गया है (रिबूट आवश्यक) + wg और wg-quick सिस्टम विभाजन में स्थापित है + कमांड लाइन उपकरण स्थापित करें + Wg और wg-quick इंस्टॉल करना + आवश्यक उपकरण अनुपलब्ध हैं + स्थानांतरण + rx: %1$s, tx: %2$s + %d B + %.2f KiB + %.2f MiB + %.2f GiB + %.2f TiB + ट्यून डिवाइस बनाने में असमर्थ + टनल को कॉन्फ़िगर करने में असमर्थ (wg-quick लौटा %d) + टनल बनाने में असमर्थ: %s + सफलतापूर्वक बनाया गया टनल “%s” + टनल “%s” पहले से मौजूद है + गलत नाम + टनल का नाम + टनल चालू करने में असमर्थ (wgTurnOn लौटा %d) + टनल का नाम बदलने में असमर्थ: %s + सफलतापूर्वक टनल का नाम बदलकर “%s” कर दिया गया + userspace पे जाए + कर्नेल मॉड्यूल + अज्ञात त्रुटि + %1$s बैकएंड %2$s + %s बैकएंड संस्करण की जाँच कर रहा है + अज्ञात %s संस्करण + WireGuard for Android v%s + वीपीएन सेवा उपयोगकर्ता द्वारा अधिकृत नहीं है + एंड्रॉयड वीपीएन सेवा प्रारंभ करने में असमर्थ + टनल का निर्यात करने में असमर्थ: %s + “%s” पर सहेजा गया + ज़िप फ़ाइल को डाउनलोड फ़ोल्डर में सहेजा जाएगा + जिप फाइल के लिए टनल को एक्सपोर्ट करें + चाबी की लम्बाई गलत + चाबी में खराब वर्ण + diff --git a/ui/src/main/res/values-hu-rHU/strings.xml b/ui/src/main/res/values-hu-rHU/strings.xml new file mode 100644 index 0000000..f16370e --- /dev/null +++ b/ui/src/main/res/values-hu-rHU/strings.xml @@ -0,0 +1,106 @@ + + + Minden alkalmazás + Kizárás + Minden alkalmazás használata + Címek + Alkalmazások + Engedélyezett IP-k + %s + %1$s a %2$s-ben + : Legyen pozitív szám és ne legyen több mint 65535 + Pozitív számnak kell lennie + Létező UDP port számot kell megadni + Érvénytelen kulcs + Érvénytelen szám + Helytelen érték + Hiányzó tulajdonság + Hiányzó szakasz + Szintaktikai hiba + Ismeretlen tulajdonság + Ismeretlen szekció + Az érték a megengedett tartományon kívül van + A fájl .conf vagy .zip legyen + QR kód nem található a képen + QR kód ellenőrzösszeg ellenőrzési hiba + Mégse + A %s konfigurációs fájl nem törölhető + A konfiguráció “%s”-hoz már létezik + A %s konfigurációs állomány már létezik + Konfigurációs állomány \"%s\" nem található meg + A %s konfigurációs állomány nem nevezhető át + Sikerült elmenteni a “%s” nevű konfigurációt + WireGuard alagút létrehozása + Nem lehet létrehozni helyi bináris könyvtárat + Nem lehet létrehozni a fájlt a letöltések könyvtárban + Létrehozás az alapoktól + Fájlból vagy tömörített állományból importálás + Beolvasás QR kódból + Nem lehet kimeneti könyvtárat létrehozni + Nem lehet létrehozni helyi átmeneti könyvtárat + Alagút létrehozása + %s a vágólapra másolva + A \"light\" azaz világos téma van használatban + A \"dark\" azaz sötét téma van használatban + A sötét téma használata + Törlés + Alagút kiválasztása törlésre + Jelöjle ki a mentéshez a meghatjót + Kérem installáljon egy fájlkezelőt az állományok használatához + Add hozzá az indításhoz az alagutat + ♥ Kérlek támogasd a WireGuard Projectet + Minden hozzájárulás segítség + Köszönjük hogy támogatja a WireGuard projektet!\n\nSajnos a Google szabályai miatt, sajnos nem adhatjuk itt meg a támogatásokat fogadó weboldal linkjét. Remélem ezt ki tudod deríteni!\n\nMégegyszer köszönjük a támogatásod. + A konfiguráció exportálásának tiltása + Ha letiltja a konfiguráció exportját akkor a privát kulcsok nem lesznek hozzáférhetőek + DNS szerverek + Domain keresés + Szerkesztés + Végpont + Ismeretlen hiba: “%s” + (automatikus) + (generált) + (választható) + (választható, nem ajánlott) + (véletlenszerű) + Nem használható fájlnév: “%s” + Nem importálható az alagút: %s + Alagút importálása QR kódból + Importálva “%s” + Felület + Utolsó kézfogás + Figyelő port + Log exportálása fájlba + Log mentése + Alkalmazás log megtekintése + Log + MTU + Több párhuzamos alagút engedélyezése + Név + Alagutat akar indítani konfiguráció nélkül + Konfiguráció nem található + Nincs létező alagút + Karakterlánc + IP cím + végpont + IP hálózat + szám + Előre megosztott kulcs + engedélyezve + Privát kulcs + Nyilvános kulcs + Csatorna átkapcsolása + Helyreállítás bootolás alatt + Mentés + Összes kijelölése + Beállítások + Összes átkapcsolása + wg és a wg-quick már installálva van + Nem lehet installálni a parancssori alkalmazásokat (nincs root-olva?) + A(z) “%s” alagút már létezik + Érvénytelen név + Csatorna neve + Kernel modul + Ismeretlen hiba + Weboldal megnyitása + diff --git a/ui/src/main/res/values-in/strings.xml b/ui/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..d665eec --- /dev/null +++ b/ui/src/main/res/values-in/strings.xml @@ -0,0 +1,246 @@ + + + + Tidak dapat menghapus %d terowongan: %s + + + Berhasil menghapus terowongan %d + + + %d terowongan dipilih + + + Mengimpor %1$d terowongan dari %2$d + + + Mengimpor %d terowongan + + + %d Aplikasi Dikecualikan + + + %d Aplikasi Disertakan + + + %d dikecualikan + + + %d termasuk + + Semua aplikasi + Kecualikan + Berlaku hanya untuk + + Aplikasi %d yang disertakan + + + Aplikasi %d yang disertakan + + + setiap 10 detik + + + detik + + Gunakan semua aplikasi + Tambahkan rekan + Alamat + Aplikasi + Aplikasi eksternal tidak dapat mengalihkan tunnel (disarankan) + Apl eksternal dapat mengalihkan tunnel (lanjutan) + Izinkan kendali jarak jauh + IP diizinkan + %1$s %2$s + %s + %1$s di %2$s + : Harus positif dan tidak lebih dari 65535 + : Harus positif + : Harus nomor porta UDP yang sah + Kunci tidak sah + Bilngan tidak sah + Nilai tidak sah + Atribut hilang + Bagian hilang + Galat sintaks + Atribut tak diketahui + Bagian tak diketahui + Nilai di luar rentang + Berkas harus .conf atau .zip + Kode QR tidak ditemukan dalam gambar + Verifikasi ceksum kode QR gagal + Batalkan + Tidak dapat menghapus berkas konfigurasi %s + Sudah ada konfigurasi untuk “%s” + Sudah ada konfigurasi berkas “%s” + Tidak menemukan konfigurasi berkas “%s” + Tidak bisa mengganti nama konfigurasi “%s” + Konfigurasi “%1$s”: %2$s tidak bisa disimpan + Konfigurasi “%s” berhasil disimpan + Buat tunel WireGuard + Tidak dapat membuat direktori biner lokal + Tidak dapat membuat file di direktori download + Buat dari awal + Impor dari berkas atau arsip + Pindai dari kode QR + Tidak dapat membuat direktori keluaran + Tidak dapat membuat direktori lokal sementara + Buat tunel + %s Disalin ke Clipboard + Saat ini menggunakan tema terang (siang) + Saat ini menggunakan tema gelap (malam) + Gunakan tema gelap + Hapus + Pilih tunnel yang akan dihapus + Pilih lokasi penyimpanan + Silakan instal aplikasi file manajer untuk memilih file + Tambahkan tunnel untuk memulai + Donasi ke Proyek WireGuard + Setiap kontribusi anda sangat membantu + Terima kasih telah mendung Proyek WireGuard!\n\nAkan tetapi, dikarenakan kebijakan Google, Kami tidak diizinkan untuk menautkan bagian halaman website yang dapat digunakan untuk melakukan donasi. Semoga anda dapat menemukan solusi untuk kendala ini!\n\nTerima kasih sekali lagi atas kontribusi anda. + Nonaktifkan ekspor konfigurasi + Menonaktifkan ekspor konfigurasi akan membuat kunci pribadi sulit diakses + Server DNS + Cari domain + Edit + Endpoint + Kesalahan pada tunel: %s + Kesalahan mengambil daftar aplikasi: %s + Izinkan akses root dan coba lagi + Kesalahan mempersiapkan tunel: %s + Kesalahan pada tunel: %s + Kecualikan IP pribadi + Buat kunci privat baru + Eror “%s” Tidak diketahui + (otomatis) + (generate) + (pilihan) + (opsional, tidak disarankan) + (acak) + Nama file “%s” ilegal + Tunel %s tidak bisa diimpor + Mengimpor tunel dari kode QR + “%s” Sudah diimpor + Antarmuka + Karakter buruk pada kunci + Panjang kunci salah + : Kunci WireGuard base64 harus terdiri dari 44 karakter (32 bit) + : Kunci WireGuard harus terdiri dari 32 bit + : Kunci hex WireGuard Harus terdiri dari 64 karakter (32 bit) + Handshake terakhir + %s yang lalu + Isi port + Log tidak bisa diekspor: %s + Berkas Log WireGuard Android + Simpan ke “%s” + Ekspor file log + Simpan log + Log dapat membantu dengan pengawakutuan + Lihat log aplikasi + Log + Tidak bisa menjalankan logcat: + Modul kernel eksperimental dapat meningkatkan kinerja + Aktifkan backend modul kernel + Backend userspace yang lebih lambat dapat meningkatkan stabilitas + Nonaktifkan backend modul kernel + Ada yang salah. Silakan coba lagi + Modul kernel eksperimental dapat meningkatkan kinerja + Tidak tersedia modul untuk perangkat anda + Unduh dan pasang modul kernel + Mengunduh and memasang… + Tidak dapat menentukan versi modul kernel + MTU + Menyalakan satu tunel akan mematikan semuanya + Beberapa tunel dapat dihidupkan secara bersamaan + Izinkan beberapa sekaligus tunel + Nama + Lihat tunel tanpa konfigurasi + Tidak ditemukan konfigurasi + Tidak ada tunel + rangkaian + Alamat IP + titik akhir + Jaringan IP + angka + %1$s “%2$s” Tidak dapat diuraikan + Rekan + mengontrol terowongan WireGuard, mengaktifkan dan menonaktifkannya sesuka hati, berpotensi salah melalu lintas Internet + Kontrol tunel WireGuard + Keepalive persisten + Kunci Pra-bersama + diaktifkan + Kunci pribadi + Kunci publik + Tips: generate dengan `qrencode -t ansiutf8 < tunnel.conf`. + Tambah ubin untuk akses panel pengaturan cepat + Pintasan ubin mengalihkan ke tunel terbaru + Tidak dapat menambahkan pintasan: error %d + Beralih tunel + Tunel yang diaktifkan tidak akan ditampilkan saat boot + Tunel yang diaktifkan akan dimunculkan saat boot + Pulihkan saat boot + Simpan + Pilih semua + Pengaturan + Shell tidak dapat membaca status keluar + Shell diharapkan 4 nilai, diterima %d + Gagal memulai shell: %d + Berhasil. Sekarang aplikasi akan memulai ulang… + Matikan semua + Tunel WireGuard %s gagal dialihkan + wg and wg-quick sudah terpasang + Tidak dapat memasang alat command-line (tidak root?) + Pasang alat opsional untuk skrip + Pasang alat opsional untuk skrip sebagai modul Magisk + Pasang alat opsional untuk skrip ke dalam partisi sistem + wg and wg-quick dipasang sebagai modul Magisk (diperlukan mulai ulang) + wg and wg-quick dipasang ke dalam partisi sistem + Pasang alat command line + Memasang wg and wg-quick + Alat yang diperlukan tidak tersedia + Transfer + %d B + %.2f GiB + %.2f KiB + %.2f MiB + trm: %1$s, krm: %2$s + %.2f TiB + Tidak dapat membuat perangkat tun + Tidak bisa mengkonfigurasikan tunel (wg-quick %d dikembalikan) + Tunel %s tidak bisa dibuat + Tunel “%s” Berhasil dibuat + Tunel “%s” sudah ada + Nama tidak valid + Tambah tunel menggunakan tombol di bawah + Nama tunel + Tidak dapat mengaktifkan tunel (wgTurnOn %d dikembalikan) + Tidak bisa mencari nama host DNS \"%s\" + Nama tunel %s tidak bisa diganti + Berhasil mengganti nama tunnel ke “%s” + Ke userspace + Modul kernel + Eror tidak diketahui + Pembaruan aplikasi tersedia. Perbarui sekarang. + Unduh & Pembaruan + Mengambil pembaharuan metadata… + Mengunduh pembaruan: %1$s / %2$s (%3$.2f%%) + Mengunduh pembaruan: %s + Gagal menginstall… + Pembaruan gagal. %s. akan dicoba kembali sementara waktu… + Aplikasi Rusak + Aplikasi ini rusak. Silahkan unduh ulang APK-nya di website bawah ini. Setelah itu, Hapus instalan aplikasi ini dan install ulang dari APK yang telah diunduh. + Buka Website + %1$s dengan v%2$s + Mengecek versi backend %s + Versi %s Tidak diketahui + WireGuard untuk Android v%s + Layanan VPN tidak diotorisasi oleh pengguna + Tidak dapat memulai layanan VPN Android + Tunel %s tidak bisa diekspor + Disimpan ke “%s” + File Zip akan disimpan di folder download + Ekspor tunel ke file zip + Otentikasi untuk mengekspor terowongan + Otentikasi untuk melihat kunci privat + Otentikasi gagal + Otentikasi gagal: %s + 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 0000000..8fba259 --- /dev/null +++ b/ui/src/main/res/values-it/strings.xml @@ -0,0 +1,259 @@ + + + + Impossibile eliminare %d tunnel: %s + Impossibile eliminare %d tunnel: %s + + + %d tunnel eliminato correttamente + %d tunnel eliminati correttamente + + + %d tunnel selezionato + %d tunnel selezionati + + + Importato %d di %d tunnel + Importati %d di %d tunnel + + + Importato %d tunnel + Importati %d tunnel + + + %d applicazione esclusa + %d applicazioni escluse + + + %d applicazione inclusa + %d applicazioni incluse + + + %d escluse + %d escluse + + + %d inclusa + %d incluse + + Tutte le applicazioni + Escludi + Includi solo + + Includi %d applicazione + Includi %d applicazioni + + + Escludi %d applicazione + Escludi %d applicazioni + + + ogni secondo + ogni %d secondi + + + secondo + secondi + + Utilizza tutte le applicazioni + Aggiungi peer + Indirizzi + Applicazioni + Le app esterne non possono attivare tunnel (consigliato) + Le app esterne possono attivare tunnel (avanzato) + Consenti app di controllo remoto + IP consentiti + %2$s di %1$s + %s + %1$s in %2$s + : deve essere positivo e non maggiore di 65535 + : deve essere positivo + : deve essere un numero di porta UDP valido + Chiave non valida + Numero non valido + Valore non valido + Attributo mancante + Sezione mancante + Errore di sintassi + Attributo sconosciuto + Sezione sconosciuta + Valore fuori scala + Il file deve essere .conf o .zip + Codice QR non trovato nell\'immagine + Verifica checksum del codice QR fallita + Annulla + Impossibile eliminare il file di configurazione %s + La configurazione per “%s” esiste già + Il file di configurazione “%s” esiste già + File di configurazione “%s” non trovato + Impossibile rinominare il file di configurazione “%s” + Impossibile salvare la configurazione per “%1$s”: %2$s + Configurazione per “%s” salvata correttamente + Crea un tunnel WireGuard + Impossibile creare cartella locale binari + Impossibile creare il file nella cartella di download + Crea da zero + Importa da file o archivio + Scansiona da codice QR + Impossibile creare la cartella di output + Impossibile creare la cartella locale temporanea + Crea tunnel + %s copiato negli appunti + Stai usando il tema chiaro (giorno) + Stai usando il tema scuro (notte) + Usa tema scuro + Elimina + Seleziona il tunnel da eliminare + Seleziona un\'unità di archiviazione + Installa un\'utilità di gestione file per sfogliare i file + Aggiungi un tunnel per iniziare + ♥ Dona al progetto WireGuard + Ogni contributo aiuta + Grazie per il sostegno al progetto WireGuard!\n\nPurtroppo, a causa delle politiche di Google, non siamo autorizzati a linkare la pagina del progetto dove puoi fare una donazione. Speriamo che la troverai!\n\nGrazie ancora per il tuo contributo. + Disattiva esportazione config + Disabilitare l\'esportazione della configurazione rende le chiavi private meno accessibili + Server DNS + Domini di ricerca DNS + Modifica + Endpoint + Errore di disattivazione del tunnel: %s + Errore di recupero dell\'elenco applicazioni: %s + Accedi come root e riprova + Errore di preparazione del tunnel: %s + Errore di attivazione del tunnel: %s + Escludi IP privati + Genera nuova chiave privata + Errore “%s” sconosciuto + (auto) + (generata) + (facoltativa) + (facoltativo, non consigliato) + (casuale) + Nome file “%s” non valido + Impossibile importare il tunnel: %s + Importa tunnel da codice QR + Importato “%s” + Interfaccia + Caratteri non validi nella chiave + Lunghezza non valida della chiave + : le chiavi base64 di WireGuard devono essere di 44 caratteri (32 byte) + : le chiavi di WireGuard devono essere di 32 byte + : le chiavi esadecimali di WireGuard devono essere di 64 caratteri (32 byte) + Ultima negoziazione + %s fa + Porta in ascolto + Impossibile esportare il log: %s + File di log WireGuard Android + Salvato in “%s” + Esporta file di log + Salva log + I log possono aiutare in fase di debug + Visualizza log dell\'applicazione + Log + Impossibile eseguire logcat: + Il modulo sperimentale del kernel può migliorare le prestazioni + Abilita il backend del modulo del kernel + Il backend in userspace più lento potrebbe migliorare la stabilità + Disabilita il backend del modulo del kernel + Qualcosa non ha funzionato. Riprova + Il modulo sperimentale del kernel può migliorare le prestazioni + Nessun modulo disponibile per il tuo dispositivo + Scarica e installa il modulo del kernel + Scaricamento e installazione… + Impossibile determinare la versione modulo del kernel + MTU + L\'attivazione di un tunnel disattiverà gli altri + Più tunnel possono essere attivati contemporaneamente + Consenti più tunnel contemporanei + Nome + Tentativo di attivare un tunnel senza configurazione + Nessuna configurazione trovata + Non esistono tunnel + stringa + Indirizzo IP + endpoint + IP rete + numero + Impossibile analizzare %1$s “%2$s” + Peer + controllare i tunnel WireGuard, abilitando e disabilitando i tunnel a piacimento, potenzialmente dirottando il traffico Internet + controlla tunnel WireGuard + Tieni sempre attivo + Chiave condivisa (PSK) + abilitata + Chiave privata + Chiave pubblica + Suggerimento: genera con `qrencode -t ansiutf8 < tunnel.conf`. + Aggiungi riquadro ale impostazioni rapide + La scorciatoia attiva/disattiva il tunnel più recente + Impossibile aggiungere la scorciatoia: errore %d + Attiva/disattiva tunnel + Non attiverà i tunnel configurati all\'avvio + Attiverà i tunnel configurati all\'avvio + Ripristina all\'avvio + Salva + Seleziona tutto + Impostazioni + La shell non riesce a leggere lo stato di uscita + La shell si aspettava 4 marker, ne ha ricevuti %d + Avvio della shell non riuscito: %d + Successo. L\'applicazione si riavvierà… + Inverti tutto + Errore di commutazione tunnel WireGuard: %s + wg e wg-quick sono già installati + Impossibile installare strumenti di riga di comando (non root?) + Installa strumenti facoltativi per script + Installa strumenti facoltativi per script come moduli Magisk + Installa strumenti facoltativi per script nella partizione di sistema + wg e wg-quick installati come moduli Magisk (riavvio necessario) + wg e wg-quick installati nella partizione di sistema + Installa strumenti di riga di comando + Installazione di wg e wg-quick + Strumenti necessari non disponibili + Trasferisci + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Impossibile creare il dispositivo tun + Impossibile configurare il tunnel (wg-quick ha risposto %d) + Impossibile creare il tunnel: %s + Tunnel “%s” creato correttamente + Il tunnel “%s” esiste già + Nome non valido + Aggiungi un tunnel usando il pulsante sotto + Nome tunnel + Impossibile attivare il tunnel (wgTurnOn ha risposto %d) + Impossibile risolve il nome di domino: \"%s\" + Impossibile rinominare il tunnel: %s + Tunnel rinominato correttamente in “%s” + Spazio utente Go + Modulo kernel + Errore sconosciuto + È disponibile un aggiornamento dell\'app. Si prega di aggiornare ora. + Scarica e aggiorna + Recupero metadati aggiornamento… + Scaricamento aggiornamento: %1$s / %2$s (%3$.2f%%) + Scaricamento aggiornamento: %s + Installazione aggiornamento… + Aggiornamento fallito: %s. Riprovo momentaneamente… + Applicazione danneggiata + Questa applicazione è danneggiata. Riscarica l\'APK dal sito collegato qui sotto. Dopo, disinstalla questa applicazione e reinstallala dall\'APK scaricato. + Apri sito web + Backend %1$s %2$s + Controllo versione backend %s + Versione %s sconosciuta + WireGuard per Android v%s + Servizio VPN non autorizzato dall\'utente + Impossibile avviare il servizio VPN di Android + Impossibile esportare i tunnel: %s + Salvato in “%s” + Il file zip verrà salvato nella cartella di download + Esporta i tunnel in un file zip + Autenticati per esportare le chiavi i tunnel + Autenticati per visualizzare le chiavi private + Autenticazione non riuscita + Autenticazione non riuscita: %s + 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 0000000..78c8b5f --- /dev/null +++ b/ui/src/main/res/values-ja/strings.xml @@ -0,0 +1,246 @@ + + + + %d トンネルを削除できません: %s + + + %d トンネルを削除しました + + + %d トンネルを選択 + + + %2$d 個中の %1$d トンネルをインポートしました + + + %d 個のトンネル設定をインポート済 + + + 除外アプリ %d 個 + + + トンネリング対象アプリ %d 個 + + + 対象外アプリ %d 個 + + + トンネル対象アプリ %d 個 + + すべてのアプリがトンネル対象 + トンネルしない + トンネルする + + %d アプリが対象 + + + %d アプリを除外 + + + %d 秒ごと + + + + + 全アプリをトンネル対象にする + ピアを追加する + アドレス + アプリ + 外部アプリはトンネルを制御できない(推奨) + 外部アプリにトンネルの制御を許可(上級者向け) + 外部アプリからの制御 + Allowed IPs + %1$s の %2$s + %s + %2$s 中の %1$s + : 65535未満の正の整数を指定してください + : 正の整数を指定してください + : 有効な UDP ポート番号を指定してください + 無効な鍵 + 無効な数字 + 無効な値 + 属性が不足しています + セクションがありません + 構文エラー + 未知の属性 + 未知のセクション + 範囲外の値 + ファイルの拡張子は .conf か .zip です + QRコードが見つかりません + QRコードのチェックサムの確認に失敗しました + キャンセル + 設定ファイル %s を削除できません + \"%s\" の定義はすでに存在します + 設定ファイル \"%s\" はすでに存在します + 設定ファイル \"%s\" が見つかりません + 設定ファイル \"%s\" の名前を変更できません + “%1$s” の設定を保存できません: %2$s + \"%s\" の設定を保存しました + WireGuard トンネルの作成 + ローカルバイナリディレクトリを作成できません + ダウンロードディレクトリにファイルを作成できません + 空の状態から作成 + ファイル、アーカイブからインポート + QRコードをスキャン + 出力ディレクトリを作成できません + ローカルに一時ディレクトリを作成できません + トンネルを作成 + %s をクリップボードにコピーしました + ライトテーマを使用中 + ダークテーマを使用中 + ダークテーマを使用する + 削除 + 削除するトンネルを選択 + ストレージを選択 + ファイルを参照するにはファイル管理アプリをインストールしてください + トンネルを追加して開始する + ♥ WireGuard プロジェクトに寄付する + すべての貢献が役立ちます + WireGuard プロジェクトを支援していただきありがとうございます!\n\n残念ながら、Google のポリシーの影響で寄付のページへのリンクを記載することができません。見つけていただけることを願っています。\n\nもう一度、あなたの貢献に深く感謝します。 + 設定のエクスポートを無効にする + 設定のエクスポートを無効にすると、秘密鍵にアクセスされにくくなります + DNS サーバ + 検索ドメイン + 編集 + エンドポイント + トンネル停止時エラー: %s + アプリ一覧取得エラー: %s + root 権限を取得して再試行してください + トンネル準備中エラー: %s + トンネル起動時エラー: %s + プライベート IP アドレスを除外 + 新しい秘密鍵を生成する + 未知の “%s” エラー + (自動) + (生成済み) + (任意) + (任意項目ですが、設定は推奨しません) + (ランダム) + 不正なファイル名 “%s” + トンネル設定をインポートできません: %s + QR コードからトンネル設定をインポートします + “%s” をインポートしました + インターフェース + 鍵に不正な文字があります + 鍵の長さが不正です + : WireGuard base64 鍵は44文字 (32バイト) でなければなりません + : WireGuard 鍵は32バイトでなければなりません + : WireGuard hex 鍵は64文字 (32バイト) でなければなりません + 直近のハンドシェイク + %s 前 + Listen ポート + ログをエクスポートできません: %s + WireGuard Android ログファイル + “%s” に保存しました + ログのエクスポート + ログの保存 + ログはデバッグに役立ちます + アプリケーションログを表示 + ログ + logcat を実行できません: + カーネルモジュールは実験的ですがパフォーマンスが向上する可能性があります。 + カーネルモジュールバックエンドの有効化 + ユーザースペースバックエンドは低速ですが安定しています。 + カーネルモジュールバックエンドの無効化 + 失敗しました. 再度実行してみてください + 実験的カーネルモジュールはパフォーマンスが向上する場合があります + このデバイス用のモジュールは利用できません + カーネルモジュールをダウンロードしてインストールする + ダウンロードしてインストールしています… + カーネルモジュールバージョンを特定できません + MTU + トンネルを有効化すると他のトンネルは無効になります + 同時に複数のトンネルを有効化できます + 複数トンネルの同時有効化 + 名前 + 未設定のままトンネルを有効化しようとしています + 設定が見つかりません + トンネルが存在しません + 文字 + IP アドレス + エンドポイント + IP ネットワーク + 数値 + %1$s の内容を解読できません “%2$s” + ピア + WireGuard トンネルを制御し、自由に有効化/無効化できますが、インターネット向けトラフィックが意図しない方向に流れる可能性があります + WireGuard トンネルの制御 + 持続的キープアライブ + 事前共有鍵 + 有効 + 秘密鍵 + 公開鍵 + Tip: `qrencode -t ansiutf8 < tunnel.conf` で生成できます + クイック設定パネルを追加 + ショートカット・タイルを使用すると、最新のトンネルに切り替わります + ショートカット・タイルを追加できません: エラー %d + トンネルを切り替え + 起動時にトンネルを有効化しない + 起動時に、前回有効だったトンネルを有効化する + 起動時に復元 + 保存 + すべて選択 + 設定 + シェルは終了ステータスを取得できません + シェルは 4 マーカーを期待していますが、 %d マーカーを受け取りました + シェルの起動に失敗しました: %d + 成功。アプリケーションは再起動します… + すべての状態を切り替え + WireGuard トンネルの状態切り替え時にエラー: %s + wg および wg-quick はインストール済みです + コマンドラインツールをインストールできません(root権限がない?) + スクリプティングのためのオプションツールのインストール + スクリプティングのためのオプションツールを Magisk モジュールとしてインストール + スクリプティングのためのオプションツールをシステムパーティションにインストール + wg および wg-quick を Magisk モジュールとしてインストールしました (再起動必須) + wg および wg-quick をシステムパーティションにインストールしました + コマンドラインツールのインストール + wg および wg-quick のインストール + 必須のツールが利用できません + 転送 + %d B + %.2f GiB + %.2f KiB + %.2f MiB + 受信: %1$s, 送信: %2$s + %.2f TiB + tun デバイスを作成できません + トンネルを設定できません (wg-quick が %d を返却) + トンネルを作成できません: %s + トンネル \"%s\" を作成しました + トンネル “%s” はすでに存在します + 不正な名前 + 下のボタンを使用してトンネルを追加 + トンネル名 + トンネルを有効にできません (wgTurnOn が %d を返却) + ホスト名 “%s” を名前解決できませんでした + トンネル名を変更できません: %s + トンネル名を “%s” に変更しました + Go ユーザースペース + カーネルモジュール + 未知のエラー + アプリを更新できます。今すぐ更新してください。 + ダウンロードして更新 + 更新のメタデータを取得しています… + 更新のダウンロード中: %1$s / %2$s (%3$.2f%%) + 更新のダウンロード中: %s + 更新をインストール中… + 更新に失敗しました: %s. 一定時間後に再試行します… + アプリケーションが破損しています + このアプリケーションは破損しています。下記のリンク先のウェブサイトから APK を再ダウンロードしてください。その後、このアプリケーションをアンインストールし、ダウンロードした APK から再インストールしてください。 + ウェブサイトを開く + %1$s バックエンド %2$s + %s バックエンドのバージョンを確認中 + 未知の %s バージョン + WireGuard for Android v%s + VPN サービスはユーザによって認証されていません + Android VPN サービスを開始できません + トンネル設定をエクスポートできません: %s + “%s” に保存 + Zip ファイルはダウンロードフォルダに保存されます + トンネル設定を zip ファイルにエクスポート + トンネル設定をエクスポートするために認証を行います + 秘密鍵を表示するために認証を行います + 認証に失敗 + 認証に失敗: %s + diff --git a/ui/src/main/res/values-ko-rKR/strings.xml b/ui/src/main/res/values-ko-rKR/strings.xml new file mode 100644 index 0000000..8120d43 --- /dev/null +++ b/ui/src/main/res/values-ko-rKR/strings.xml @@ -0,0 +1,246 @@ + + + + %d개의 터널을 삭제할 수 없습니다: %s + + + %d개의 터널을 성공적으로 삭제했습니다 + + + %d개의 터널이 선택되었습니다 + + + %2$d개의 터널 중에서 %1$d개를 가져왔습니다 + + + %d개의 터널을 가져왔습니다 + + + %d개의 앱이 제외됨 + + + %d개의 앱이 포함됨 + + + %d개 제외됨 + + + %d개 포함됨 + + 모든 앱 + 제외 + 이것만 포함 + + %d개의 앱을 포함함 + + + %d개의 앱을 제외함 + + + %d초 마다 + + + + + 모든 앱을 사용 + 피어 추가 + 주소 + + 다른 앱이 터널을 조작할 수 없음 (권장) + 다른 앱이 터널을 조작할 수 있음 (상급자용) + 앱을 원격으로 조정할 수 있음 + 허용된 IP + %1$s의 %2$s + %s + %2$s의 %1$s + : 65535 이하의 양수여야 합니다 + : 양수여야 합니다 + : 올바른 UDP 포트가 아닙니다 + 잘못된 키 + 잘못된 숫자 + 잘못된 값 + 누락된 특성 + 누락된 섹션 + 구문 오류 + 알 수 없는 속성 + 알수 없는 섹션 + 범위를 벗어난 값 + .conf 또는 .zip 파일이어야 함 + 이미지에서 QR 코드를 찾을 수 없습니다 + QR 코드 체크섬 검증 실패 + 취소 + 설정파일 %s를 삭제할 수 없음 + \"%s\"에 대한 설정이 이미 존재함 + \"%s\" 설정 파일이 이미 존재함 + 설정파일 \"%s\"를 찾을 수 없음 + 설정파일 \"%s\"의 이름을 변경할 수 없음 + \"%1$s\"에 대한 설정을 저장할 수 없음: %2$s + \"%s\"에 대한 설정을 성공적으로 저장했습니다 + WireGuard 터널 만들기 + 로컬 바이너리 디렉터리를 만들 수 없음 + 다운로드 디렉토리에 파일을 만들 수 없음 + 직접 만들기 + 파일 또는 압축파일에서 불러오기 + QR코드 스캔 + 출력 디렉토리를 만들 수 없음 + 로컬 임시 디렉토리를 만들 수 없음 + 터널 만들기 + %s가 클립보드에 복사됨 + 밝은(주간) 테마 사용 중 + 다크(야간) 테마 사용 중 + 다크 테마 사용하기 + 삭제 + 삭제할 터널을 선택 + 저장소를 선택 + 파일을 찾는 데 사용할 파일 관리자를 설치하시오 + 시작하려면 터널을 추가하시오 + ♥ WireGuard 프로젝트에 기부해주세요 + 모든 기여가 도움이 됩니다 + WireGuard 프로젝트를 지원해 주셔서 감사합니다!\n\n안타깝게도 Google 정책으로 인해 프로젝트 웹페이지에서 기부할 수 있는 부분에 대한 링크가 허용되지 않습니다. 이 문제를 해결할 수 있기를 바랍니다!\n\n기여해 주셔서 다시 한번 감사드립니다. + 설정 내보내기 기능을 중지 + 설정 내보내기 기능을 중지하면 개인키 유출을 줄일 수 있음 + DNS 서버 + Dns 도메인 검색 +  수정 + 엔드포인트 + 터널 중단 시 오류 발생: %s + 앱 목록을 받는 도중 오류 발생: %s + 관리자 권한이 필요함 + 터널 준비 오류: %s + 터널을 시작 시 오류 발생: %s + 사설 IP 제외 + 새로운 개인 키 만들기 + 알려지지 않은 오류: “%s” + (자동) + (생성됨) + (선택사항) + (선택사항, 권장되지 않음) + (무작위) + 잘못된 파일 이름: \"%s\" + 터널을 불러올 수 없음: %s + QR코드에서 터널 불러오기 + \"%s\" 불러옴 + 인터페이스 + 키에서 잘못된 글자 발견 + 잘못된 키 길이 + : WireGuard의 base64 키는 반드시 44 글자(32 바이트)임 + : WireGuard의 키는 반드시 32 바이트임 + : WireGuard의 16진수 키는 반드시 64 글자(32 바이트)임 + 마지막 정보교환 + %s 전에 + 수신 대기 포트 + 로그를 내보낼 수 없음: %s + WireGuard 안드로이드 로그 파일 + “%s”에 저장됨 + 로그 파일 내보내기 + 로그를 저장하기 + 로그는 디버깅에 활용됨 + 앱 로그 보기 + 로그 + logcat을 실행할 수 없음: + 아직 실험중이 커널 모듈을 사용하면 성능이 향상될 수 있음 + 커널 모듈 백엔드 활성화하기 + 사용자공간 백엔드를 사용하면 느리지만 안정성이 좋아짐 + 커널 모듈 백엔드를 비활성화하기 + 문제가 발생했습니다. 다시 시도하십시오 + 아직 실험중이 커널 모듈을 사용하면 성능이 향상될 수 있음 + 이 기기에서 사용가능한 모듈이 없음 + 커널 모듈을 다운로드하고 설치하기 + 다운로드 및 설치 중… + 커널 모듈 버전을 인식할 수 없음 + MTU + 한 터널을 켜면 다른 터널은 꺼짐 + 여러 터널이 동시에 켜질 수 있음 + 여러 터널을 동시에 사용하기 + 이름 + 아무 설정 없이 터널을 시작할 수 없음 + 설정을 찾을 수 없음 + 터널이 존재하지 않음 + 문자열 + IP 주소 + 엔드포인트 + IP 네트워크 + 횟수 + %1$s을 파싱할 수 없음: “%2$s” + 피어 + 마음대로 터널을 활성화 및 비활성화하는 등 WireGuard 터널을 제어하면, 인터넷 트래픽을 잘못 전달할 위험이 있음 + WireGuard 터널 제어 + Persistent keepalive + 사전 공유 키 + 활성화됨 + 개인 키 + 공개 키 + 팁: `qrencode -t ansiutf8 < tunnel.conf` 로 생성가능함. + 빠른 설정 패널에 타일 추가 + 바로가기 타일은 가장 최근 터널을 전환합니다 + 바로가기 타일을 추가할 수 없음: 오류 %d + 터널 전환 + 부팅 시 활성화된 터널들을 켜지 않음 + 부팅 시 활성화된 터널들을 켬 + 부트 후 복구 + 저장 + 모두 선택 + 설정 + 쉘은 종료 상태를 읽을 수 없음 + 쉘은 4 개의 마커를 받아야 하지만 %d 개만 받음 + 쉘이 실행 실패함: %d + 성공. 앱이 곧 재시작됨… + 모두 반전 + WireGuard 터널 토글링 오류: %s + wg와 wg-quick 모두 이미 설치되었음 + 커맨드라인 도구 설치 불가 (루트 권한 필요) + 스크립팅에 필요한 선택적 도구 설치하기 + Magisk 모듈로 설치할 때의 스크립팅에 필요한 선택적 도구 설치하기 + 시스템 파티션에 설치할 때의 스크립팅에 필요한 선택적 도구 설치하기 + wg와 wg-quick가 Magisk 모듈로 설치됨 (재부팅 필요) + wg와 wg-quick가 시스템 파티션에 설치됨 + 커맨드라인 도구 설치하기 + wg와 wg-quick 설치하는 중 + 필요한 도구를 사용할 수 없음 + 전송 + %d B + %.2f GiB + %.2f KiB + %.2f MiB + 수신: %1$s, 송신: %2$s + %.2f TiB + tun 장치를 만들 수 없음 + 터널을 설정할 수 없음 (wg-quick가 %d을 반환함) + 터널을 생성할 수 없음: %s + 터널 \"%s\"을 성공적으로 생성함 + 터널 \"%s\"가 이미 존재함 + 잘못된 이름 + 아래 버튼을 사용하여 터널을 추가하세요 + 터널 이름 + 터널을 켤 수 없음 (wgTurnOn이 %d를 반환함) + DNS 호스트 이름을 확인할 수 없음: “%s” + 터널 이름을 바꿀 수 없음: %s + 터널 이름을 \"%s\"로 변경 성공 + Go userspace + 커널 모듈 + 알 수 없는 오류 + 애플리케이션 업데이트를 사용할 수 있습니다. 지금 업데이트하세요. + 다운로드 & 업데이트 + 업데이트 메타데이터를 가져오는 중… + 업데이트 다운로드 중: %1$s / %2$s (%3$.2f%%) + 업데이트 다운로드 중: %s + 업데이트 설치 중… + 업데이트 실패: %s. 잠시 후 다시 시도합니다… + 애플리케이션 손상 + 이 응용 프로그램이 손상되었습니다. 아래 링크된 웹사이트에서 APK를 다시 다운로드하세요. 그런 다음 이 애플리케이션을 제거하고 다운로드한 APK에서 다시 설치하세요. + 웹사이트 열기 + %1$s 백엔드 %2$s + %s 백엔드 버전 확인 중 + 알 수 없는 버전: %s + WireGuard for 안드로이드 v%s + VPN 서비스는 사용자에 의해 승인되지 않았음 + 안드로이드 VPN 서비스를 시작할 수 없음 + 터널을 내보낼 수 없음: %s + “%s”에 저장됨 + Zip 파일은 다운로드 폴더에 저장됨 + 터널들을 Zip 파일에 내보내기 + 터널들을 내보내기 위한 인증과정 + 개인 키를 보기 위한 인증과정 + 인증 실패 + 인증 실패: %s + 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 0000000..f59fc1a --- /dev/null +++ b/ui/src/main/res/values-night/bools.xml @@ -0,0 +1,8 @@ + + + false + false + diff --git a/ui/src/main/res/values-night/logviewer_colors.xml b/ui/src/main/res/values-night/logviewer_colors.xml new file mode 100644 index 0000000..a029a0c --- /dev/null +++ b/ui/src/main/res/values-night/logviewer_colors.xml @@ -0,0 +1,10 @@ + + + #aaaaaa + #ff0000 + #00ff00 + #ffff00 + diff --git a/ui/src/main/res/values-night/themes.xml b/ui/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..331a10c --- /dev/null +++ b/ui/src/main/res/values-night/themes.xml @@ -0,0 +1,34 @@ + + + + diff --git a/ui/src/main/res/values-nl-rNL/strings.xml b/ui/src/main/res/values-nl-rNL/strings.xml new file mode 100644 index 0000000..0aaa7c7 --- /dev/null +++ b/ui/src/main/res/values-nl-rNL/strings.xml @@ -0,0 +1,251 @@ + + + + Kan %d tunnel niet verwijderen: %s + Kan %d tunnels niet verwijderen: %s + + + %d tunnel succesvol verwijderd + %d tunnels succesvol verwijderd + + + %d tunnel geselecteerd + %d tunnels geselecteerd + + + %1$d van %2$d tunnels geïmporteerd + %1$d van de %2$d tunnels geïmporteerd + + + %d tunnel geïmporteerd + %d tunnels geïmporteerd + + + %d uitgesloten applicatie(s) + %d uitgesloten applicaties + + + %d inbegrepen applicatie + %d inbegrepen applicaties + + + %d uitgesloten + %d uitgesloten + + + %d inbegrepen + %d inbegrepen + + Alle applicaties + Uitsluiten + Alleen opnemen + + Neem %d app op + Voeg %d apps toe + + + %d app uitsluiten + %d apps uitsluiten + + + iedere seconde + iedere %d seconden + + + seconde + seconden + + Gebruik alle applicaties + Peer toevoegen + Adressen + Applicaties + Externe apps kunnen mogelijk geen tunnels in-/uitschakelen (aanbevolen) + Externe apps kunnen tunnels in-/uitschakelen (geavanceerd) + Controle door externe besturingsapps toestaan + Toegestane IP-adressen + %1$s\'s %2$s + %s + %1$s in %2$s + : moet positief zijn en niet meer dan 65535 + : Moet positief zijn + : Moet een geldig UDP poortnummer zijn + Ongeldige sleutel + Ongeldig nummer + Ongeldige waarde + Attribuut ontbreekt + Ontbrekende sectie + Syntaxfout + Onbekend attribuut + Onbekende sectie + Waarde buiten bereik + Bestand moet .conf of .zip zijn + QR-code niet gevonden in afbeelding + QR-code checksum verificatie mislukt + Annuleren + Kan configuratiebestand %s niet verwijderen + Configuratie voor \"%s\" bestaat al + Configuratiebestand \"%s\" bestaat al + Configuratiebestand \"%s\" niet gevonden + Kan configuratiebestand \"%s\" \" niet hernoemen + Kan de configuratie voor \"%1$s\" niet opslaan: %2$s + Configuratie succesvol opgeslagen voor \"%s\" + WireGuard tunnel aanmaken + Kan geen lokale \'bin\' map aanmaken + Kan bestand niet maken in downloadmap + Begin met lege configuratie + Importeren uit bestand of archief + Scan van QR code + Kan de output map niet aanmaken + Kan geen tijdelijke map aanmaken + Maak nieuwe tunnel + %s gekopieerd naar klembord + Momenteel wordt licht (dag) thema gebruikt + Momenteel wordt donker (nacht) thema gebruikt + Gebruik donker thema + Verwijder + Selecteer tunnel om te verwijderen + Selecteer een opslaglocatie + Installeer een bestandsbeheer applicatie + Voeg een tunnel toe om te beginnen + ♥️ Doneer aan het WireGuard Project + Elke bijdrage helpt + Bedankt voor het steunen van het WireGuard Project!\n\nHelaas, als gevolg van Google beleid, We mogen niet linken naar de webpagina van het project waar u een donatie kunt doen. Hopelijk kunt u deze zelf wel vinden!\n\nNogmaals bedankt voor uw bijdrage. + Config export uitschakelen + Het uitschakelen van configuratie export maakt privésleutels minder toegankelijk + DNS-servers + DNS-zoekdomeinen + Bewerken + Eindpunt + Fout bij stoppen tunnel: %s + Fout bij ophalen van apps-lijst: %s + Verkrijg root toegang en probeer het opnieuw + Fout bij voorbereiden tunnel: %s + Fout bij het starten van tunnel: %s + Privé-IP\'s uitsluiten + Nieuwe privésleutel genereren + Onbekend fout: \"%s\" + (auto) + (gegenereerd) + (optioneel) + (optioneel, niet aanbevolen) + (willekeurig) + Ongeldige bestandsnaam \"%s\" \" + Kan tunnel niet importeren: %s + Importeer Tunnel uit QR Code + Geïmporteerd \"%s\" + Interface + Slechte tekens in de veld + Onjuiste sleutellengte + : WireGuard base64 sleutels moeten 44 tekens zijn (32 bytes) + : WireGuard sleutels moeten 32 bytes zijn + : WireGuard hex sleutels moeten 64 tekens zijn (32 bytes) + Recentste uitwisseling + %s geleden + Luister op poort + Kan logboek niet exporteren: %s + WireGuard Android logbestand + Opgeslagen in \"%s\" + Exporteer logboek naar bestand + Logboek opslaan + Logboeken kunnen helpen bij het debuggen + Bekijk applicatielogboek + Log + Kan logcat niet uitvoeren: + De experimentele kernel module kan de prestaties verbeteren + Kernel module backend inschakelen + De langzamere userspace backend kan de stabiliteit verbeteren + Uitschakelen kernel module backend + Er ging iets mis. Probeer het nog eens + De experimentele kernel module kan de prestaties verbeteren + Er zijn geen modules beschikbaar voor uw apparaat + Download en installeer kernel module + Downloaden en installeren… + Niet in staat om kernel module versie te bepalen + MTU + Het inschakelen van één tunnel zal anderen uitzetten + Meerdere tunnels kunnen tegelijkertijd actief zijn + Meerdere gelijktijdige tunnels toestaan + Naam + Probeer een tunnel zonder configuratie te starten + Geen configuraties gevonden + Geen tunnels gedefinieerd + string + IP-adres + eindpunt + IP netwerk + nummer + Kan %1$s%2$s niet parsen + Peer + beheer WireGuard tunnels, zet tunnels naar keuze aan en uit, en misleid mogelijk het Internetverkeer + WireGuard tunnels beheren + Voortdurende verbindingstest + Gedeelde sleutel + ingeschakeld + Privésleutel + Publieke sleutel + Tip: genereer met `qrencode -t ansiutf8 < tunnel.conf`. + Voeg tegel toe aan snelle instellingen + De sneltoets schakelt de meest recente tunnel aan + Kan geen sneltoets toevoegen: fout %d + tunnel in-/uitschakelen + Zal ingeschakelde tunnels niet aanzetten bij opstarten + Zal ingeschakelde tunnels aanzetten bij opstarten + Tunnel starten bij herstart + Opslaan + Selecteer alles + Instellingen + Shell kan de exitstatus niet lezen + Shell verwachtte 4 markeringen, ontving er %d + Shell kon niet starten: %d + Succes. De toepassing zal nu herstarten… + Alles wisselen + Fout bij omschakelen Wireguard tunnel: %s + wg and wg-quick zijn al geïnstalleerd + Kan de command-line tools niet installeren (geen root?) + Optionele tools voor scripts installeren + Optionele tools voor het scripting als Magisk module installeren + Optionele tools voor scripting in de systeempartitie installeren + wg en wg-quick installeren als een Magisk-module (herstart vereist) + \"Wg en wg-quick\" geïnstalleerd in de systeempartitie + Installeer command line tools + Installeren van wg en wg-quick + Vereiste tools niet beschikbaar + Transfer + %d B + %.2f GiB + %.2f KiB + %.2f MiB + ontvangen: %1$s, verzonden: %2$s + %.2f TiB + Kan tun apparaat niet aanmaken + Kan tunnel %s niet creëren + Tunnel succesvol aangemaakt \"%s\" + Tunnel \"%s\" bestaat al + Ongeldige naam + Voeg een tunnel toe met de knop hieronder + Tunnelnaam + Kan tunnel niet hernoemen: %s + Tunnel succesvol hernoemd naar \"%s\" + Go userspace + Kernel module + Onbekende fout + Er is een nieuwe versie beschikbaar. Update a.u.b. + Download & installeer updates + Update metadata downloaden… + Ophalen nieuwe versie: %1$s / %2$s (%3$.2f%%) + Updates downloaden: %s + Update wordt geïnstalleerd… + Bijwerken mislukt: %s. Zal het zo opnieuw proberen… + Toepassing beschadigd + De toepassing is beschadigd. Download de APK opnieuw van de onderstaande website. De-installeer daarna het programma, en herinstalleer het met de gedownloade APK. + Open website + Onbekende versie: %s + WireGuard voor Android v%s + VPN-service niet geautoriseerd door gebruiker + Zip-bestand wordt opgeslagen in de downloadmap + Exporteer tunnels naar zip-bestand + Authenticeer om de tunnel configuratie te exporteren + Authenticeer om de persoonlijke sleutel te bekijken + Authenticatiefout + Authenticatiefout: %s + diff --git a/ui/src/main/res/values-no-rNO/strings.xml b/ui/src/main/res/values-no-rNO/strings.xml new file mode 100644 index 0000000..244cab5 --- /dev/null +++ b/ui/src/main/res/values-no-rNO/strings.xml @@ -0,0 +1,259 @@ + + + + Kan ikke slette %d tunnel %s + Kan ikke slette %d tunneler %s + + + Slettet %d tunnel + Slettet %d tunneler + + + %d tunnel valgt + %d tunneler valgt + + + Importerte %1$d av %2$d tunneler + Importerte %1$d av %2$d tunneler + + + Importerte %d tunnel + Importerte %d tunneler + + + %d ekskludert app + %d ekskluderte apper + + + %d inkludert app + %d inkluderte apper + + + %d ekskludert + %d ekskluderte + + + %d inkludert + %d inkluderte + + Alle applikasjoner + Ekskluder + Inkluder kun + + Inkluder %d app + Inkluder %d apper + + + Ekskluder %d app + Ekskluder %d apper + + + hvert %d sekund + hvert %d sekund + + + sekund + sekunder + + Bruk alle apper + Legg til peer + Adresser + Applikasjoner + Fjernstyrte apper kan ikke toggle tunneler (anbefalt) + Fjernstyrte apper toggle tunneler (avansert) + Tillat fjernstyrte apper + Tillatte IP-adresser + %1$s sin %2$s + %s + %1$s i %2$s + : Må være positiv og ikke mer enn 65535 + : Må være positiv + : Må være et gyldig UDP portnummer + Ugyldig tegn + Ugyldig tall + Ugyldig verdi + Manglende attributt + Manglende seksjon + Syntaksfeil + Ukjent attributt + Ukjent seksjon + Verdien er utenfor gyldig område + Filen må være .conf eller .zip + Ingen QR-kode funnet i bildet + Feil ved sjekksumverifisering av QR-kode + Avbryt + Kan ikke slette konfigurasjonsfilen %s + Konfigurasjon for «%s» finnes allerede + Konfigurasjonsfil «%s» finnes allerede + Konfigurasjonsfil «%s» ble ikke funnet + Kan ikke endre navn på konfigurasjonsfilen «%s» + Kan ikke lagre konfigurasjonen for “%1$s”: %2$s + Vellykket lagring av konfigurasjon for «%s» + Opprett WireGuard tunnel + Kan ikke opprette lokal binærmappe + Kan ikke opprette fil i nedlastingsmappen + Lag fra begynnelsen + Importer fra fil eller arkiv + Skann QR-kode + Kan ikke opprette utdata-katalog + Kan ikke opprette lokal, midlertidig mappe + Opprett tunnel + %s kopiert til utklippstavlen + Bruker nå lyst (dag) tema + Bruker nå mørkt (natt) tema + Bruk mørkt tema + Slett + Velg tunnel som skal slettes + Velg en lagringsenhet + Vennligst installer et filhåndteringsverktøy for å bla i filer + Opprett en ny tunnel for å komme i gang + ♥ Donér til WireGuard-prosjektet + Hvert bidrag hjelper + Takk for at du støtter WireGuard-prosjektet!\n\nPå grunn av Googles retningslinjer, kan vi dessverre ikke linke til den delen av prosjektets nettside der du kan donere. Forhåpentligvis klarer du å finne denne selv!\n\nVi takker igjen for ditt bidrag. + Deaktiver eksport av konfigurasjon + Deaktivering av konfigurasjonseksport gjør private nøkler mindre tilgjengelig + DNS tjenere + Søk gjennom domener + Rediger + Endepunkt + Feil når tunnel skulle tas ned: %s + Feil ved henting av applikasjonsliste: %s + Vennligst få root-tilgang og prøv igjen + Feil ved klargjøring av tunnel: %s + Feil når tunnel skulle tas opp: %s + Utelukk private IP-adresser + Lag ny privat nøkkel + Ukjent «%s» feil + (automatisk) + (generert) + (valgfritt) + (valgfritt, anbefales ikke) + (tilfeldig) + Ugyldig filnavn «%s» + Kan ikke importere tunnel: %s + Importer tunnel fra QR-kode + Importerte «%s» + Grensesnitt + Ugyldig tegn i nøkkel + Ugyldig nøkkellengde + : WireGuard base64-nøkler må være 44 tegn (32 byte) + : WireGuard nøkler må være 32 byte + : WireGuard hex-nøkler må være 64 tegn (32 byte) + Siste håndtrykk + for %s siden + Lytt på port + Kan ikke eksportere logg: %s + WireGuard Android loggfil + Lagret til «%s» + Eksporter loggfil + Lagre logg + Logger kan være til hjelp med feilsøking + Vis programlogg + Logg + Kan ikke kjøre logcat: + Den eksperimentelle kjernemodulen kan gi bedre ytelse + Aktiver backend for kjerne-modul + Backend i userspace er litt tregere men kan gi bedre stabilitet + Deaktiver backend for kjerne-modul + Noe gikk galt. Vennligst prøv igjen + Den eksperimentelle kjernemodulen kan gi bedre ytelse + Ingen moduler er tilgjengelige for din enhet + Last ned og installer kjernelenmodul + Laster ned og installerer… + Kunne ikke finne versjon av kjerne-modulen + MTU + Når en tunnel slås på vil andre slås av + Flere tunneler kan være aktivert samtidig + Tillat flere samtidige tunneler + Navn + Prøver å få opp en tunnel uten konfigurasjon + Ingen konfigurasjoner funnet + Ingen tunneler er opprettet + streng + IP-adresse + endepunkt + IP nettverk + nummer + Kan ikke tolke %1$s “%2$s” + Partner + kontroller WireGuard-tunneler, aktiver og deaktiver dem. Kan potensielt ta ned internett-tilgangen + kontroller WireGuard-tunneler + Send keepalive-pakker + Forhåndsdelt nøkkel + aktivert + Privat Nøkkel + Offentlig nøkkel + Tips: generer med `qrencode -t ansiutf8 < tunnel.conf`. + Legge til i hurtiginnstillingspanelet + Snarveien i hurtiginstillinger viser den siste tilkoblede tunnelen + Kunne ikke legge til snarveis: feil %d + Koble til tunnel + Vil ikke ta opp aktive tunneler ved oppstart + Vil ta opp aktive tunneler ved oppstart + Gjenopprett ved oppstart + Lagre + Velg alle + Innstillinger + Skallet kan ikke lese avslutningsstatus + Skallet forventet 4 markører, mottok %d + Skallet klarte ikke å starte: %d + Suksess. Programmet vil nå starte på nytt… + Toggle alle + Feil under toggling av WireGuard tunnel: %s + wg og wg-quick er allerede installert + Kunne ikke installere kommandolinjeverktøy (ingen root-tilgang?) + Installer valgfrie verktøy for skripting + Installer valgfrie verktøy for skripting som Magisk modul + Installer valgfrie verktøy for skripting på systempartisjonen + wg og wg-quick installert som en Magisk modul (omstart kreves) + wg og wg-quick installert på systempartisjonen + Installer kommandolinjeverktøy + Installerer wg og wg-quick + Påkrevde verktøy er ikke tilgjengelig + Overfør + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Kan ikke opprette tun enhetsfil + Kunne ikke konfigurere tunnel (wg-quick returnerte %d) + Kan ikke opprette tunnel: %s + Opprettet tunnelen «%s» + Tunnel «%s» finnes allerede + Ugyldig navn + Legg til en tunnel ved å bruke knappen under + Tunnelnavn + Kan ikke slå på tunnel (wgTurnOn returnerte %d) + Kan ikke slå opp DNS-vertsnavn: “%s\" + Kan ikke endre navn på tunnel: %s + Endret navn på tunnelen til «%s» + Bruk userspace + Kjernemodul + Ukjent feil + En programoppdatering er tilgjengelig. Oppdater nå. + Last ned & oppdatering + Henter metadata for oppdatering… + Laster ned oppdatering: %1$s / %2$s (%3$.2f%%) + Laster ned oppdatering: %s + Installler oppdatering… + Oppdateringen feilet: %s. Vil prøve igjen øyeblikkelig… + Applikasjonen feilet + Dette programmet feilet. Last ned APK på nytt fra nettstedet koblet til nedenfor. Avinstaller dette programmet og installer den nedlastede APK. + Åpne nettsiden + %1$s backend %2$s + Sjekker %s backend versjon + Ukjent %s versjon + WireGuard for Android v%s + VPN-tjeneste er ikke autorisert av bruker + Kan ikke starte Android VPN-tjenesten + Kan ikke eksportere tunneler: %s + Lagret til «%s» + Zip-filen vil bli lagret i nedlastingsmappen + Eksporter tunneler til zip-fil + Autentiser for å eksportere tunneler + Autentiser for å vise privatnøkkel + Autentiseringsfeil + Autentiseringsfeil: %s + diff --git a/ui/src/main/res/values-pa-rIN/strings.xml b/ui/src/main/res/values-pa-rIN/strings.xml new file mode 100644 index 0000000..8ab9d4c --- /dev/null +++ b/ui/src/main/res/values-pa-rIN/strings.xml @@ -0,0 +1,259 @@ + + + + %d ਟਨਲ ਹਟਾਉਣ ਲਈ ਅਸਮਰੱਥ: %s + %d ਟਨਲਾਂ ਹਟਾਉਣ ਲਈ ਅਸਮਰੱਥ: %s + + + %d ਟਨਲ ਕਾਮਯਾਬੀ ਨਾਲ ਹਟਾਈ + %d ਟਨਲਾਂ ਕਾਮਯਾਬੀ ਨਾਲ ਹਟਾਈਆਂ + + + %d ਟਨਲ ਚੁਣੀ + %d ਟਨਲਾਂ ਚੁਣੀਆਂ + + + %2$d ਟਨਲਾਂ ਵਿੱਚੋਂ %1$d ਇੰਪੋਰਟ ਕੀਤੀ + %2$d ਟਨਲਾਂ ਵਿੱਚੋਂ %1$d ਇੰਪੋਰਟ ਕੀਤੀਆਂ + + + %d ਟਨਲ ਇੰਪੋਰਟ ਕੀਤੀ + %d ਟਨਲਾਂ ਇੰਪੋਰਟ ਕੀਤੀਆਂ + + + %d ਅਲਹਿਦਾ ਕੀਤੀ ਐਪਲੀਕੇਸ਼ਨ + %d ਅਲਹਿਦਾ ਕੀਤੀਆਂ ਐਪਲੀਕੇਸ਼ਨਾਂ + + + %d ਐਪਲੀਕੇਸ਼ਨ ਸਮੇਤ + %d ਐਪਲੀਕੇਸ਼ਨਾਂ ਸਮੇਤ + + + %d ਅਲਹਿਦਾ ਰੱਖਿਆ + %d ਅਲਹਿਦਾ ਰੱਖੇ + + + %d ਸਮੇਤ + %d ਸਮੇਤ + + ਸਾਰੀਆਂ ਐਪਲੀਕੇਸ਼ਨਾਂ + ਅਲਹਿਦਾ + ਸਿਰਫ਼ ਸ਼ਾਮਲ + + %d ਐਪ ਸ਼ਾਮਲ ਕਰੋ + %d ਐਪਾਂ ਸ਼ਾਮਲ ਕਰੋ + + + %d ਐਪ ਅਲਹਿਦਾ ਰੱਖੋ + %d ਐਪਾਂ ਅਲਹਿਦਾ ਰੱਖੋ + + + ਹਰ ਸਕਿੰਟ + ਹਰ %d ਸਕਿੰਟ + + + ਸਕਿੰਟ + ਸਕਿੰਟ + + ਸਾਰੀਆਂ ਐਪਾਂ ਵਰਤੋਂ + ਪੀਅਰ ਜੋੜੋ + ਸਿਰਨਾਵੇ + ਐਪਲੀਕੇਸ਼ਨਾਂ + ਬਾਹਰੀ ਐਪਾਂ ਟਨਲਾਂ ਨੂੰ ਬਦਲ ਨਹੀਂ ਸਕਦੀਆਂ (ਸਿਫਾਰਸ਼ੀ) + ਬਾਹਰੀ ਐਪਾਂ ਟਨਲਾਂ ਨੂੰ ਬਦਲ ਸਕਦੀਆਂ ਹਨ (ਤਕਨੀਕੀ) + ਰਿਮੋਟ ਕੰਟਰੋਲ ਐਪਾਂ ਦੀ ਇਜਾਜ਼ਤ ਦਿਓ + ਮਨਜ਼ੂਰ ਕੀਤੇ IP + %1$s ਦੇ %2$s + %s + %2$s ਵਿੱਚ %1$s + : ਧਨਾਤਮਕ ਅਤੇ 65535 ਤੋਂ ਘੱਟ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ + : ਧਨਾਤਮਕ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ + : ਢੁੱਕਵਾਂ UDP ਪੋਰਟ ਨੰਬਰ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ + ਗ਼ੈਰਵਾਜਬ ਕੁੰਜੀ + ਗ਼ੈਰਵਾਜਬ ਨੰਬਰ + ਗ਼ੈਰਵਾਜਬ ਮੁੱਲ + ਨਾ-ਮੌਜੂਦ ਗੁਣ + ਨਾ-ਮੌਜੂਦ ਚੋਣ + ਸੰਟੈਕਸ ਗ਼ਲਤੀ + ਅਣਪਛਾਤਾ ਗੁਣ + ਅਣਪਛਾਤਾ ਭਾਗ + ਮੁੱਲ ਹੱਦ ਤੋਂ ਬਾਹਰ ਹੈ + ਫ਼ਾਇਲ .conf ਜਾਂ .zip ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ + ਚਿੱਤਰ ਵਿੱਚ QR ਕੋਡ ਨਹੀਂ ਲੱਭਿਆ + QR ਕੋਡ ਚੈਕ-ਸਮ ਤਸਦੀਕ ਅਸਫ਼ਲ ਹੋਈ + ਰੱਦ ਕਰੋ + ਸੰਰਚਨਾ ਫ਼ਾਇਲ %s ਹਟਾਈ ਨਹੀਂ ਜਾ ਸਕਦੀ ਹੈ + “%s” ਲਈ ਸੰਰਚਨਾ ਪਹਿਲਾਂ ਹੀ ਮੌਜੂਦ ਹੈ + ਸੰਰਚਨਾ ਫ਼ਾਇਲ “%s” ਪਹਿਲਾਂ ਹੀ ਮੌਜੂਦ ਹੈ + ਸੰਰਚਨਾ ਫ਼ਾਇਲ “%s” ਨਹੀਂ ਲੱਭੀ + ਸੰਰਚਨਾ ਫ਼ਾਇਲ “%s” ਦਾ ਨਾਂ ਨਹੀਂ ਬਦਲਿਆ ਜਾ ਸਕਦਾ ਹੈ + “%1$s” ਲਈ ਸੰਰਚਨਾ ਫ਼ਾਇਲ ਸੰਭਾਲੀ ਨਹੀਂ ਜਾ ਸਕਦੀ ਹੈ: %2$s + “%s” ਲਈ ਸੰਰਚਨਾ ਕਾਮਯਾਬੀ ਨਾਲ ਸੰਭਾਲੀ ਗਈ ਹੈ + ਵਾਇਰਗਾਰਡ ਟਨਲ ਬਣਾਓ + ਲੋਕਲ ਬਾਈਨਰੀ ਡਾਇਰੈਕਟਰੀ ਬਣਾਈ ਨਹੀਂ ਜਾ ਸਕਦੀ ਹੈ + ਡਾਊਨਲੋਡ ਡਾਇਰੈਕਟਰੀ ਵਿੱਚ ਫ਼ਾਇਲ ਬਣਾਈ ਨਹੀਂ ਜਾ ਸਕਦੀ ਹੈ + ਮੁੱਢ ਤੋਂ ਬਣਾਓ + ਫ਼ਾਇਲ ਜਾਂ ਅਕਾਇਵ ਤੋਂ ਦਰਾਮਦ ਕਰੋ + QR ਕੋਡ ਤੋਂ ਸਕੈਨ ਕਰੋ + ਆਉਟਪੁੱਟ ਡਾਇਰੈਕਟਰੀ ਬਣਾਈ ਨਹੀਂ ਜਾ ਸਕਦੀ ਹੈ + ਲੋਕਲ ਆਰਜ਼ੀ ਡਾਇਰੈਕਟਰੀ ਬਣਾਈ ਨਹੀਂ ਜਾ ਸਕਦੀ ਹੈ + ਟਨਲ ਬਣਾਓ + %s ਕਲਿੱਪਬੋਰਡ ਵਿੱਚ ਕਾਪੀ ਕੀਤਾ + ਇਸ ਵੇਲੇ ਹਲਕਾ (ਦਿਨ) ਥੀਮ ਵਰਤਿਆ ਜਾ ਰਿਹਾ ਹੈ + ਇਸ ਵੇਲੇ ਗੂੜ੍ਹਾ (ਰਾਤ) ਥੀਮ ਵਰਤਿਆ ਜਾ ਰਿਹਾ ਹੈ + ਗੂੜ੍ਹਾ ਥੀਮ ਵਰਤੋਂ + ਹਟਾਓ + ਹਟਾਉਣ ਲਈ ਟਨਲ ਚੁਣੋ + ਸਟੋਰੇਜ਼ ਡਰਾਈਵ ਚੁਣੋ + ਫਾਇਲਾਂ ਬਰਾਊਜ਼ ਕਰਨ ਲਈ ਫਾਇਲ ਪਰਬੰਧਕੀ ਸਹੂਲਤ ਇੰਸਟਾਲ ਕਰੋ + ਸ਼ੁਰੂ ਕਰਨ ਲਈ ਟਨਲ ਜੋੜੋ + ♥ ਵਾਇਰਗਾਰਡ ਪਰੋਜੈਕਟ ਨੂੰ ਦਾਨ ਦਿਓ + ਹਰ ਯੋਗਦਾਨ ਮਦਦਗਾਰ ਹੈ + ਵਾਇਰਗਾਰਡ (WireGuard) ਪ੍ਰੋਜੈਕਟ ਦੀ ਮਦਦ ਕਰਨ ਲਈ ਤੁਹਾਡਾ ਧੰਨਵਾਦ ਹੈ!\n\nਅਫ਼ਸੋਸ ਹੈ ਕਿ Google ਦੀਆਂ ਨੀਤੀਆਂ ਕਰਕੇ ਪ੍ਰੋਜੈਕਟ ਦੇ ਵੈੱਬ-ਸਫ਼ੇ, ਜਿੱਥੇ ਤੁਸੀਂ ਦਾਨ ਦੇ ਸਕਦੇ ਹੋ, ਸਾਨੂੰ ਇੱਥੇ ਨਹੀਂ ਲਿੰਕ ਕਰਨ ਦੀ ਇਜਾਜ਼ਤ ਨਹੀਂ ਹੈ। ਆਸ ਕਰਦੇ ਹਾਂ ਕਿ ਤੁਸੀਂ ਇਹ ਲੱਭ ਲਵੋਗੇ!\n\nਤੁਹਾਡੇ ਯੋਗਦਾਨ ਵਾਸਤੇ ਇੱਕ ਵਾਰ ਫੇਰ ਧੰਨਵਾਦ ਹੈ। + ਸੰਰਚਨਾ ਐਕਸਪੋਰਟ ਕਰਨ ਨੂੰ ਅਸਮਰੱਥ ਕਰੋ + ਸੰਰਚਨਾ ਐਕਸਪੋਰਟ ਕਰਨ ਉੱਤੇ ਰੋਕ ਲਾਉਣ ਨਾਲ ਪ੍ਰਾਈਵੇਟ ਕੁੰਜੀਆਂ ਲਈ ਪਹੁੰਚ ਘਟੇਗੀ + DNS ਸਰਵਰ + ਡੋਮੇਨਾਂ ਲੱਭੋ + ਸੋਧੋ + ਐਂਡ-ਪੁਆਇੰਟ + ਟਨਲ ਬੰਦ ਕਰਨ ਦੌਰਾਨ ਗ਼ਲਤੀ: %s + ਐਪ ਸੂਚੀ ਲੈਣ ਦੌਰਾਨ ਗ਼ਲਤੀ: %s + ਰੂਟ ਪਹੁੰਚ ਲਵੋ ਅਤੇ ਮੁੜ ਕੋਸ਼ਿਸ਼ ਕਰੋ + ਟਨਲ ਤਿਆਰ ਕਰਨ ਦੌਰਾਨ ਗਲਤੀ: %s + ਟਨਲ ਚਾਲੂ ਕਰਨ ਦੌਰਾਨ ਗ਼ਲਤੀ: %s + ਪ੍ਰਾਈਵੇਟ IP ਅਲਹਿਦਾ ਰੱਖੋ + ਨਵੀਂ ਪ੍ਰਾਈਵੇਟ ਕੁੰਜੀ ਬਣਾਓ + ਅਣਪਛਾਤੀ “%s” ਗਲਤੀ + (ਆਟੋ) + (ਤਿਆਰ ਕੀਤਾ) + (ਚੋਣਵਾਂ) + (ਚੋਣਵਾਂ, ਪਰ ਸਿਫਾਰਸ਼ੀ ਨਹੀਂ) + (ਰਲਵਾਂ) + ਗ਼ੈਰ-ਵਾਜਬ ਫ਼ਾਇਲ ਨਾਂ \"%s\" + ਟਨਲ ਇੰਪੋਰਟ ਕਰਨ ਲਈ ਅਸਮਰੱਥ: %s + QR ਕੋਡ ਤੋਂ ਟਨਲ ਇੰਪੋਰਟ ਕਰੋ + \"%s\" ਇੰਪੋਰਟ ਕੀਤੀ + ਇੰਟਰਫੇਸ + ਕੁੰਜੀ ਵਿੱਚ ਗ਼ਲਤ ਅੱਖਰ + ਗ਼ਲਤ ਕੁੰਜੀ ਦੀ ਲੰਬਾਈ + : WireGuard base64 ਕੁੰਜੀਆਂ ਵਿੱਚ 44 ਅੱਖਰ ਹੋਣੇ ਚਾਹੀਦੇ ਹਨ (32 ਬਾਈਟ) + : WireGuard ਕੁੰਜੀਆਂ 32 ਬਾਈਟ ਹੋਣੀਆਂ ਚਾਹੀਦੀਆਂ ਹਨ + : WireGuard ਹੈਕਸਾ ਕੁੰਜੀਆਂ ਵਿੱਚ 64 ਅੱਖਰ ਹੋਣੇ ਚਾਹੀਦੇ ਹਨ (32 ਬਾਈਟ) + ਆਖਰੀ ਹੈਂਡ-ਸ਼ੇਕ + %s ਪਹਿਲਾਂ + ਸੁਣਨ ਵਾਲੀ ਪੋਰਟ + ਲਾਗ ਐਕਸਪੋਰਟ ਕਰਨ ਲਈ ਅਸਮਰੱਥ: %s + ਵਾਇਰਗਾਰਡ ਐਂਡਰਾਈਡ ਲਾਗ ਫ਼ਾਇਲ + “%s” ਵਜੋਂ ਸੰਭਾਲਿਆ ਗਿਆ + ਲਾਗ ਫ਼ਾਇਲ ਬਰਾਮਦ ਕਰੋ + ਲਾਗ ਸੰਭਾਲੋ + ਲਾਗ ਡੀਬੱਗ ਕਰਨ ਲਈ ਸਹਾਇਤਾ ਕਰ ਸਕਦੇ ਹਨ + ਐਪਲੀਕੇਸ਼ਨ ਲਾਗ ਵੇਖੋ + ਲਾਗ + logcat ਚਲਾਉਣ ਲਈ ਅਸਮਰੱਥ: + ਤਜਰਬੇ ਅਧੀਨ ਕਰਨਲ ਮੋਡੀਊਲ ਕਾਰਗੁਜ਼ਾਰੀ ਸੁਧਾਰ ਸਕਦਾ ਹੈ + ਕਰਨਲ ਮੋਡੀਊਲ ਬੈਕਐਂਡ ਸਮਰੱਥ ਕਰੋ + ਹੌਲੀ ਵਰਤੋਂਕਾਰ-ਸਪੇਸ ਬੈਂਕਡ ਸਥਿਰਤਾ ਸੁਧਾਰ ਕਰ ਸਕਦਾ ਹੈ + ਕਰਨਲ ਮੋਡੀਊਲ ਬੈਕਐਂਡ ਅਸਮਰੱਥ ਕਰੋ + ਕੁਝ ਗਲਤ ਵਾਪਰ ਗਿਆ। ਮੁੜ ਕੋਸ਼ਿਸ਼ ਕਰੋ + ਤਜਰਬੇ ਅਧੀਨ ਕਰਨਲ ਮੋਡੀਊਲ ਕਾਰਗੁਜ਼ਾਰੀ ਸੁਧਾਰ ਸਕਦਾ ਹੈ + ਤੁਹਾਡੇ ਡਿਵਾਈਸ ਲਈ ਕੋਈ ਮੋਡੀਊਲ ਮੌਜੂਦ ਨਹੀਂ ਹਨ + ਕਰਨਲ ਮੋਡੀਊਲ ਡਾਊਨਲੋਡ ਕਰਕੇ ਇੰਸਟਾਲ ਕਰੋ + ਡਾਊਨਲੋਡ ਤੇ ਇੰਸਟਾਲ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ… + ਕਰਨਲ ਮੋਡੀਊਲ ਵਰਜ਼ਨ ਪਤਾ ਲਗਾਉਣ ਲਈ ਅਸਮਰੱਥ ਹੈ + MTU + ਇੱਕ ਟਨਲ ਨੂੰ ਚਾਲੂ ਕਰਨ ਨਾਲ ਹੋਰ ਬੰਦ ਹੋ ਜਾਣਗੀਆਂ + ਕਈ ਟਨਲਾਂ ਇੱਕੋ ਸਮੇਂ ਵੀ ਚਾਲੂ ਕੀਤੀਆਂ ਜਾ ਸਕਦੀਆਂ ਹਨ + ਇੱਕੋ ਸਮੇਂ ਕਈ ਟਨਲਾਂ ਦੀ ਇਜਾਜ਼ਤ ਦਿਓ + ਨਾਂ + ਬਿਨਾਂ ਸੰਰਚਨਾ ਦੇ ਟਲਨ ਨੂੰ ਚਾਲੂ ਕਰਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ + ਕੋਈ ਸੰਰਚਨਾ ਨਹੀਂ ਲੱਭੀ + ਕੋਈ ਟਨਲ ਮੌਜੂਦ ਨਹੀਂ ਹੈ + ਸਤਰ + IP ਸਿਰਨਾਵਾਂ + ਐਂਡ-ਪੁਆਇੰਟ + IP ਨੈੱਟਵਰਕ + ਨੰਬਰ + %1$s “%2$s” ਨੂੰ ਪਾਰਸ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ + ਪੀਅਰ + ਵਾਇਰਗਾਰਡ ਟਨਲਾਂ ਨੂੰ ਕੰਟਰੋਲ ਕਰੋ, ਮਨਮਰਜ਼ੀ ਮੁਤਾਬਕ ਟਨਲਾਂ ਨੂੰ ਸਮਰੱਥ ਤੇ ਅਸਮਰੱਥ ਕਰੋ, ਸੰਭਾਵਿਤ ਇੰਟਰਨੈੱਟ ਟਰੈਫਿਕ ਦੀ ਦਿਸ਼ਾ ਬਦਲੋ + ਵਾਇਰਗਰਾਡ ਟਨਲਾਂ ਕੰਟਰੋਲ ਕਰੋ + ਸਥਿਰ ਲਗਾਤਾਰ ਜਾਰੀ ਰੱਖੋ + ਪਹਿਲਾਂ-ਸਾਂਝੀ ਕੀਤੀ ਕੁੰਜੀ + ਸਮਰੱਥ ਹੈ + ਪ੍ਰਾਈਵੇਟ ਕੁੰਜੀ + ਪਬਲਿਕ ਕੁੰਜੀ + ਟੋਟਕਾ: `qrencode -t ansiutf8 < tunnel.conf` ਨਾਲ ਤਿਆਰ ਕਰੋ। + ਫ਼ੌਰੀ ਸੈਟਿੰਗਾਂ ਪੈਨਲ ਵਿੱਚ ਟਾਈਲ ਜੋੜੋ + ਸ਼ਾਰਟਕੱਟ ਟਾਈਲ ਸਭ ਤੋਂ ਸੱਜਰੀ ਟਨਲ ਨੂੰ ਬਦਲਦੀ ਹੈ + ਸ਼ਾਰਟਕ਼ਟ ਟਾਈਲ ਜੋੜਨ ਲਈ ਅਸਮਰੱਥ: ਗਲਤੀ %d + ਟਨਲ ਨੂੰ ਬਦਲੋ + ਬੂਟ ਕਰਨ ਸਮੇਂ ਸਮਰੱਥ ਕੀਤੀਆਂ ਟਨਲਾਂ ਨੂੰ ਚਾਲੂ ਨਹੀਂ ਕੀਤਾ ਜਾਵੇਗਾ + ਬੂਟ ਕਰਨ ਸਮੇਂ ਸਮਰੱਥ ਕੀਤੀਆਂ ਟਨਲਾਂ ਨੂੰ ਚਾਲੂ ਕੀਤਾ ਜਾਵੇਗਾ + ਬੂਟ ਕਰਨ ਉੱਤੇ ਬਹਾਲ ਕਰੋ + ਸੰਭਾਲੋ + ਸਾਰੇ ਚੁਣੋ + ਸੈਟਿੰਗਾਂ + ਸ਼ੈਲ ਬਾਹਰ ਜਾਣ (exit) ਸਥਿਤੀ ਪੜ੍ਹ ਨਹੀਂ ਸਕਦੀ + ਸ਼ੈਲ 4 ਮਾਰਕਰਾਂ ਦੀ ਉਮੀਦ ਕਰਦੀ ਸੀ, %d ਮਿਲੇ + ਸ਼ੈਲ ਸ਼ੁਰੂ ਕਰਨ ਲਈ ਅਸਫ਼ਲ ਹੈ: %d + ਕਾਮਯਾਬ। ਐਪਲੀਕੇਸ਼ਨ ਹੁਣ ਮੁੜ-ਚਾਲੂ ਹੋਵੇਗੀ… + ਸਭ ਪਲਟੋ + ਵਾਇਰਗਾਰਡ ਟਨਲ ਬਦਲਣ ਦੌਰਾਨ ਗ਼ਲਤੀ: %s + wg ਅਤੇ wg-quick ਪਹਿਲਾਂ ਹੀ ਇੰਸਟਾਲ ਹਨ + ਕਮਾਂਡ-ਲਾਈਨ ਟੂਲ ਇੰਸਟਾਲ ਕਰਨ ਲਈ ਅਸਮਰੱਥ (ਰੂਟ ਨਹੀਂ ਹੋ?) + ਸਕ੍ਰਿਪਟ ਲਿਖਣ ਲਈ ਚੋਣਵੇਂ ਟੂਲ ਇੰਸਟਾਲ ਕਰੋ + Magisk ਮੋਡੀਊਲ ਵਜੋਂ ਸਕ੍ਰਿਪਟਾਂ ਲਿਖਣ ਲਈ ਚੋਣਵੇਂ ਟੂਲ ਇੰਸਟਾਲ ਕਰੋ + ਸਿਸਟਮ ਪਾਰਟੀਸ਼ਨ ਵਿੱਚ ਸਕ੍ਰਿਪਟ ਲਿਖਣ ਲਈ ਚੋਣਵੇਂ ਟੂਲ ਇੰਸਟਾਲ ਕਰੋ + wg ਅਤੇ wg-quick ਨੂੰ Magisk ਮੋਡੀਊਲ ਵਜੋਂ ਇੰਸਟਾਲ ਕੀਤਾ (ਮੁੜ-ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੋਵੇਗੀ) + wg ਅਤੇ wg-quick ਨੂੰ ਸਿਸਟਮ ਪਾਰਟੀਸ਼ਨ ਵਿੱਚ ਇੰਸਟਾਲ ਕੀਤਾ + ਕਮਾਂਡ ਲਾਈਨ ਟੂਲ ਇੰਸਟਾਲ ਕਰੋ + wg ਤੇ wg-quick ਇੰਸਟਾਲ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ + ਚਾਹੀਦੇ ਟੂਲ ਮੌਜੂਦ ਨਹੀਂ ਹਨ + ਟਰਾਂਸਫਰ + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + tun ਡਿਵਾਈਸ ਬਣਾਉਣ ਲਈ ਅਸਮਰੱਥ + ਟਨਲ ਦੀ ਸੰਰਚਨਾ ਕਰਨ ਲਈ ਅਸਮਰੱਥ (wg-quick ਨੇ %d ਵਾਪਸ ਕੀਤਾ) + ਟਨਲ ਬਣਾਉਣ ਲਈ ਅਸਮਰੱਥ: %s + “%s” ਟਨਲ ਕਾਮਯਾਬੀ ਨਾਲ ਬਣਾਈ + ਟਨਲ “%s” ਪਹਿਲਾਂ ਹੀ ਮੌਜੂਦ ਹੈ + ਅਯੋਗ ਨਾਂ + ਹੇਠਾਂ ਦਿੱਤੇ ਬਟਨ ਨੂੰ ਵਰਤ ਕੇ ਟਨਲ ਜੋੜੋ + ਟਨਲ ਦਾ ਨਾਂ + ਟਨਲ ਚਾਲੂ ਕਰਨ ਲਈ ਅਸਮਰੱਥ (wgTurnOn ਨੇ %d ਵਾਪਸ ਕੀਤਾ) + DNS ਹੋਸਟ-ਨਾਂ ਹੱਲ ਕਰਨ ਲਈ ਅਸਮਰੱਥ: “%s” + ਟਨਲ ਨਾਂ-ਬਦਲਣ ਲਈ ਅਸਮਰੱਥ: %s + ਟਨਲ ਦਾ ਨਾਂ \"%s\" ਵਜੋਂ ਕਾਮਯਾਬੀ ਨਾਲ ਬਦਲਿਆ ਗਿਆ + ਵਰਤੋਂ-ਸਪੇਸ ਤੇ ਜਾਓ + ਕਰਨਲ ਮੋਡੀਊਲ + ਅਣਪਛਾਤੀ ਗਲਤੀ + ਐਪਲੀਕੇਸ਼ਨ ਅੱਪਡੇਟ ਮੌਜੂਦ ਹੈ। ਹੁਣੇ ਅੱਪਡੇਟ ਕਰੋ। + ਡਾਊਨਲੋਡ ਤੇ ਅੱਪਡੇਟ ਕਰੋ + ਅੱਪਡੇਟ ਮੇਟਾਡਾਟਾ ਲਿਆ ਜਾ ਰਿਹਾ ਹੈ… + ਅੱਪਡੇਟ ਡਾਊਨਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ: %1$s / %2$s (%3$.2f%%) + ਅਪਡੇਟ ਡਾਊਨਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ: %s + ਅੱਪਡੇਟ ਇੰਸਟਾਲ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ… + ਅੱਪਡੇਟ ਅਸਫ਼ਲ: %s। ਕੁਝ ਕੁ ਪਲਾਂ ਵਿੱਚ ਫੇਰ ਕੋਸ਼ਿਸ਼ ਕਰਾਂਗੇ… + ਐਪਲੀਕੇਸ਼ਨ ਖ਼ਰਾਬ ਹੈ + ਇਹ ਐਪਲੀਕੇਸ਼ਨ ਖ਼ਰਾਬ ਹੋ ਗਈ ਹੈ। ਹੇਠ ਦਿੱਤੇ ਵੈੱਬਸਾਈਟ ਦੇ ਲਿੰਕ ਤੋਂ APK ਨੂੰ ਫੇਰ ਡਾਊਨਲੋਡ ਕਰੋ। ਇਸ ਐਪਲੀਕੇਸ਼ਨ ਨੂੰ ਅਣ-ਇੰਸਟਾਲ ਕਰਨ ਅਤੇ ਫੇਰ ਡਾਊਨਲੋਡ ਕੀਤੀ APK ਤੋਂ ਮੁੜ-ਇੰਸਟਾਲ ਕਰੋ। + ਵੈੱਬਸਾਈਟ ਖੋਲ੍ਹੋ + %1$s ਬੈਕਐਂਡ %2$s + %s ਬੈਕਐਂਡ ਵਰਜ਼ਨ ਦੀ ਜਾਂਚ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ + ਅਣਪਛਾਤਾ %s ਵਰਜਨ + Android ਲਈ WireGuard v%s + VPN ਸੇਵਾ ਨੂੰ ਵਰਤੋਂਕਾਰ ਨੇ ਪਰਮਾਣਿਤ ਨਹੀਂ ਕੀਤਾ + ਐਂਡਰਾਈਡ VPN ਸੇਵਾ ਸ਼ੁਰੂ ਕਰਨ ਲਈ ਅਸਮਰੱਥ + ਟਨਲਾਂ ਐਕਸਪੋਰਟ ਕਰਨ ਲਈ ਅਸਮਰੱਥ: %s + “%s” ਉੱਤੇ ਸੰਭਾਲਿਆ ਗਿਆ + ਜ਼ਿਪ ਫਾਈਲ ਨੂੰ ਡਾਊਨਲੋਡ ਫੋਲਡਰ ਵਿੱਚ ਸੰਭਾਲਿਆ ਜਾਵੇਗਾ + ਟਨਲ ਨੂੰ ਜ਼ਿੱਪ ਫ਼ਾਇਲ ਵਜੋਂ ਬਰਾਮਦ ਕਰੋ + ਟਨਲ ਬਰਾਮਦ ਕਰਨ ਲਈ ਪਰਮਾਣਕਿਤਾ + ਪ੍ਰਾਈਵੇਟ ਕੁੰਜੀ ਵੇਖਣ ਲਈ ਪਰਮਾਣਕਿਤਾ + ਪਰਮਾਣਿਤ ਕਰਨ ਲਈ ਅਸਫ਼ਲ + ਪਰਮਾਣਿਤ ਕਰਨ ਲਈ ਅਸਫ਼ਲ: %s + diff --git a/ui/src/main/res/values-pl-rPL/strings.xml b/ui/src/main/res/values-pl-rPL/strings.xml new file mode 100644 index 0000000..1a0f0fa --- /dev/null +++ b/ui/src/main/res/values-pl-rPL/strings.xml @@ -0,0 +1,285 @@ + + + + Nie można usunąć %d tunelu: %s + Nie można usunąć %d tuneli: %s + Nie można usunąć %d tuneli: %s + Nie można usunąć %d tuneli: %s + + + Pomyślnie usunięto tunel %d + Pomyślnie usunięto %d tunele + Pomyślnie usunięto %d tuneli + Pomyślnie usunięto %d tuneli + + + %d wybrany tunel + %d wybrane tunele + %d wybranych tuneli + %d wybranych tuneli + + + Zaimportowano %1$d z %2$d tuneli + Zaimportowano %1$d z %2$d tuneli + Zaimportowano %1$d z %2$d tuneli + Zaimportowano %1$d z %2$d tuneli + + + Zaimportowano %d tunel + Zaimportowano %d tunele + Zaimportowano %d tuneli + Zaimportowano %d tuneli + + + %d wykluczona aplikacja + %d wykluczone aplikacje + %d wykluczonych aplikacji + %d wykluczonych aplikacji + + + %d dołączona aplikacja + %d dołączone aplikacje + %d dołączonych aplikacji + %d dołączonych aplikacji + + + %d wykluczona + %d wykluczone + %d wykluczonych + %d wykluczonych + + + %d dołączona + %d dołączone + %d dołączonych + %d dołączonych + + Wszystkie aplikacje + Wyklucz + Dołącz tylko + + Dołącz %d aplikację + Dołącz %d aplikacje + Dołącz %d aplikacji + Dołącz %d aplikacji + + + Wyklucz %d aplikację + Wyklucz %d aplikacje + Wyklucz %d aplikacji + Wyklucz %d aplikacji + + + co sekundę + co %d sekundy + co %d sekund + co %d sekund + + + sekunda + sekundy + sekund + sekundy + + Użyj wszystkich + Dodaj peera + Adresy + Aplikacje + Zewnętrzne aplikacje nie mogą przełączać tuneli (zalecane) + Zewnętrzne aplikacje mogą przełączać tunele (zaawansowane) + Zezwól na kontrolowanie przez zewnętrzne aplikacje + Dozwolone adresy IP + %1$s dla %2$s + %s + %1$s w %2$s + : Musi być dodatni i nie większy niż 65535 + : Musi być dodatni + : Musi być prawidłowym numerem portu UDP + Nieprawidłowy klucz + Nieprawidłowa liczba + Nieprawidłowa wartość + Brakujący atrybut + Brakująca sekcja + Błąd składni + Nieznany atrybut + Nieznana sekcja + Wartość poza zakresem + Plik musi posiadać rozszerzenie CONF lub ZIP + Kod QR nie został znaleziony w obrazie + Weryfikacja sumy kontrolnej kodu QR nie powiodła się + Anuluj + Nie można usunąć pliku konfiguracyjnego „%s” + Konfiguracja dla „%s” już istnieje + Plik konfiguracyjny „%s” już istnieje + Nie znaleziono pliku konfiguracyjnego „%s” + Nie można zmienić nazwy pliku konfiguracyjnego „%s” + Nie można zapisać konfiguracji dla „%1$s”: %2$s + Pomyślnie zapisano konfigurację dla „%s” + Utwórz tunel WireGuard + Nie można utworzyć lokalnego folderu dla plików wykonywalnych + Nie można utworzyć pliku w folderze pobierania + Utwórz ręcznie + Utwórz z pliku lub archiwum + Zeskanuj kod QR + Nie można utworzyć folderu wyjściowego + Nie można utworzyć tymczasowego folderu lokalnego + Utwórz tunel + %s skopiowano do schowka + Obecnie używany jest jasny motyw + Obecnie używany jest ciemny motyw + Użyj ciemnego motywu + Usuń + Wybierz tunel do usunięcia + Wybierz dysk pamięci + Zainstaluj narzędzie do zarządzania plikami, aby przeglądać pliki + Dodaj tunel, aby rozpocząć + ♥ Wpłać na rzecz projektu WireGuard + Każdy wkład pomaga + Dziękujemy za wsparcie projektu WireGuard!\n\nNiestety, ze względu na zasady Google, nie możemy umieszczać linków do części strony projektu, w której możesz przekazać darowiznę. Mamy nadzieję, że uda Ci się to rozgryźć!\n\nJeszcze raz dziękujemy za Twój wkład. + Wyłącz eksportowanie konfiguracji + Wyłączenie eksportowania konfiguracji sprawi, że klucze prywatne będą mniej dostępne + Serwery DNS + Sufiksy DNS + Edytuj + Punkt końcowy + Błąd podczas zamykania tunelu: %s + Błąd podczas pobierania listy aplikacji: %s + Uzyskaj dostęp do roota i spróbuj ponownie + Błąd podczas przygotowywania tunelu: %s + Błąd podczas otwierania tunelu: %s + Wyklucz prywatne adresy IP + Wygeneruj nowy klucz prywatny + Nieznany błąd „%s” + (auto.) + (wygenerowany) + (opcjonalnie) + (opcjonalnie, niezalecane) + (losowy) + Niedozwolona nazwa pliku „%s” + Nie można zaimportować tunelu: %s + Importuj tunel za pomocą kodu QR + Zaimportowano „%s” + Interfejs + Nieprawidłowe znaki w kluczu + Nieprawidłowa długość klucza + : Klucze base64 WireGuard muszą mieć długość 44 znaków (32 bajty) + : Klucze WireGuard muszą mieć wielkość 32 bajtów + : Klucze hex WireGuard muszą mieć długość 64 znaków (32 bajty) + Ostatnie uzgadnianie + %s temu + Port nasłuchu + Nie można wyeksportować logu: %s + Plik logu programu WireGuard dla systemu Android + Zapisano w „%s” + Wyeksportuj plik logu + Zapisz log + Logi mogą pomóc w debugowaniu + Wyświetl log aplikacji + Log + Nie można uruchomić narzędzia logcat: + Eksperymentalny moduł jądra może poprawić wydajność + Włącz moduł jądra + Wolniejsza implementacja w przestrzeni użytkownika może poprawić stabilność + Wyłącz moduł jądra + Coś poszło nie tak. Proszę spróbować ponownie + Eksperymentalny moduł jądra może poprawić wydajność + Brak dostępnych modułów dla tego urządzenia + Pobierz i zainstaluj moduł jądra + Pobieranie i instalowanie… + Nie można określić wersji modułu jądra + MTU + Włączenie jednego tunelu spowoduje wyłączenie innych + Wiele tuneli może być włączonych jednocześnie + Zezwól na wiele równoległych tuneli + Nazwa + Próba otwarcia tunelu bez konfiguracji + Nie odnaleziono żadnych konfiguracji + Brak tuneli + ciąg + Adresy IP + punkt końcowy + Sieć IP + liczba + Nie można przetworzyć %1$s „%2$s” + Peer + kontrolowanie tuneli WireGuard, włączanie i wyłączanie tuneli, potencjalnie błędne kierowanie ruchem internetowym + sterowanie tunelami WireGuard + Utrzymanie połączenia + Klucz wstępnie udostępniony + włączone + Klucz prywatny + Klucz publiczny + Wskazówka: wygeneruj za pomocą `qrencode -t ansiutf8 < tunnel.conf`. + Dodaj kafelek do panelu szybkich ustawień + Kafelek skrótu przełącza najnowszy tunel + Nie można dodać kafelka skrótu: błąd %d + Przełącz tunel + Włączone tunele nie zostaną przywrócone podczas uruchamiania + Włączone tunele zostaną przywrócone podczas uruchamiania + Przywróć podczas uruchamiania + Zapisz + Wybierz wszystko + Ustawienia + Powłoka nie może odczytać statusu wyjścia + Powłoka spodziewała się 4 znaczników, otrzymano %d + Nie udało się uruchomić powłoki: %d + Ukończono pomyślnie. Aplikacja zostanie uruchomiona ponownie… + Przełącz wszystkie + Błąd podczas przełączania tunelu WireGuard: %s + Narzędzia wg i wg-quick są już zainstalowane + Nie można zainstalować narzędzi wiersza poleceń (brak roota?) + Zainstaluj opcjonalne narzędzia do tworzenia skryptów + Zainstaluj opcjonalne narzędzia do tworzenia skryptów jako moduł Magisk + Zainstaluj opcjonalne narzędzia do tworzenia skryptów na partycji systemowej + Narzędzia wg i wg-quick zostały zainstalowane jako moduł Magisk (wymagane ponowne uruchomienie) + Narzędzia wg i wg-quick zostały zainstalowane na partycji systemowej + Zainstaluj narzędzia wiersza poleceń + Instalowanie wg i wg-quick + Wymagane narzędzia są niedostępne + Transfer + %d B + %.2f GiB + %.2f KiB + %.2f MiB + odebrano: %1$s, wysłano: %2$s + %.2f TiB + Nie można utworzyć urządzenia TUN + Nie można skonfigurować tunelu (wg-quick zwróciło %d) + Nie można utworzyć tunelu: %s + Pomyślnie utworzono tunel „%s” + Tunel \"%s\" już istnieje + Nieprawidłowa nazwa + Dodaj tunel za pomocą przycisku poniżej + Nazwa tunelu + Nie można włączyć tunelu (wgTurnOn zwróciło %d) + Nie można odnaleźć nazwy hosta DNS: “%s” + Nie można zmienić nazwy tunelu: %s + Pomyślnie zmieniono nazwę tunelu na „%s” + Przestrzeń użytkownika Go + Moduł jądra + Nieznany błąd + Dostępna jest aktualizacja aplikacji. Zaktualizuj teraz. + Pobierz i zaktualizuj + Pobieranie metadanych aktualizacji… + Pobieranie aktualizacji: %1$s / %2$s (%3$.2f%%) + Pobieranie aktualizacji: %s + Instalowanie aktualizacji… + Błąd aktualizacji: %s. Ponowienie próby za chwilę… + Aplikacja uszkodzona + Ta aplikacja jest uszkodzona. Pobierz ponownie plik APK z witryny, do której link znajduje się poniżej. Następnie odinstaluj tę aplikację i zainstaluj ją ponownie z pobranego pliku APK. + Otwórz witrynę + %1$s backend %2$s + Sprawdzanie wersji implementacji: %s + Nieznana wersja %s + WireGuard dla systemu Android v%s + Usługa VPN nie została autoryzowana przez użytkownika + Nie można uruchomić usługi VPN systemu Android + Nie można wyeksportować tuneli: %s + Zapisano w „%s” + Plik ZIP zostanie zapisany w folderze pobierania + Wyeksportuj tunele do pliku ZIP + Uwierzytelnij, aby wyeksportować tunele + Uwierzytelnij, aby zobaczyć klucz prywatny + Błąd uwierzytelnienia + Błąd uwierzytelnienia: %s + diff --git a/ui/src/main/res/values-pt-rBR/strings.xml b/ui/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..d3a1445 --- /dev/null +++ b/ui/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,259 @@ + + + + Não é possível excluir o túnel %d: %s + Não foi possível excluir túneis %d: %s + + + Túnel %d excluído com sucesso + Túneis %d excluídos com êxito + + + Túnel %d selecionado + Túneis %d selecionados + + + Importados %1$d dos %2$d túneis + Importados %1$d dos %2$d túneis + + + Importado %d túnel + Importados %d túneis + + + Aplicação %d Excluída + Aplicações %d Excluídas + + + %d Aplicação Incluída + %d Aplicações Incluídas + + + %d retirada + %d retiradas + + + %d incluída + %d incluídas + + Todos as aplicativos + Retirar + Incluir somente + + Incluir %d app + Incluir %d aplicações + + + Retirar %d app + Retirar %d apps + + + a cada segundo + a cada %d segundos + + + segundo + segundos + + Usar todas aplicações + Adicionar Par + Endereço + Aplicativo + Aplicativos externos podem não alternar túneis (recomendado) + Aplicativos externos podem alternar túneis (avançado) + Permitir controle remoto de apps + IPs Permitidos + %2$s da %1$s + %s + %1$s em %2$s + : Deve ser positivo e não mais que 65535 + : Deve ser positivo + : Deve ser um número de porta UDP válido + Chave inválida + Número inválido + Valor inválido + Atributo ausente + Seção em falta + Erro de sintaxe + Atributo desconhecido + Seção desconhecida + Valor fora do intervalo + O arquivo deve ser .conf ou .zip + Código QR não encontrado na imagem + Falha na verificação do código QR + Cancelar + Não é possível excluir o arquivo de configuração %s + Configuração para \"%s\" já existe + Arquivo de configuração “%s” já existe + Arquivo de configuração “%s” não encontrado + Não é possível renomear o arquivo de configuração “%s” + Não pode salvar a configuração para \"%1$s\": %2$s + Configuração salva com sucesso para “%s” + Criar túnel WireGuard + Não é possível criar o diretório local do binário + Não é possível criar arquivo no diretório de downloads + Criar do zero + Criar a partir de arquivo + Ler código QR + Não é possível criar o diretório de saída + Não é possível criar o diretório temporário local + Criar túnel + %s copiado para a área de transferência + Atualmente usando tema claro (dia) + Atualmente usando tema escuro (noite) + Usar tema escuro + Excluir + Selecione o túnel para excluir + Selecione uma unidade de armazenamento + Por favor, instale um utilitário de gerenciamento de arquivos para procurar arquivos + Adicione um túnel para começar + ♥️ Doar para o projeto WireGuard + Todas as contribuições ajudam + Obrigado por apoiar o Projeto WireGuard!\n\nInfelizmente, devido às políticas do Google, não temos permissão para vincular a parte da página do projeto onde você pode fazer uma doação. Esperamos que você consiga descobrir isso!\n\nObrigado novamente pela sua contribuição. + Desativar exportação de configuração + Desativar a exportação de configuração torna as chaves privadas menos acessíveis + Servidores DNS + Domínios de pesquisa de DNS + Editar + Endpoint + Erro ao derrubar o túnel: %s + Erro ao obter lista de apps: %s + Por favor, obtenha acesso root e tente novamente + Erro ao preparar o túnel: %s + Erro ao criar túnel: %s + Excluir IPs privados + Gerar uma nova chave privada + Erro desconhecido “%s” + (automático) + (gerado) + (opcional) + (opcional, não recomendado) + (aleatório) + Nome de arquivo inválido “%s” + Não foi possível importar o túnel: %s + Importar Túnel por QR Code + Importado “%s” + Interface + Caracteres inválidos na chave + Chave com tamanho incorreto + : Chaves base64 do WireGuard devem ter 44 caracteres (32 bytes) + : Chaves do WireGuard devem ter 32 bytes + : Chaves hex do WireGuard devem ter 64 caracteres (32 bytes) + Último handshake + %s atrás + Porta de escuta + Não foi possível exportar o log: %s + Arquivo de log do WireGuard Android + Salvo em “%s” + Exportar arquivo de log + Salvar log + Registros podem ajudar na depuração + Exibir registros da aplicação + Registro + Não foi possível executar o logcat: + O módulo do Kernel experimental pode melhorar o desempenho + Habilitar módulo backend do kernel + O backend do userspace mais lento pode aumentar a estabilidade + Desativar backend do módulo do kernel + Ocorreu um erro. Tente novamente + O módulo experimental do kernel pode melhorar o desempenho + Não há módulos disponíveis para o seu dispositivo + Baixar e instalar módulo do kernel + Baixando e instalando… + Não foi possível determinar a versão do módulo do kernel + MTU + Ligar um túnel irá desligar outros + Múltiplos túneis podem ser ativados simultaneamente + Permitir múltiplos túneis simultâneos + Nome + Não é possível criar um túnel sem configuração + Nenhuma configuração foi encontrada + Não existem túneis + string + Endereço IP + endpoint + Rede IP + número + Não é possível analisar %1$s “%2$s” + Par + permite controlar túneis do WireGuard, ativando e desativando túneis a vontade, potencialmente desviando o tráfego na Internet + controlar túneis do WireGuard + Keepalive persistente + Chave pré-partilhada + ativo + Chave Privada + Chave pública + Dica: gerar com `qrencode -t ansiutf8 < tunnel.conf`. + Adicionar botão ao painel de configurações rápidas + A tecla de atalho alterna o túnel mais recente + Não foi possível adicionar o atalho de botão: erro %d + Ativar/Desativar túnel + Não irá criar túneis habilitados na inicialização + Irá criar túneis habilitados na inicialização + Restaurar na inicialização + Salvar + Selecionar tudo + Definições + O Shell não pode ler o status de saída + Shell esperava 4 marcadores, mas recebeu apenas %d + Shell falhou ao iniciar: %d + Sucesso. O aplicativo irá reiniciar agora… + Alternar Todos + Erro ao ativar o túnel do WireGuard: %s + wg e wg-quick já estão instalados + Não foi possível instalar ferramentas de linha de comando (sem root?) + Instalar ferramentas opcionais para scripting + Instalar ferramentas opcionais para scripting como o módulo Magisk + Instalar ferramentas opcionais para escrever na partição do sistema + wg e wg-quick instalado como um módulo Magisk (é necessário reiniciar) + wg e wg-quick instalados na partição do sistema + Instalar ferramentas de linha de comando + Instalando wg e wg-quick + Ferramentas necessárias indisponíveis + Transferir + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Não foi possível criar o túnel: %s + Não foi possível configurar o túnel (wg-quick retornou %d) + Não foi possível criar o túnel: %s + Túnel criado com sucesso “%s” + Túnel “%s” já existe + Nome inválido + Adicionar um túnel usando o botão abaixo + Nome do túnel + Não foi possível ativar o túnel (wgTurnOn retornou %d) + Não foi possível resolver host DNS: \"%s\" + Não foi possível renomear o túnel: %s + Renomeado com sucesso o túnel para “%s” + Espaço do usuário Go + Modo de Kernel + Erro desconhecido + Uma atualização do aplicativo está disponível. Pôr favor atualize agora. + Baixar & Atualizar + Obtendo metadados de atualização… + Baixando a atualização: %1$s / %2$s (%3$.2f%%) + Baixando atualização: %s + Instalando atualização… + Falha na atualização: %s. Tentaremos novamente em breve… + Aplicativo corrompido + Este aplicativo está corrompido. Por favor, baixe novamente o APK do site vinculado abaixo. Depois disso, desinstale o aplicativo e reinstale-o a partir do APK baixado. + Abrir site + Backend do %1$s %2$s + Verificando a versão do backend %s + Versão %s desconhecida + WireGuard para Android v%s + Serviço de VPN não autorizado pelo usuário + Não foi possível iniciar o serviço de VPN do Android + Não foi possível exportar túneis: %s + Salvo em “%s” + O arquivo Zip será salvo na pasta de downloads + Exportar túneis para arquivo zip + Autenticar para exportar túneis + Autenticar para ver a chave privada + Falha de autenticação + Falha de autenticação: %s + diff --git a/ui/src/main/res/values-pt-rPT/strings.xml b/ui/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..b12dca1 --- /dev/null +++ b/ui/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,234 @@ + + + + Não foi possível apagar %d túnel: %s + Não foi possível apagar %d túneis: %s + + + %d túnel apagado com sucesso + %d túneis apagados com sucesso + + + %d túnel selecionado + %d túneis selecionados + + + Importado %1$d de %2$d túneis + Importados %1$d de %2$d túneis + + + %d túnel importado + %d túneis importados + + + %d Aplicação Excluída + %d Aplicações Excluídas + + + %d Aplicação Incluída + %d Aplicações incluídas + + + %d Excluída + %d Excluídas + + + %d Incluída + %d Incluídas + + Todas as Aplicações + Excluir + Apenas aplicações incluídas + + Incluir %d Aplicação + Incluir %d Aplicações + + + Excluir %d Aplicação + Exluir %d Aplicações + + + a cada %d segundo + a cada %d segundos + + + segundo + segundos + + Usar todas as aplicações + Adicionar nó + Endereços + Aplicações + Aplicações externas não podem controlar túneis (recomendado) + Aplicações externas podem controlar túneis (avançado) + Permitir controlo remoto por outras aplicações + IPs permitidos + %2$s de %1$s + %s + %1$s em %2$s + : Tem que ser positivo e não mais do que 65535 + : tem que ser positivo + : Tem que ser um número de porta UDP válido + Chave inválida + Número inválido + Valor inválido + Atributo em falta + Secção em falta + Erro de sintaxe + Atributo desconhecido + Secção desconhecida + Valor fora do intervalo + O ficheiro tem que ser do tipo .conf ou .zip + Código QR não encontrado na imagem + Cancelar + Não é possível apagar arquivo de configuração %s + A configuração para \"%s\" já existe + O ficheiro de configuração \"%s\" já existe + O ficheiro de configuração \"%s\" não foi encontrado + Não foi possível renomear o ficheiro de configuração “%s\" + Não foi possível guardar a configuração para “%1$s”: %2$s + A configuração para \"%s\" foi guardada com sucesso + Criar túnel WireGuard + Não foi possível criar diretoria de binários local + Não foi possível criar diretoria de downloads local + Criar do zero + Importar de ficheiro ou arquivo + Digitalizar a partir de código QR + Não foi possível criar diretoria de saída + Não foi possível criar diretoria local temporária + Criar túnel + %s copiado para a área de transferências + Atualmente a usar tema claro (dia) + Atualmente a usar tema escuro (noite) + Usar tema escuro + Eliminar + Selecione o túnel para apagar + Selecione uma unidade de armazenamento + Por favor, instale um utilitário de gestão de ficheiros para procurar arquivos + Adicionar um túnel para começar + Desativar exportação de configuração + Desativar a exportação de configuração torna as chaves privadas menos acessíveis + Servidores de DNS + Editar + Endpoint + Erro ao fechar túnel: %s + Erro ao obter lista de aplicações: %s + Por favor, obtenha acesso root e tente novamente + Erro ao abrir túnel: %s + Excluir IPs privados + Gerar nova chave privada + Erro desconhecido “%s” + (automático) + (gerado) + (opcional) + (opcional, não recomendado) + (aleatório) + Nome de arquivo inválido “%s” + Não foi possível importar túnel: %s + Importar Túnel a partir de Código QR + “%s” Importado + Interface + Caracteres inválidos na chave + Tamanho de chave incorreto + : Chaves base64 do WireGuard têm que ter 44 caracteres (32 bytes) + : Chaves de WireGuard têm que ter 32 bytes + : Chaves hexadecimais do WireGuard têm que ter 64 caracteres (32 bytes) + Porta de escuta + Não foi possível exportar o log: %s + Arquivo de log do WireGuard Android + Guardado em “%s” + Exportar ficheiro de log + Guardar log + Logs podem ajudar na depuração + Ver log da aplicação + Log + Não foi possível executar o logcat: + O módulo experimental de kernel pode melhorar o desempenho + Habilitar módulo backend do kernel + O backend do userspace mais lento pode aumentar a estabilidade + Desabilitar módulo backend do kernel + Ocorreu um erro. Por favor, tente novamente + O módulo experimental do kernel pode melhorar o desempenho + Não há módulos disponíveis para o seu dispositivo + Transferir e instalar módulo de kernel + A transferir e instalar… + Não foi possível determinar a versão do módulo de kernel + MTU + Ativar um túnel irá desativar todos os outros + Vários túneis podem ser ativados simultaneamente + Permitir vários túneis em simultâneo + Nome + A tentar abrir um túnel sem configuração + Nenhuma configuração encontrada + Não existem túneis + texto + Endereço IP + endpoint + Rede de IP + número + Não foi possível analisar %1$s “%2$s” + + controlar túneis WireGuard, ativando e desativando túneis a vontade, potencialmente desviando o tráfego na Internet + controlar túneis WireGuard + Keepalive persistente + Chave pré-partilhada + ativo + Chave privada + Chave Pública + Dica: gerar com `qrencode -t ansiutf8 < tunnel.conf`. + Não abrirá túneis ativos durante o arranque + Abrirá túneis ativos durante o arranque + Restaurar no arranque + Guardar + Selecionar tudo + Definições + A shell não pôde ler o estado de saída + A shell esperava 4 marcadores, recebeu %d + A shell falhou ao iniciar: %d + Sucesso. A aplicação irá reiniciar… + Ativar/Desativar todos + Erro ao alterar o túnel WireGuard: %s + wg e wg-quick já estão instalados + Não foi possível instalar as ferramentas de linha de comando (sem root?) + Instalar ferramentas opcionais para scripting + Instalar ferramentas opcionais para scripting como módulo Magisk + Instalar ferramentas opcionais para scripting na partição de sistema + wg e wg-quick instalados como módulo Magisk (requer reinicialização) + wg e wg-quick instalados na partição do sistema + Instalar ferramentas de linha de comando + A instalar wg e wg-quick + Ferramentas necessárias não disponíveis + Transferência + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Não foi possível criar dispositivo tun + Não foi possível configurar o túnel (wg-quick retornou %d) + Não foi possível criar o túnel: %s + Túnel criado com sucesso “%s” + O túnel “%s” já existe + Nome inválido + Nome do túnel + Não foi possível ligar o túnel (wgTurnOn retornou %d) + Não foi possível renomear o túnel: %s + Túnel renomeado com sucesso para “%s” + Go userspace + Módulo de kernel + Erro desconhecido + A verificar versão de backend %s + Versão %s desconhecida + WireGuard para Android v%s + Serviço VPN não foi autorizado pelo utilizador + Não foi possível iniciar o serviço de VPN do Android + Não foi possível exportar túneis: %s + Guardado em “%s” + O arquivo Zip será guardado na pasta de downloads + Exportar túneis para arquivo zip + Autenticar para exportar túneis + Autenticar para ver a chave privada + Falha de autenticação + Falha de autenticação: %s + diff --git a/ui/src/main/res/values-ro-rRO/strings.xml b/ui/src/main/res/values-ro-rRO/strings.xml new file mode 100644 index 0000000..16ee62f --- /dev/null +++ b/ui/src/main/res/values-ro-rRO/strings.xml @@ -0,0 +1,259 @@ + + + + Nu se poate șterge %d tunel: %s + Nu se pot șterge %d tunele: %s + Nu se pot șterge %d de tunele: %s + + + %d tunel a fost șters + %d tunele șterse + %d de tunele șterse + + + %d tunel selectat + %d tuneluri selectate + %d de tuneluri selectate + + + %1$d din %2$d tuneluri importate + %1$d din %2$d tuneluri importate + %1$d din %2$d de tuneluri importate + + + %d tunel importat + %d tuneluri importate + %d de tuneluri importate + + + %d aplicație exclusă + %d aplicații excluse + %d de aplicații excluse + + + %d aplicație inclusă + %d aplicații incluse + %d de aplicații incluse + + + %d exclusă + %d excluse + %d excluse + + + %d inclusă + %d incluse + %d incluse + + Toate aplicațiile + Excludere + Includere numai + + Include %d aplicație + Include %d aplicații + Include %d de aplicații + + + Exclude %d aplicație + Exclude %d aplicații + Exclude %d de aplicații + + + în fiecare secundă + la fiecare %d secunde + la fiecare %d secunde + + + secundă + secunde + secunde + + Utilizează toate aplicațiile + Adăugare pereche + Adrese + Aplicații + Aplicațiile externe nu pot comuta tunelurile (recomandat) + Aplicațiile externe pot comuta tunelurile (avansat) + Permite aplicații de control la distanță + IP-uri permise + %2$s pentru %1$s + %s + %1$s în %2$s + : Trebuie să fie pozitiv și nu mai mare decât 65535 + : Trebuie să fie pozitiv + : Trebuie să fie un număr de port UDP valid + Cheie invalidă + Număr invalid + Valoare invalidă + Atribuit lipsă + Secțiune lipsă + Eroare de sintaxă + Atribuit necunoscut + Secțiune necunoscută + Valoare în afara intervalului + Fișierul trebuie să fie .conf sau .zip + Codul QR nu a fost găsit în imagine + Nu a putut fi efectuată verificarea sumei de control pentru codul QR + Anulare + Fișierul de configurare %s nu poate fi șters + Configurația pentru „%s” există deja + Fișierul de configurare „%s” există deja + Fișier de configurare „%s” negăsit + Fișierul de configurare „%s” nu poate fi redenumit + Nu poate fi salvată configurația pentru „%1$s”: %2$s + Configurația pentru „%s” a fost salvată + Creare tunel WireGuard + Nu se poate crea directorul binar local + Fișierul nu poate fi creat în directorul pentru descărcări + Creare de la zero + Importare din fișier sau arhivă + Scanare din cod QR + Nu se poate crea directorul de ieșire + Nu se poate crea directorul temporar local + Creare tunel + %s a fost copiat în memoria temporară + În prezent este utilizată tema luminoasă (zi) + În prezent este utilizată tema întunecată (noapte) + Utilizare temă întunecată + Ștergere + Selectează tunelul de șters + Selectează o unitate de stocare + Instalează un serviciu de administrare a fișierelor pentru a căuta fișiere + Adaugă un tunel pentru a începe + ♥ Donează pentru proiectul WireGuard + Fiecare contribuţie ajută + Vă mulțumim pentru sprijinul acordat Proiectului WireGuard!\n\nDin păcate, din cauza politicilor Google, nu avem voie să punem un link către pagina web a proiectului unde poți face o donație. Sperăm că vă puteți descurca!\n\nMulțumim din nou pentru contribuție. + Dezactivează exportarea configurației + Dezactivarea exportării configurației face mai puțin accesibile cheile private + Servere DNS + Domenii de căutare + Editare + Punct final + Eroare la oprirea tunelului: %s + Eroare la preluarea listei de aplicații: %s + Obține acces root și încearcă din nou + Eroare la pregătirea tunelului: %s + Eroare la pornirea tunelului: %s + Excludere IP-uri private + Generare cheie privată nouă + Eroare „%s” necunoscută + (auto) + (generată) + (opțională) + (opțională, nerecomandată) + (aleatorie) + Nume nepermis de fișier „%s” + Tunelul nu poate fi importat: %s + Importare tunel din cod QR + „%s” importat + Interfață + Caractere incorecte în cheie + Lungime incorectă a cheii + : Cheile base64 ale WireGuard trebuie să aibă 44 de caractere (32 de octeți) + : Cheile WireGuard trebuie să aibă 32 de octeți + : Cheile hex WireGuard trebuie să aibă 64 de caractere (32 de octeți) + Cea mai recentă negociere + %s în urmă + Port de ascultare + Jurnalul nu poate fi exportat: %s + Fișier de jurnal Android WireGuard + Salvat în „%s” + Exportare fișier de jurnal + Salvare jurnal + Jurnalele pot ajuta la depanare + Vizualizare jurnal aplicație + Jurnal + Programul logcat nu poate fi executat: + Modulul experimental de nucleu poate îmbunătăți performanța + Activează biblioteca modulului de nucleu + Biblioteca mai lentă a spațiului utilizatorului poate îmbunătăți stabilitatea + Dezactivează biblioteca modulului de nucleu + A apărut o eroare. Încearcă din nou + Modulul experimental de nucleu poate îmbunătăți performanța + Nu sunt disponibile module pentru dispozitivul tău + Descărcare și instalare modul nucleu + Se descarcă și se instalează… + Nu se poate determina versiunea modulului de nucleu + MTU + Pornirea unui tunel va opri celelalte tuneluri + Mai multe tunele pot fi pornite simultan + Permite mai multe tuneluri simultane + Nume + Se încearcă pornirea unui tunel fără configurație + Nu au fost găsite configurații + Nu există tuneluri + șir + Adresă IP + punct final + Rețea IP + număr + Nu se poate analiza %1$s „%2$s” + Pereche + controleze tuneluri WireGuard și, astfel, să activeze și să dezactiveze tuneluri în mod automat, existând posibilitatea de a dirija greșit traficul de internet + controleze tuneluri WireGuard + Mesaj keepalive persistent + Cheie predistribuită + activată + Cheie privată + Cheie publică + Sfat: generează cu `qrencode -t ansiutf8 < tunnel.conf`. + Adaugă secțiune la panoul de setări rapide + Comanda rapidă comută cel mai recent tunel + Tunelurile activate nu vor fi pornite odată cu pornirea dispozitivului + Tunelurile activate vor fi pornite odată cu pornirea dispozitivului + Restaurare la pornire + Salvare + Selectare totală + Setări + Interfața de comunicare nu poate citi starea de ieșire + Interfața de comunicare a așteptat 4 marcatori, a primit %d + Interfața de comunicare nu a putut porni: %d + Succes. Aplicația se va reporni acum… + Comutare toate + Eroare la comutarea tunelului WireGuard: %s + wg și wg-quick sunt deja instalate + Nu se pot instala instrumentele de linie de comandă (lipsă root?) + Instalare instrumente opționale pentru scriptare + Instalare instrumente opționale pentru scriptare ca modul Magisk + Instalare instrumente opționale pentru scriptare în partiția de sistem + wg și wg-quick instalate ca modul Magisk (este necesară repornirea) + wg și wg-quick instalate în partiția de sistem + Instalare instrumente de linie de comandă + Se instalează wg și wg-quick + Instrumentele necesare sunt indisponibile + Transferare + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Nu se poate crea dispozitiv TUN + Tunelul nu poate fi configurat (wg-quick a returnat %d) + Tunelul nu poate fi creat: %s + Tunelul „%s” a fost creat + Tunelul „%s” există deja + Nume invalid + Numele tunelului + Tunelul nu poate fi pornit (wgTurnOn a returnat %d) + Nu se poate rezolva numele gazdei DNS: „%s” + Tunelul nu poate fi redenumit: %s + Tunelul a fost redenumit ca „%s” + Spațiu de utilizator Go + Modul nucleu + Eroare necunoscută + Bibliotecă %1$s %2$s + Se verifică versiunea bibliotecii %s + Versiune %s necunoscută + WireGuard pentru Android v%s + Serviciul VPN nu este autorizat de utilizator + Serviciul VPN Android nu poate fi pornit + Tunelurile nu pot fi exportate: %s + Salvat în „%s” + Fișierul zip va fi salvat în dosarul pentru descărcări + Exportare tuneluri în fișierul zip + Autentifică-te pentru a exporta tunelurile + Autentifică-te pentru a vizualiza cheia privată + Eroare la autentificare + Eroare la autentificare: %s + 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 0000000..231ca93 --- /dev/null +++ b/ui/src/main/res/values-ru/strings.xml @@ -0,0 +1,285 @@ + + + + Не удалось удалить %d туннель: %s + Не удалось удалить %d туннеля: %s + Не удалось удалить %d туннелей: %s + Не удалось удалить %d туннелей: %s + + + Успешно удален %d туннель + Успешно удалены %d туннеля + Успешно удалено %d туннелей + Успешно удалено %d туннелей + + + %d туннель выбран + %d туннеля выбрано + %d туннелей выбрано + %d туннелей выбрано + + + Импортирован %1$d из %2$d туннелей + Импортировано %1$d из %2$d туннелей + Импортировано %1$d из %2$d туннелей + Импортировано %1$d из %2$d туннелей + + + Импортирован %d туннель + Импортировано %d туннеля + Импортировано %d туннелей + Импортировано %d туннелей + + + %d исключенное приложение + %d исключенных приложения + %d исключенных приложений + %d исключенных приложений + + + %d включенное приложение + %d включенных приложения + %d включенных приложений + %d включенных приложений + + + %d исключено + %d исключено + %d исключено + %d исключено + + + %d включено + %d включено + %d включено + %d включено + + Все приложения + Исключить для + Включить для + + Включить %d приложение + Включить %d приложения + Включить %d приложений + Включить %d приложений + + + Исключить %d приложение + Исключить %d приложения + Исключить %d приложений + Исключить %d приложений + + + каждую %d секунду + каждые %d секунды + каждые %d секунд + каждые %d секунд + + + секунда + секунды + секунд + секунд + + Использовать все приложения + Добавить пир + Адреса + Приложения + Внешние приложения не могут переключать туннели (рекомендуется) + Внешние приложения могут переключать туннели (продвинутые) + Разрешить управление через внешние приложения + Разрешенные IP-адреса + %2$s в %1$s + %s + %1$s в %2$s + : Значение должно быть больше нуля, но меньше 65535 + : Значение должно быть больше нуля + : Должен быть допустимым UDP-портом + Неправильный ключ + Неправильный номер + Недопустимое значение + Несуществующий атрибут + Несуществующий раздел + Синтаксическая ошибка + Неизвестный атрибут + Неизвестный раздел + Значение вне диапазона + Файл должен иметь формат .conf или .zip + QR-код не найден на изображении + Ошибка проверки контрольной суммы QR-кода + Отмена + Не удалось удалить файл конфигурации %s + Конфигурация для “%s” уже существует + Файл конфигурации “%s” уже существует + Файл конфигурации “%s” не найден + Не удалось переименовать файл конфигурации “%s” + Не удалось сохранить конфигурацию для “%1$s”: %2$s + Конфигурация для “%s” успешно сохранена + Создать туннель WireGuard + Не удалось создать локальный бинарный каталог + Не удалось создать файл в каталоге загрузок + Создать с нуля + Импорт из файла или архива + Сканировать QR-код + Не удалось создать выходной каталог + Не удалось создать временный локальный каталог + Создать туннель + Скопирован(о) в буфер обмена: %s + В данный момент используется светлая (дневная) тема + В данный момент используется темная (ночная) тема + Использовать темную тему + Удалить + Выберите туннель для удаления + Выберите накопитель + Пожалуйста, установите утилиту управления файлами для их просмотра + Добавьте туннель, чтобы начать + ♥ Пожертвовать проекту WireGuard + Каждое пожертвование помогает + Спасибо за поддержку проекта WireGuard!\n\nК сожалению, из-за политики Google, нельзя размещать ссылку на тот раздел сайта проекта, где можно сделать пожертвование. Надеемся, вы сможете разобраться самостоятельно!\n\nЕщё раз спасибо за ваш вклад. + Отключить экспорт конфигурации + Отключение экспорта конфигурации делает приватные ключи менее доступными + DNS-серверы + Домены поиска + Изменить + Конечная точка + Ошибка при отключении туннеля: %s + Ошибка при получении списка приложений: %s + Пожалуйста, получите root-доступ и попробуйте снова + Ошибка при подготовке туннеля: %s + Ошибка при запуске туннеля: %s + Исключить частные IP-адреса + Сгенерировать новый приватный ключ + Неизвестная ошибка “%s” + (авто) + (сгенерирован) + (опционально) + (необязательно, не рекомендуется) + (случайно) + Недопустимое имя файла “%s” + Невозможно импортировать туннель: %s + Импортировать туннель из QR-кода + Импортировано “%s” + Интерфейс + Плохие символы в ключе + Неправильная длина ключа + : ключи WireGuard base64 должны содержать 44 символа (32 байта) + : ключи WireGuard должны быть 32 байта + : HEX-ключи WireGuard должны содержать 64 символа (32 байта) + Последнее рукопожатие + %s назад + Порт + Не удалось экспортировать журнал: %s + Файл журнала WireGuard Android + Сохранено в “%s” + Экспорт журнала в файл + Сохранить журнал + Журналы могут помочь при отладке + Просмотр журналов приложения + Журнал + Не удалось запустить logcat: + Экспериментальный модуль ядра может улучшить производительность + Включить бэкэнд модуля ядра + Пользовательское пространство немного медленнее и улучшает стабильность + Отключить бэкэнд модуля ядра + Что-то пошло не так. Пожалуйста, попробуйте еще раз + Экспериментальный модуль ядра может улучшить производительность + Для вашего устройства нет доступных модулей + Скачать и установить модуль ядра + Скачивание и установка… + Невозможно определить версию модуля ядра + MTU + Включение одного туннеля отключит другие + Одновременно можно включить несколько туннелей + Несколько туннелей + Название + Попытка поднять туннель без конфигурации + Конфигурации не найдены + Туннелей не существует + строка + IP-адрес + конечная точка + IP-сеть + число + Невозможно разобрать %1$s “%2$s” + Пир + контроль над туннелями WireGuard, включение и отключение туннелей по своему усмотрению, возможность неправильного управления сетевым трафиком + управлять туннелями WireGuard + Постоянное соединение + Общий ключ + включено + Приватный ключ + Публичный ключ + Совет: генерировать с “qrencode -t ansiutf8 < tunnel.conf”. + Добавить элемент в панель быстрых настроек + Элемент переключает последний активный туннель + Не удается добавить ярлык: ошибка %d + Переключить туннель + Не поднимать ранее выбранные туннели при загрузке + Поднимать ранее выбранные туннели при загрузке + Восстановить при загрузке + Сохранить + Выбрать все + Настройки + Shell не может прочитать статус выхода + Shell ожидает 4 маркера, получено %d + Не удалось запустить Shell: %d + Успешно. Приложение будет перезапущено… + Инвертировать все + Ошибка переключения туннеля WireGuard: %s + wg и wg-quick уже установлены + Не удалось установить инструменты командной строки (нет root?) + Установить дополнительные инструменты для сценариев + Установить дополнительные инструменты для сценариев в качестве модуля Magisk + Установить дополнительные инструменты для сценариев в системный раздел + wg и wg-quick установлены как модуль Magisk (требуется перезагрузка) + wg и wg-quick установлены в системный раздел + Установить инструменты командной строки + Установка wg и wg-quick + Необходимые инструменты недоступны + Статистика + %d Б + %.2f ГиБ + %.2f КиБ + %.2f МиБ + Принято: %1$s, Передано: %2$s + %.2f ТиБ + Не удалось создать устройство tun + Не удалось настроить туннель (wg-quick вернул %d) + Не удалось создать туннель: %s + Успешно создан туннель “%s” + Туннель “%s” уже существует + Неправильное имя + Добавьте туннель с помощью кнопки ниже + Название туннеля + Не удалось включить туннель (wgTurnOn вернул %d) + Не удалось определить DNS имя: “%s” + Не удалось переименовать туннель: %s + Туннель успешно переименован в “%s” + Go в пользовательском пространстве + Модуль ядра + Неизвестная ошибка + Доступно обновление приложения. Пожалуйста, обновите. + Загрузить и установить + Получение метаданных обновления… + Загрузка обновления: %1$s / %2$s (%3$.2f%%) + Загрузка обновления: %s + Установка обновления… + Ошибка обновления: %s. Повторите попытку… + Приложение повреждено + Приложение повреждено. Загрузите APK с сайта, указанного ниже, затем удалите это приложение и установите из загруженного APK. + Открыть сайт + Бэкенд: %1$s %2$s + Проверка версии бэкэнда %s + Неизвестная версия %s + WireGuard для Android v%s + Сервис VPN не авторизован пользователем + Не удалось запустить службу Android VPN + Не удалось экспортировать туннели: %s + Сохранено в “%s” + Zip-файл будет сохранен в папке загрузок + Экспорт туннелей в zip-файл + Аутентификация для экспорта туннелей + Аутентификация для просмотра приватного ключа + Ошибка аутентификации + Ошибка аутентификации: %s + diff --git a/ui/src/main/res/values-si-rLK/strings.xml b/ui/src/main/res/values-si-rLK/strings.xml new file mode 100644 index 0000000..f7941a1 --- /dev/null +++ b/ui/src/main/res/values-si-rLK/strings.xml @@ -0,0 +1,204 @@ + + + + බැහැර කළ යෙදුම් %d යි + බැහැර කළ යෙදුම් %d යි + + + ඇතුළත් කළ යෙදුම් %d යි + ඇතුළත් කළ යෙදුම් %d යි + + + %dක් බැහැරයි + %dක් බැහැරයි + + + %dක් ඇතුළත් + %dක් ඇතුළත් + + සියළුම යෙදුම් + බැහැර + ඇතුළත් දෑ පමණි + + යෙදුම් %dක් ඇතුළත් + යෙදුම් %dක් ඇතුළත් + + + යෙදුම් %dක් බැහැර + යෙදුම් %dක් බැහැර + + + සෑම තත්පරයකට + සෑම තත්පර %d කට + + + තත්පරය + තත්පර + + සියලුම යෙදුම් භාවිතාකරන්න + ලිපින + යෙදුම් + දුරස්ථ පාලක යෙදුම්වලට ඉඩදෙන්න + ඉඩදුන් අ.ජා.කෙ.: + %1$s\' %2$s + %s + %2$sන් %1$s + : ධනාත්මක විය යුතු අතර 65535 ට නොවැඩි විය යුතුය + : ධනාත්මක විය යුතුය + : වලංගු UDP තොට අංකයක් විය යුතුය + වලංගු නොවන යතුරකි + වලංගු නොවන අංකයකි + වලංගු නොවන අගයකි + ගුණාංගය මග හැරී ඇත + කොටස අතුරුදහන් + වාක්‍ය ඛණ්ඩ දෝෂය + නොදන්නා ගුණාංගය + නොදන්නා කොටස + අගය පරාසයෙන් පිටත + ගොනුව .conf හෝ .zip විය යුතුය + රූපයේ QR කේතය හමු නොවේ + QR කේත චෙක්සම් සත්‍යාපනය අසාර්ථක විය + අවලංගු + වින්‍යාස ගොනුව %s මැකීමට නොහැකිය + “%s” සඳහා වින්‍යාසය දැනටමත් පවතී + “%s” වින්‍යාස ගොනුව දැනටමත් පවතී + \"%s\" වින්‍යාස ගොනුව හමු නොවිණි + “%s” වින්‍යාස ගොනුව නැවත නම් කළ නොහැකිය + “%1$s”: %2$s සඳහා වින්‍යාසය සුරැකීමට නොහැකිය + “%s” සඳහා සාර්ථකව වින්‍යාසය සුරැකිණි + WireGuard Tunnel සාදන්න + දේශීය ද්විමය නාමාවලිය සෑදිය නොහැක + බාගැනීම් නාමාවලියෙහි ගොනුව සෑදීමට නොහැකිය + මුල සිට නිර්මාණය කරන්න + ගොනුවකින් හෝ සංරක්‍ෂිතයකින් ආයාතය + QR කේතයෙන් සුපිරික්සන්න + ප්‍රතිදාන නාමාවලිය සෑදිය නොහැකිය + තාවකාලික ස්ථානීය නාමාවලිය සෑදිය නොහැකිය + %s පසුරුපුවරුවට පිටපත්විය + දැනට දීප්ත (දිවා) තේමාව භාවිතා කරයි + දැනට අඳුරු (රාත්‍රී) තේමාව භාවිතා කරයි + අඳුරු තේමාව භාවිතය + මකන්න + ගබඩා ධාවකයක් තෝරන්න + කරුණාකර ගොනු පිරික්සීමට ගොනු කළමනාකරණ උපයෝගිතා ස්ථාපනය කරන්න + වින්‍යාස අපනයනය අක්‍රිය කිරීම පුද්ගලික යතුරු වලට ප්‍රවේශ වීම අඩු කරයි + ව.නා.ප. සේවාදායක + වසම් සොයන්න + සංස්කරණය + යෙදුම් ලැයිස්තුව ලබා ගැනීමේ දෝෂයකි: %s + කරුණාකර මූල ප්‍රවේශය ලබාගෙන නැවත උත්සාහ කරන්න + පෞද්. යතුරු බැහැර කරන්න + නව පෞද්. යතුර උත්පාදනය + නොදන්නා \"%s\" දෝෂයකි + (ස්වයං) + (උත්පාදිතයි) + (විකල්ප) + (විකල්ප, නිර්දේශ නොකරයි) + (අහඹු) + “%s” නීතිවිරෝධී ගොනු නාමයකි + “%s” අයාත කළා + අතුරුමුහුණත + යතුරේ නරක අකුරු + යතුරේ ආයාමය සාවද්‍යයි + : WireGuard base64 යතුරු අක්ෂර 44 (බයිට් 32) විය යුතුය. + : වයර්ගාඩ් යතුරු බයිට 32 ක් විය යුතුය + : WireGuard hex යතුරු අක්ෂර 64 (බයිට් 32) විය යුතුය. + සවන්දීමේ කෙවෙනිය + ලොගය අපනයනය කළ නොහැක: %s + WireGuard Android ලොග් ගොනුව + “%s” ට සුරැකිණි + ලොග් ගොනුව අපනයනය කරන්න + ලොගය සුරකින්න + ලඝු-සටහන් නිදොස්කරණයට සහාය විය හැක + යෙදුම් ලොගය බලන්න + ලඝු + logcat ධාවනය කළ නොහැක: + පර්යේෂණාත්මක කර්නල් මොඩියුලය කාර්ය සාධනය වැඩි දියුණු කළ හැක + කර්නල් මොඩියුල පසුපෙළ සබල කරන්න + මන්දගාමී පරිශීලක අවකාශයේ පසුපෙළ ස්ථාවරත්වය වැඩි දියුණු කළ හැකිය + කර්නල් මොඩියුල පසුපෙළ අක්‍රීය කරන්න + මොකක්හරි වැරැද්දක් වෙලා. කරුණාකර නැවත උත්සාහ කරන්න + පර්යේෂණාත්මක කර්නල් මොඩියුලය කාර්ය සාධනය වැඩි දියුණු කළ හැක + ඔබගේ උපාංගය සඳහා මොඩියුල නොමැත + කර්නල් මොඩියුලය බාගත කර ස්ථාපනය කරන්න + බාගතවෙමින් සහ ස්ථාපනය වෙමින්… + කර්නල් මොඩියුල අනුවාදය තීරණය කළ නොහැක + MTU + එක් උමගක් සක්රිය කිරීමෙන් අනෙක් ඒවා නිවා දමනු ඇත + බහු උමං මාර්ග එකවර ක්‍රියාත්මක කළ හැක + එකවර උමං මාර්ග කිහිපයකට ඉඩ දෙන්න + නම + කිසිදු වින්‍යාසයක් නොමැති උමගක් ගෙන ඒමට උත්සාහ කිරීම + වින්‍යාස කිරීම් හමු නොවිණි + උමං මාර්ග නොමැත + නූල් + අ.ජා.කෙ. ලිපිනය + අවසන් ලක්ෂ්යය + අ.ජා.කෙ. ජාලය + අංකය + %1$s \"%2$s\" විග්‍රහ කළ නොහැක + සම වයසේ මිතුරන් + නොනැසී පැවතීම + පෙර-බෙදාගත් යතුර + සබලයි + පුද්ගලික යතුර + පොදු යතුර + ඉඟිය: `qrencode -t ansiutf8 < tunnel.conf` සමඟින් ජනනය කරන්න. + ආරම්භයේදී සක්‍රීය උමං ගෙන එන්නේ නැත + ආරම්භයේදී සක්‍රීය උමං ගෙන එනු ඇත + ආරම්භයේදී ප්‍රතිසාධනය කරන්න + සුරකින්න + සියල්ල තෝරන්න + සැකසුම් + Shell හට පිටවීමේ තත්ත්වය කියවිය නොහැක + Shell අපේක්ෂිත ලකුණු 4, %dලැබිණි + Shell ආරම්භ කිරීමට අසමත් විය: %d + සාර්ථකයි. යෙදුම දැන් නැවත ආරම්භ කෙරේ… + සියල්ල ටොගල් කරන්න + WireGuard උමං ටොගල් කිරීමේ දෝෂය: %s + wg සහ wg-quick දැනටමත් ස්ථාපනය කර ඇත + විධාන රේඛා මෙවලම් ස්ථාපනය කළ නොහැක (root නැත?) + ස්ක්‍රිප්ටින් සඳහා විකල්ප මෙවලම් ස්ථාපනය කරන්න + මැජික් මොඩියුලය ලෙස ස්ක්‍රිප්ට් කිරීම සඳහා විකල්ප මෙවලම් ස්ථාපනය කරන්න + පද්ධති කොටසට ස්ක්‍රිප්ට් කිරීම සඳහා විකල්ප මෙවලම් ස්ථාපනය කරන්න + wg සහ wg-quick Magisk මොඩියුලයක් ලෙස ස්ථාපනය කර ඇත (නැවත පණගැන්වීම අවශ්‍යයි) + wg සහ wg-quick පද්ධති කොටස තුළ ස්ථාපනය කර ඇත + විධාන රේඛා මෙවලම් ස්ථාපනය කරන්න + wg සහ wg-ඉක්මන් ස්ථාපනය කිරීම + අවශ්‍ය මෙවලම් නොමැත + මාරු + බ. %d + ගි.බ. %.2f + කි.බ. %.2f + මෙ.බ. %.2f + rx: %1$s, tx: %2$s + ටෙ.බ. %.2f + ටුන් උපාංගය සෑදීමට නොහැක + උමග වින්‍යාස කිරීමට නොහැක (wg-quick return %d) + උමග නිර්මාණය කළ නොහැක: %s + උමග \"%s\" සාර්ථකව නිර්මාණය කරන ලදී + උමං \"%s\" දැනටමත් පවතී + වලංගු නොවන නමකි + උමං නම + උමග ක්‍රියාත්මක කළ නොහැක (wgTurnOn %dආපසු ලබා දෙන ලදී) + DNS සත්කාරක නාමය විසඳිය නොහැක: \"%s\" + උමග නැවත නම් කළ නොහැක: %s + උමඟ සාර්ථකව \"%s\" ලෙස නම් කරන ලදී + පරිශීලක අවකාශයට යන්න + කර්නල් මොඩියුලය + නොදන්නා දෝෂයකි + %1$s පසුපෙළ %2$s + %s පසුබිම් අනුවාදය පරීක්ෂා කරමින් + නොදන්නා %s අනුවාදය + ඇන්ඩ්‍රොයිඩ් සඳහා වයර්ගාඩ් අනු.%s + VPN සේවාව පරිශීලකයා විසින් අනුමත කර නොමැත + Android VPN සේවාව ආරම්භ කළ නොහැක + උමං අපනයනය කළ නොහැක: %s + “%s” ට සුරැකිණි + Zip ගොනුව බාගැනීම් ෆෝල්ඩරයට සුරකිනු ඇත + zip ගොනුවට උමං අපනයනය කරන්න + උමං අපනයනය කිරීමට සත්‍යාපනය කරන්න + පුද්ගලික යතුර බැලීමට සත්‍යාපනය කරන්න + සත්‍යාපනය අසාර්ථක වීම + සත්‍යාපන අසාර්ථකත්වය: %s + diff --git a/ui/src/main/res/values-sk-rSK/strings.xml b/ui/src/main/res/values-sk-rSK/strings.xml new file mode 100644 index 0000000..40df1d7 --- /dev/null +++ b/ui/src/main/res/values-sk-rSK/strings.xml @@ -0,0 +1,158 @@ + + + Všetky aplikácie + Vylúč + Zahrň len + Použi všetky aplikácie + Pridať peera + Adresy + Aplikácie + Externé aplikácie nemôžu spustiť tunely (odporúčané) + Externé aplikácie môžu spustiť tunely (pokročilé) + Povoliť aplikáciám vzdialenú správu + Povolené IP adresy + %s + %1$s v %2$s + : Musí byť kladné a nie väčšie ako 65535 + : Musí byť kladné + : Musí byť platné číslo UDP portu + Neplatný kľúč + Neplatné číslo + Neplatná hodnota + Chýbajúci atribút + Chýbajúca sekcia + Chyba syntaxe + Neznámy atribút + Neznáma sekcia + Hodnota mimo povoleného rozsahu + Súbor musí byť .conf alebo .zip + Zrušiť + Nemožete vymazať konfiguračný súbor %s + Konfigurácia pre “%s” už existuje + Konfiguračný súbor pre “%s” už existuje + Konfiguračný súbor “%s” sa nenašiel + Nepodarilo sa premenovať konfiguračný súbor “%s” + Nepodarilo sa uložiť konfiguráciu pre “%1$s”: %2$s + Úspešne sa podarilo uložiť konfiguráciu pre “%s” + Vytvoriť WireGuard tunel + Nepodarilo sa vytvoriť lokálny priečinok pre binárne súbory + Nepodarilo sa vytvoriť súbor v priečinku stiahnuté + Vytvoriť od počiatku + Importovať zo súboru alebo archívu + Skenovať z QR kódu + Nepodarilo sa vytvoriť výstupný adresár + Nepodarilo sa vytvoriť lokálny dočasný priečinok + Vytvoriť tunel + %s skopírované do schránky + Momentálne používate svetlý (denný) vzhľad + Momentálne používate tmavý (nočný) vzhľad + Používať tmavý vzhľad + Odstrániť + Vyberte tunel na odstránenie + Vyberte úložnú jednotku + Prosím nainštalujte manažéra súborov aby ste mohli prehliadať súbory + Pridajte tunel aby ste mohli začať + Zakázať export konfigurácie + Zakázanie exportu konfigurácie spôsobí, že prístup k súkromným kľúčom sa stáva zložitým + Servery DNS + Prehľadávať domény + Upraviť + Koncový bod + Chyba pri vypínaní tunela: %s + Chyba pri načítaní zoznamu aplikácií: %s + Získajte prístup root a skúste znova + Chyba pri zapínaní tunela: %s + Vynechať súkromné IP + Generovať nový súkromný kľúč + Neznáma “%s” chyba + (automatické) + (generované) + (voliteľné) + (voliteľné, neodporúča sa) + (náhodné) + Nepovolené meno súboru “%s” + Nepodarilo sa importovať tunel: %s + Importovať tunel z QR kódu + Podarilo sa importovať “%s” + Rozhranie + Nepovolené znaky v kľúči + Nesprávna dĺžka kľúču + : WireGuard base64 kľúče musia mať 44 znakov (32 bytes) + : WireGuard kľúče musia byť 32 bytové + : WireGuard hex kľúče musia mať 64 znakov (32 bytes) + Otvorený port + Nepodarilo sa exportovať log: %s + WireGuard Android Denník udalostí + Uložené do “%s” + Exportovať denník udalostí + Uložiť denník udalostí + Denníky udalostí môžu byt nápomocné pri ladení aplikácie + Zobraziť denník udalostí aplikácie + Denník udalostí + Nepodarilo sa spustiť logcat: + Pomalší userspace backend môže zlepšiť stabilitu + Niečo sa pokazilo. Prosím, skúste znova + Pre vaše zariadenie nie sú k dispozícii žiadne moduly + Stiahnutie a inštalácia kernelového modulu + Sťahuje sa a inštaluje sa… + Maximálna prenosová jednotka + Zapnutím jedného tunela vypnete ostatné + Môžu byť zapnuté viaceré tunely naraz + Povoliť viacero tunelov naraz + Názov + Pokúšam sa zapnúť tunel bez konfigurácie + Nenašli sa žiadne konfigurácie + Neexistujú žiadne tunely + reťazec + IP adresa + koncový bod + IP sieť + číslo + Nedá sa parsovať %1$s “%2$s” + Vopred zdieľaný kľúč + povolené + Súkromný kľúč + Verejný kľúč + Tip: vygenerovať s `qrencode -t ansiutf8 < tunnel.conf`. + Obnov po štarte + Uložiť + Označiť všetko + Nastavenia + Prepnúť všetko + wg a wg-quick už sú nainštalované + Nainštalovať voliteľné nástroje pre skriptovanie + Nainštalovať voliteľné nástroje pre skriptovanie ako Magisk modul + wg a wg-quick sú nainštalované ako Magisk modul (reštart požadovaný) + Inštalácia nástrojov príkazového riadku + Inštaluje sa wg a wg-quick + Potrebné nástroje nie sú k dispozícii + Prenos + %d B + %.2f GiB + %.2f KiB + %.2f MiB + sem: %1$s, tam: %2$s + %.2f TiB + Nepodarilo sa vytvoriť tun zariadenie + Nepodarilo sa vytvoriť tunel: %s + Úspešne vytvorený tunel “%s” + Tunel “%s” už existuje + Neplatný názov + Meno tunelu + Nepodarilo sa premenovať tunel: %s + Úspešne premenovaný tunel na “%s” + Kernelový modul + Neznáma chyba + Neznáma %s verzia + WireGuard pre Android v%s + VPN služba neautorizovaná používateľom + Nepodarilo sa spustiť Android VPN službu + Nepodarilo sa exportovať tunely: %s + Uložené ako “%s” + Zip súbor bude uložený do priečinka stiahnuté + Export tunelov do zip súboru + Overovanie pre export tunelov + Authenticate to view private key + Overovanie zlyhalo + Overovanie zlyhalo: %s + diff --git a/ui/src/main/res/values-sl/strings.xml b/ui/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..f2ca4f4 --- /dev/null +++ b/ui/src/main/res/values-sl/strings.xml @@ -0,0 +1,285 @@ + + + + %d tunela ni bilo mogoče izbrisati: %s + %d tunelov ni bilo mogoče izbrisati: %s + %d tunelov ni bilo mogoče izbrisati: %s + %d tunelov ni bilo mogoče izbrisati: %s + + + %d tunel uspešno izbrisan + %d tunela uspešno izbrisana + %d tuneli uspešno izbrisani + %d tunelov uspešno izbrisanih + + + %d tunel izbran + %d tunela izbrana + %d tuneli izbrani + %d tunelov izbranih + + + %1$d tunel od %2$d uvožen + %1$d tunela od %2$d uvožena + %1$d tuneli od %2$d uvoženi + %1$d tunelov od %2$d uvoženih + + + %d tunel uvožen + %d tunela uvožena + %d tuneli uvoženi + %d tunelov uvoženih + + + %d izvzeta aplikacija + %d izvzeti aplikaciji + %d izvzete aplikacije + %d izvzetih aplikacij + + + %d vključena aplikacija + %d vključeni aplikaciji + %d vključene aplikacije + %d vključenih aplikacij + + + %d izvzeta + %d izvzeti + %d izvzete + %d izvzetih + + + %d vključena + %d vključeni + %d vključene + %d vključenih + + Vse aplikacije + Izvzemi + Vključi samo za + + Vključi za %d aplikacijo + Vključi za %d aplikaciji + Vključi za %d aplikacije + Vključi za %d aplikacij + + + Izvzemi %d aplikacijo + Izvzemi %d aplikaciji + Izvzemi %d aplikacije + Izvzemi %d aplikacij + + + vsako %d sekundo + vsaki %d sekundi + vsake %d sekunde + vsakih %d sekund + + + sekunda + sekundi + sekunde + sekund + + Vse aplikacije + Dodaj vrstnike + Naslovi + Aplikacije + Zunanjim aplikacijam ni dovoljeno preklapljati tunelov (priporočeno) + Zunanje aplikacije lahko preklapljajo tunele (napredno) + Dovoljeno upravljanje preko zunanjih aplikacij + Dovoljeni naslovi IP + %1$s-ov %2$s + %s + %1$s v %2$s + : Mora biti pozitivno in ne večje od 65535 + : Mora biti pozitivno + : Mora biti veljavna številka vrat UDP + Neveljaven ključ + Neveljavno število + Neveljavna vrednost + Manjka atribut + Manjka odsek + Sintaktična napaka + Neznan atribut + Neznani odsek + Vrednost je zunaj veljavnega območja + Datoteka mora biti .conf ali .zip + Ne najdem kode QR v sliki + Napaka pri preverjanju kontrolne vsote kode QR + Prekliči + Konfiguracijske datoteke %s ni bilo mogoče izbrisati + Konfiguracija za „%s“ že obstaja + Konfiguracijska datoteka za „%s“ že obstaja + Konfiguracijske datoteke za „%s“ ni bilo mogoče najti + Konfiguracijske datoteke „%s“ ni bilo mogoče preimenovati + Konfiguracijske datoteke za „%1$s“ ni bilo mogoče shraniti: %2$s + Konfiguracijska datoteka za „%s“ uspešno shranjena + Ustvarite tunel WireGuard + Lokalnega imenika za aplikacijo ni bilo mogoče ustvariti + Datoteke v mapi prenosov ni bilo mogoče ustvariti + Ustvari na novo + Uvozi iz datoteke ali arhiva + Skreniraj kodo QR + Izhodnega imenika ni bilo mogoče ustvariti + Lokalnega začasnega imenika ni bilo mogoče ustvariti + Ustvari tunel + %s kopirano v odložišče + V uporabi svetla (dnevna) tema + V uporabi temna (nočna) tema + Uporabi temno temo + Izbriši + Izberi tunel za izbris + Izberite podatkovni pogon + Prosim namesti orodje za upravljanje datotek za njihov ogled + Za začetek dodaj tunel + ♥ Doniraj za projekt WireGuard + Vsak prispevek šteje + Hvala, ker podpirate projekt WireGuard!\n\nŽal zaradi Googlove politike ne smemo objaviti povezave na del spletne strani projekta, kjer lahko opravite donacijo. Upamo, da se boste lahko znašli sami!\n\nŠe enkrat hvala za vaš prispevek. + Onemogoči izvoz nastavitev + Onemogočenje nastavitev za izvoz nastavitev naredi zasebne ključe manj dostopne + Strežniki DNS + Pripone DNS + Uredi + Končna točka + Napaka pri zaključevanju tunela: %s + Napaka pri poizvedovanju seznama aplikacij: %s + Prosim omogočite dostop root in poskusite ponovno + Napaka pri pripravi tunela: %s + Napaka pri vzpostavitvi tunela: %s + Izvzemi zasebne naslove IP + Izdelaj nov zasebni ključ + Neznana napaka „%s“ + (samodejno) + (izdelano) + (izbirno) + (izbirno, ni priporočljivo) + (naključno) + Neveljavno ime datoteke „%s“ + Tunela ni bilo mogoče uvoziti: %s + Uvozi tunel iz kode QR + „%s“ uvožen + Vmesnik + Napačni znaki v ključu + Napačna dolžina ključa + : WireGuardovi ključi base64 morajo vsebovati 44 znakov (32 bajtov) + : WireGuardovi ključi morajo biti veliki 32 bajtov + : WireGuardovi ključi hex morajo biti veliki 64 znakov (32 bajtov) + Zadnje rokovanje + %s nazaj + Vrata poslušanja + Dnevnika ni bilo mogoče izvoziti: %s + Dnevniška datoteka WireGuard za Android + Shranjeno v „%s“ + Izvozi dnevniško datoteko + Shrani dnevnik + Dnevniki lahko pomagajo pri razhroščevanju + Prikaži dnevnik aplikacije + Dnevnik + Ukaza logcat ni bilo mogoče izvesti: + Eksperimentalni modul jedra lahko izboljša zmogljivost + Omogoči zaledje za modul jedra + Počasnejše uporabniško zaledje lahko izboljša stabilnost + Onemogoči zaledje za modul jedra + Nekaj je šlo narobe, prosimo poskusite znova + Eksperimentalni modul jedra lahko izboljša zmogljivost + Za vašo napravo ni razpoložljivih modulov + Prenesi in namesti modul jedra + Prenašanje in nameščanje … + Verzije modula jedra ni bilo mogoče določiti + MTU + Vklop enega tunela bo izklopil druge + Več tunelov je lahko aktiviranih sočasno + Dovoli več sočasnih tunelov + Ime + Poskus vzpostavitve tunela brez konfiguracije + Ni najdenih konfiguracij + Ni tunelov + niz + Naslov IP + končna točka + Omrežje IP + število + Ni mogoče razčleniti %1$s „%2$s“ + Vrstnik + upravljanje tunelov WireGuard, aktivacija in deaktivacija tunela, zaradi česar bo internetni promet mogoče napačno usmerjen + upravljanje tunelov WireGuard + Trajno ohranjanje povezave + Ključ v skupni rabi + omogočeno + Zasebni ključ + Javni ključ + Namig: izdelajte z `qrencode -t ansiutf8 < tunnel.conf`. + Dodaj ikono v panel hitrih nastavitev + Bližnjica z ikono preklopi zadnji uporabljani tunel + Napaka pri dodajanju ikone z bližnjico: napaka %d + Preklopi tunel + Pri zagonu telefona omogočeni tuneli ne bodo aktivirani + Pri zagonu telefona bodo omogočeni tuneli aktivirani + Ponovna vzpostavitev ob zagonu + Shrani + Izberi vse + Nastavitve + Lupina ne more prebrati izhodnega statusa + Lupina je pričakovala 4 oznake, dobila pa je %d + Lupina se ni mogla zagnati: %d + Uspešno. Aplikacija se bo znova zagnala … + Preklopi vse + Napaka pri preklopu tunela WireGuard: %s + wg in wg-quick sta že nameščena + Orodij ukazne vrstice ni bilo mogoče namestiti (ni dostopa root?) + Namestitev izbirnih orodij za skripte + Namestitev izbirnih orodij za skripte kot modula Magisk + Namestitev izbirnih orodij za skripte na sistemsko particijo + wg in wg-quick kot modul Magisk nameščen (zahtevan ponovni zagon) + wg in wg-quick nameščena na sistemsko particijo + Namesti orodja za ukazno vrstico + Nameščam wg in wg-quick + Zahtevana orodja niso na voljo + Prenos + %d B + %.2f GiB + %.2f KiB + %.2f MiB + prejeto: %1$s, poslano: %2$s + %.2f TiB + Naprave tun ni bilo mogoče ustvariti + Tunela ni bilo mogoče nastaviti (wg-quick je vrnil %d) + Tunela ni bilo mogoče ustvariti: %s + Tunel „%s“ uspešno ustvarjen + Tunel „%s“ že obstaja + Neveljavno ime + Dodaj tunel s spodnjim gumbom + Ime tunela + Tunela ni bilo mogoče vključiti (wgTurnOn je vrnil %d) + Imena DNS gostitelja ni bilo mogoče razrešiti: \"%s\" + Tunela ni bilo mogoče preimenovati: %s + Tunel uspešno preimenovan v „%s“ + Uporabniški prostor Go + Modul jedra + Neznana napaka + Na voljo je posodobitev aplikacije. Prosimo, posodobite zdaj. + Prenesi in posodobi + Pridobivanje metapodatkov o posodobitvi … + Prenašanje posodobitve: %1$s / %2$s (%3$.2f %%) + Prenašanje posodobitve: %s + Nameščanje posodobitve … + Posodobitev ni uspela: %s. Ponovni poskus kmalu … + Pokvarjena aplikacija + Ta aplikacija je pokvarjena. Prosimo, ponovno prenesite APK s spletne strani na spodnji povezavi. Po tem odstranite to aplikacijo in jo ponovno namestite s prenesenim APK-jem. + Odpri spletno stran + Zaledje %1$s %2$s + Preverjam verzijo zaledja %s + Neznana verzija %s + WireGuard za Android v%s + Uporabnik ni odobril storitve VPN + Storitve Android VPN ni bilo mogoče zagnati + Tunelov ni bilo mogoče izvoziti: %s + Shranjeno v „%s“ + Datoteka zip je bila shranjena v mapo Prenosi + Izvozi tunele v datoteko zip + Za izvoz tunelov se prijavite + Za ogled zasebnega ključa se prijavite + Napaka pri overovljanju + Napaka pri overovljanju: %s + diff --git a/ui/src/main/res/values-sv-rSE/strings.xml b/ui/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 0000000..b477e3f --- /dev/null +++ b/ui/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,259 @@ + + + + Kunde inte ta bort %d tunnel: %s + Kunde inte ta bort %d tunnlar: %s + + + Tog bort %d tunneln + Tog bort %d tunnlar + + + %d tunnel vald + %d tunnlar valda + + + Importerade %1$d av %2$d tunnlar + Importerade %1$d av %2$d tunnlar + + + Importerade %d tunnel + Importerade %d tunnlar + + + %d exkluderad applikation + %d Exkluderade applikationer + + + %d inkluderad applikation + %d inkluderade applikationer + + + %d exkluderad + %d exkluderade + + + %d inkluderad + %d inkluderade + + Alla applikationer + Exkludera + Inkludera endast + + Inkludera %d app + Inkludera %d appar + + + Exkludera %d app + Exkludera %d appar + + + varje sekund + var %d sekund + + + sekund + sekunder + + Använd alla appar + Lägg till klient + Adresser + Applikationer + Externa appar kan inte växla tunnlar (rekommenderas) + Externa appar kan växla tunnlar (avancerat) + Tillåt fjärrstyrningsappar + Tillåtna IP-adresser + %1$s\'s %2$s + %s + %1$s i %2$s + : Måste vara positivt och högst 65535 + : Måste vara positivt + : Måste vara ett giltigt UDP-portnummer + Ogiltig nyckel + Ogiltigt nummer + Ogiltigt värde + Attribut saknas + Avsnitt saknas + Syntaxfel + Okänt attribut + Okänt avsnitt + Värde utanför giltigt intervall + Filen måste vara .conf eller .zip + QR-kod hittas inte i bilden + QR-kods checksifferkontroll misslyckades + Avbryt + Kan inte ta bort konfigurationsfilen %s + Konfiguration för ”%s” finns redan + Konfigurationsfil ”%s” finns redan + Konfigurationsfil ”%s” hittades inte + Kan inte byta namn på konfigurationsfil ”%s” + Kan inte spara konfigurationen för ”%1$s”: %2$s + Konfigurationen för ”%s ” sparades + Skapa WireGuard Tunnel + Kan inte skapa lokal binärkatalog + Kan inte skapa fil i nedladdningskatalogen + Skapa från grunden + Importera från fil eller arkiv + Skanna från QR-kod + Kan inte skapa utdatakatalog + Kan inte skapa lokal temporär katalog + Skapa tunnel + %s kopierades till urklipp + Använder just nu ljust (dag) tema + Använder just nu mörkt (natt) tema + Använd mörkt tema + Radera + Välj tunnel att ta bort + Välj en lagringsenhet + Installera ett filhanteringsverktyg för att bläddra bland filer + Lägg till en tunnel för att komma igång + ♥ Donera till WireGuard projektet + Varje bidrag hjälper + Tack för att du stödjer WireGuard projektet!\n\nPå grund av Googles policyer får vi dessvärre inte länka till den del av projektets webbsida där du kan göra en donation. Förhoppningsvis kan du hitta dit ändå!\n\nTack igen för ditt bidrag. + Inaktivera export av konfiguration + Inaktivering av konfigurationsexport gör privata nycklar mindre tillgängliga + DNS-servrar + Sök domäner + Redigera + Slutpunkt + Fel vid nedtagning av tunnel: %s + Fel vid hämtning av applista: %s + Vänligen få rootbehörighet och försök igen + Fel vid förberedelse av tunnel: %s + Fel vid uppstart av tunnel: %s + Uteslut privata IP-adresser + Skapa ny privat nyckel + Okänt ”%s” fel + (automatisk) + (skapad) + (valfritt) + (valfritt, rekommenderas inte) + (slumpmässigt) + Ogiltigt filnamn ”%s” + Kan inte importera tunnel: %s + Importera tunnel från QR-kod + Importerade ”%s” + Gränssnitt + Ogiltiga tecken i nyckel + Felaktig nyckellängd + : WireGuard base64 nycklar måste vara 44 tecken (32 bytes) + : WireGuard nycklar måste vara 32 bytes + : WireGuard hex nycklar måste vara 64 tecken (32 bytes) + Senaste handskakning + %s sedan + Lyssningsport + Kan inte exportera loggen: %s + WireGuard Android loggfil + Sparad till ”%s” + Exportera loggfil + Spara logg + Loggfiler kan underlätta vid felsökning + Visa applikationslogg + Logg + Kunde inte köra logcat: + Den experimentella kärnmodulen kan förbättra prestanda + Aktivera backend för kärnmodul + Det långsammare icke-kernel implementationen kan förbättra stabiliteten + Inaktivera backend för kärnmodul + Något gick fel. Vänligen försök igen + Den experimentella kärnmodulen kan förbättra prestanda + Inga moduler finns tillgängliga för din enhet + Ladda ner och installera kärnmodul + Hämtar och installerar… + Kunde inte bestämma version av kärnmodulen + MTU + Att slå på en tunnel stänger av andra + Flera tunnlar kan slås på samtidigt + Tillåt flera samtidiga tunnlar + Namn + Försöker bygga en tunnel utan konfiguration + Inga konfigurationer hittades + Inga tunnlar finns + sträng + IP-adress + slutpunkt + IP-nätverk + nummer + Kan inte tolka %1$s ”%2$s” + Klient + styra WireGuard tunnlar, aktivera och inaktivera tunnlar efter behag, möjlighet till felkoppling av internettrafik + kontrollera WireGuard tunnlar + Beständig keepalive + Fördelad nyckel + aktiverad + Privat nyckel + Offentlig nyckel + Tips: generera med `qrencode -t ansiutf8 < tunnel.conf`. + Lägg till tile i snabbinställningarna + Tilen växlar din senaste tunnel mellan på och av + Misslyckades med att skapa tile: fel %d + Växla tunnel på/av + Kommer inte ta upp aktiverade tunnlar vid uppstart + Kommer ta upp aktiverade tunnlar vid uppstart + Återställ vid uppstart + Spara + Välj alla + Inställningar + Shell kan inte läsa avslutningsstatus + Shell förväntade sig 4 markörer, tog emot %d + Shell kunde inte starta: %d + Framgång. Applikationen kommer nu att starta om… + Växla alla + Fel vid växling av WireGuard-tunnel: %s + wg och wg-quick är redan installerade + Kan inte installera kommandoradsverktyg (ej root?) + Installera valfria verktyg för skriptprogram + Installera valfria verktyg för skript som Magisk modul + Installera valfria verktyg för skriptning till systempartitionen + wg och wg-quick installerat som en Magisk modul (omstart krävs) + wg och wg-quick installerat i systempartitionen + Installera kommandoradsverktyg + Installera wg och wg-quick + Nödvändiga verktyg är inte tillgängliga + Överföring + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Kunde inte skapa tun-enhet + Går inte att konfigurera tunneln (wg-quick returnerade %d) + Kan inte skapa tunnel: %s + Lyckades skapa tunnel “%s” + Tunnel ”%s” finns redan + Ogiltigt namn + Lägg till en tunnel med knappen nedan + Tunnelns namn + Kunde inte aktivera tunneln (wgTurnOn returnerade %d) + Det går inte att lösa DNS-värdnamn: ”%s” + Kan inte byta namn på tunnel: %s + Lyckades döpa om tunnel till “%s” + Go userspace + Kärnmodul + Okänt fel + Det finns en uppdatering till appen. Vänligen uppdatera nu. + Ladda ner & uppdatera + Hämtar uppdateringens metadata… + Laddar ner uppdatering: %1$s / %2$s (%3$.2f%%) + Laddar ner uppdatering: %s + Installerar uppdatering… + Uppdatering misslyckades: %s. Försöker igen inom kort… + Applikationen är korrupt + Applikationen är korrupt. Vänligen ladda ner en APK från hemsidan länkad nedan. Avinstallera därefter denna applikation och installera den nerladdade APKn. + Öppna hemsida + %1$s bakstycke %2$s + Kontrollerar %s backstycke utgåva + Okänd %s utgåva + WireGuard för Android v%s + VPN-tjänsten är inte godkänd av användaren + Kan inte starta Android VPN-tjänst + Kan inte exportera tunnlar: %s + Sparad till ”%s” + Zip-filen kommer att sparas till nedladdningskatalogen + Exportera tunnlar till zip-fil + Godkänn för att exportera tunnlar + Godkänn för att visa tunnelns privata nycklar + Fel vid godkännande + Fel vid godkännande: %s + diff --git a/ui/src/main/res/values-tr-rTR/strings.xml b/ui/src/main/res/values-tr-rTR/strings.xml new file mode 100644 index 0000000..3ffb777 --- /dev/null +++ b/ui/src/main/res/values-tr-rTR/strings.xml @@ -0,0 +1,259 @@ + + + + %d tünel silinemedi: %s + %d tünel silinemedi: %s + + + %d tünel başarıyla silindi + %d tünel başarıyla silindi + + + %d tünel seçildi + %d tünel seçildi + + + %1$d/%2$d tünel içe aktarıldı + %1$d/%2$d tünel içe aktarıldı + + + %d tünel içe aktarıldı + %d tünel içe aktarıldı + + + %d uygulama hariç tutuldu + %d uygulama hariç tutuldu + + + %d uygulama dahil edildi + %d uygulama dahil edildi + + + %d uygulama hariç + %d uygulama hariç + + + %d uygulama dahil + %d uygulama dahil + + Tüm uygulamalar + Hariç tut + Dahil et + + %d uygulamayı dahil et + %d uygulamayı dahil et + + + %d uygulamayı hariç tut + %d uygulamayı hariç tut + + + her saniye + %d saniyede bir + + + saniye + saniye + + Tüm uygulamaları kullan + Eş ekle + Adresler + Uygulamalar + Dış uygulamalar tünelleri açıp kapatamaz (önerilir) + Dış uygulamalar tünelleri açıp kapatabilir (ileri düzey) + Uzaktan kontrol uygulamalarına izin ver + İzin verilen IP’ler + %1$s - %2$s + %s + %2$s içinde %1$s + : Pozitif olmalı ve 65535\'ten fazla olmamalıdır + : Pozitif olmalıdır + : Geçerli bir UDP port numarası olmalıdır + Geçersiz anahtar + Geçersiz numara + Geçersiz değer + Eksik öznitelik + Eksik bölüm + Sözdizimi hatası + Bilinmeyen öznitelik + Bilinmeyen bölüm + Değer sınır dışında + Dosya .conf veya .zip olmalıdır + Görselde QR kodu bulunamadı + QR kodunun sağlaması başarısız oldu + İptal + %s yapılandırma dosyası silinemedi + “%s” yapılandırması zaten mevcut + “%s“ yapılandırma dosyası zaten mevcut + “%s“ yapılandırma dosyası bulunamadı + “%s” yapılandırma dosyası yeniden adlandırılamadı + “%1$s” yapılandırması kaydedilemedi: %2$s + “%s“ yapılandırması başarıyla kaydedildi + WireGuard tüneli oluştur + Yerel ikili dizin oluşturulamadı + İndirilenler klasöründe dosya oluşturulamadı + Sıfırdan oluştur + Dosyadan veya arşivden içe aktar + QR kodu okut + Çıktı klasörü oluşturulamadı + Yerel geçici dizin oluşturulamadı + Tünel oluştur + %s panoya kopyalandı + Şu anda açık (gündüz) tema kullanılıyor + Şu anda koyu (gece) tema kullanılıyor + Koyu temayı kullan + Sil + Silinecek tüneli seçin + Bir depolama sürücüsü seçin + Dosyalara göz atmak için lütfen bir dosya yönetim aracı yükleyin + Başlamak için bir tünel ekleyin + ♥ WireGuard projesine bağış yap + Tüm bağışlarınız bizim için değerli + WireGuard projesini desteklediğiniz için teşekkür ederiz!\n\nNe yazık ki Google\'ın politikaları nedeniyle web sitemizin bağış yapabileceğiniz bölümüne bağlantı veremiyoruz. Umarız ilgili sayfayı kendiniz bulabilirsiniz.\n\nKatkılarınız için tekrar teşekkür ederiz. + Yapılandırmayı dışa aktarmayı devre dışı bırak + Yapıalandırmayı dışa aktarmayı devre dışı bırakırsanız özel anahtarlara erişmek zorlaşır + DNS sunucuları + Alan adı ara + Düzenle + Uç nokta + Tünel kapatılırken hata oluştu: %s + Uygulama listesi getirilirken hata oluştu: %s + Lütfen root erişimi elde edip yeniden deneyin + Tünel hazırlanırken hata oluştu: %s + Tünel açılırken hata oluştu: %s + Özel IP’leri hariç tut + Yeni özel anahtar oluştur + Bilinmeyen “%s” hatası + (otomatik) + (oluşturuldu) + (isteğe bağlı) + (isteğe bağlı, önerilmez) + (rastgele) + “%s” dosya adı geçersiz + Tünel içe aktarılamadı: %s + Tüneli QR kodundan içe aktar + “%s” içe aktarıldı + Arabirim + Anahtarda yanlış karakterler var + Anahtar uzunluğu yanlış + : WireGuard base64 anahtarları 44 karakter (32 bayt) olmalıdır + : WireGuard anahtarları 32 bayt olmalıdır + : WireGuard on altılık anahtarları 64 karakter (32 bayt) olmalıdır + En son el sıkışma + %s önce + Dinlenen port + Günlük dışa aktarılamadı: %s + WireGuard Android Günlük Dosyası + “%s” dosyasına kaydedildi + Günlük dosyasını dışa aktar + Günlüğü kaydet + Günlükler hata ayıklamaya yardımcı olabilir + Uygulama günlüğünü görüntüle + Günlük + Logcat çalıştırılamadı: + Deneysel çekirdek modülü performansı artırabilir + Çekirdek modülü arka ucunu etkinleştir + Daha yavaş kullanıcı uzayı arka ucu, kararlılığı artırabilir + Çekirdek modülü arka ucunu devre dışı bırak + Bir sorun oluştu. Lütfen yeniden deneyin + Deneysel çekirdek modülü performansı artırabilir + Cihazınız için uygun modül yok + Çekirdek modülünü indir ve yükle + İndiriliyor ve yükleniyor… + Çekirdek modülü sürümü belirlenemedi + MTU + Bir tüneli açtığınızda diğer tüneller kapatılır + Aynı anda birden fazla tünel açılabilir + Birden çok eş zamanlı tünele izin ver + İsim + Yapılandırmasız bir tünel açılmaya çalışılıyor + Yapılandırma bulunamadı + Hiç tünel yok + dize + IP adresi + uç nokta + IP ağı + sayı + %1$s “%2$s” ayrıştırılamıyor + + WireGuard tünellerini yöneterek tünelleri istendiği anda açma ve kapatma. İnternet trafiğini potansiyel olarak yanlış yönlendirebilir + WireGuard tünellerini yönet + Sürekli keepalive + Önceden paylaşılan anahtar + etkin + Özel anahtar + Ortak anahtar + İpucu: `qrencode -t ansiutf8 < tunnel.conf` ile oluşturabilirsiniz. + Hızlı ayarlar paneline simge ekle + Kısayol simgesi son kullandığınız tüneli açıp kapatır + Kısayol simgesi eklenemedi: hata %d + Tüneli aç/kapat + Etkin tüneller sistem açılışında başlatılmayacaktır + Etkin tüneller sistem açılışında başlatılacaktır + Sistem açılışında başlat + Kaydet + Tümünü seç + Ayarlar + Kabuk, çıkış durumunu okuyamıyor + Kabuk 4 işaret bekliyordu, %d aldı + Kabuk başlatılamadı: %d + İşlem başarılı. Uygulama şimdi yeniden başlayacak… + Tümünü aç/kapat + WireGuard tüneli açılırken/kapatılırken hata oluştu: %s + wg ve wg-quick zaten yüklenmiş + Komut satırı araçları yüklenemiyor (root erişiminiz olmayabilir) + Scripting için isteğe bağlı araçları yükle + Scripting için isteğe bağlı araçları Magisk modülü olarak yükle + Scripting için isteğe bağlı araçları sistem bölümüne yükle + wg ve wg-quick, Magisk modülü olarak yüklendi (Yeniden başlatma gerekir) + wg ve wg-quick, sistem bölümüne yüklendi + Komut satırı araçlarını yükle + wg ve wg-quick yükleniyor + Gerekli araçlar mevcut değil + Aktarım + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Tun aygıtı oluşturulamadı + Tünel yapılandırılamadı (wg-quick %d döndürdü) + Tünel oluşturulamadı: %s + “%s” tüneli başarıyla oluşturuldu + “%s” tüneli zaten mevcut + Geçersiz isim + Aşağıdaki düğmeyi kullanarak tünel ekleyebilirsiniz + Tünel adı + Tünel açılamadı (wgTornOn %d döndürdü) + DNS ana makinesi çözülemedi: \"%s\" + Tünel yeniden adlandırılamadı: %s + Tünelin adı “%s” olarak değiştirildi + Go kullanıcı uzayı + Çekirdek modülü + Bilinmeyen hata + Uygulama güncellemesi mevcut. Lütfen şimdi güncelleyin. + İndir ve güncelle + Güncellemenin meta verileri alınıyor… + Güncelleme indiriliyor: %1$s / %2$s (%3$.2f%%) + Güncelleme indiriliyor: %s + Güncelleme yükleniyor… + Güncelleme hatası: %s. Birazdan yeniden denenecek… + Uygulama hasar görmüş + Bu uygulama hasarlı. Lütfen APK dosyasını aşağıdaki web sitesinden yeniden indirin. Daha sonra bu uygulamayı kaldırın ve indirdiğiniz APK\'den yeniden yükleyin. + Web sitesini aç + %1$s arka ucu %2$s + %s arka uç sürümü kontrol ediliyor + Bilinmeyen %s sürümü + Android için WireGuard v%s + VPN hizmeti kullanıcı tarafından yetkilendirilmemiş + Android VPN hizmeti başlatılamadı + Tüneller dışa aktarılamadı: %s + “%s” dosyasına kaydedildi + Zip dosyası indirilenler klasörüne kaydedilecektir + Tünelleri zip dosyasına aktar + Tünelleri dışa aktarmak için kimliğinizi doğrulayın + Özel anahtarı görmek için kimliğinizi doğrulayın + Kimlik doğrulama hatası + Kimlik doğrulama hatası: %s + diff --git a/ui/src/main/res/values-uk-rUA/strings.xml b/ui/src/main/res/values-uk-rUA/strings.xml new file mode 100644 index 0000000..089546e --- /dev/null +++ b/ui/src/main/res/values-uk-rUA/strings.xml @@ -0,0 +1,285 @@ + + + + Неможливо видалити %d тунель: %s + Неможливо видалити %d тунелі: %s + Неможливо видалити %d тунелів: %s + Неможливо видалити %d тунелів: %s + + + Успішно видалено %d тунель + Успішно видалено %d тунелі + Успішно видалено %d тунелів + Успішно видалено %d тунелів + + + %d тунель вибрано + %d тунелі вибрано + %d тунелів вибрано + %d тунелів вибрано + + + Імпортовано %1$d з %2$d тунелів + Імпортовано %1$d з %2$d тунелів + Імпортовано %1$d з %2$d тунелів + Імпортовано %1$d з %2$d тунелів + + + Імпортовано %d тунель + Імпортовано %d тунелі + Імпортовано %d тунелів + Імпортовано %d тунелів + + + %d Виключений додаток + %d Виключених додатки + %d Виключені додатки + %d Виключених додатків + + + %d Включений додаток + %d Включені додатки + %d Включених додатків + %d Включених додатків + + + %d виключений + %d виключені + %d виключені + %d виключені + + + %d включено + %d включено + %d включено + %d включено + + Всі додатки + Виключити + Включити тільки + + Включити %d додаток + Включити %d додатки + Включити %d додатків + Включити %d додатків + + + Виключити %d додаток + Виключити %d додатки + Виключити %d додатків + Виключити %d додатків + + + кожну секунду + кожні %d секунди + кожних %d секунд + кожних %d секунд + + + секунда + секунди + секунд + секунд + + Використовувати всі додатки + Додати пір + Адреси + Додатки + Зовнішні додатки не можуть перемикати тунелі (рекомендовано) + Зовнішні додатки можуть перемикати тунелі (для досвідчених) + Дозволити керування через інші додатки + Дозволені IP адреси + %1$s з %2$s + %s + %1$s в %2$s + : Повинно бути додатним і не більше 65535 + : Повинно бути додатним + : Повинен бути дійсним номером UDP порту + Невірний ключ + Недійсний номер + Неприпустиме значення + Відсутній атрибут + Розділ відсутній + Синтаксична помилка + Невідомий атрибут + Невідома секція + Значення поза діапазоном + Файл повинен мати розширення .conf або .zip + QR-код не знайдено на зображенні + Не вдалося перевірити контрольну суму QR-коду + Скасувати + Не вдалося видалити файл конфігурації %s + Конфігурація для \"%s\" вже існує + Файл конфігурації \"%s\" вже існує + Файл конфігурації \"%s\" не знайдено + Не вдалося перейменувати файл конфігурації \"%s\" + Не вдалося зберегти конфігурацію для \"%1$s\": %2$s + Конфігурацію успішно збережено для \"%s\" + Створити тунель WireGuard + Не вдалося створити локальну бінарну теку + Не вдалося створити файл в папці завантажень + Створити з нуля + Імпортувати з файлу або архіву + Сканувати з QR-коду + Неможливо створити вихідний каталог + Не вдалося створити локальну тимчасову папку + Створити тунель + %s скопійовано в буфер обміну + Зараз використовується світла (денна) тема + Зараз використовується темна (нічна) тема + Використовувати темну тему + Видалити + Оберіть тунель для видалення + Виберіть диск зберігання + Будь ласка, встановіть провідник для перегляду файлів + Додайте тунель, щоб почати + ♥️ Пожертвуйте на проект WireGuard + Кожен внесок допомагає + Дякуємо за підтримку проекту WireGuard!\n\nНа жаль, через політику Google нам заборонено розміщувати посилання на веб-сторінку проекту, де можна зробити пожертву. Сподіваємося на розуміння!\n\nЩе раз дякую за ваш внесок. + Вимкнути експорт конфігурації + Вимкнення експорту налаштувань робить приватні ключі менш доступними + DNS-сервери + Пошук доменів + Редагувати + Endpoint + Помилка вимкнення тунелю: %s + Помилка при отриманні списку додатків: %s + Будь ласка, отримайте root-доступ і спробуйте ще раз + Помилка підготовки тунелю: %s + Помилка при увімкненні тунелю: %s + Виключити приватні IP + Згенерувати новий приватний ключ + Невідома помилка “%s” + (авто) + (згенеровано) + (необов\'язково) + (необов\'язково, не рекомендується) + (випадковий) + Некоректна назва файлу“%s” + Не вдалося імпортувати тунель: %s + Імпортувати тунель з QR-коду + Імпортовано “%s” + Інтерфейс + Недопустимі символи в ключі + Неправильна довжина ключа + : Ключі WireGuard base64 повинні мати довжину 44 символи (32 байти) + : Ключі WireGuard повинні мати довжину 32 байти + : hex ключі WireGuard повинні мати довжину 64 символи (32 байти) + Останнє рукостискання + %s тому + Порт + Не вдалося експортувати журнал: %s + Файл журналу WireGuard + Збережено до “%s” + Експорт файлу журналу + Зберегти лог + Логи можуть допомогти в налагодженні + Переглянути журнал програми + Журнал + Не вдалося запустити logcat: + Експериментальний модуль ядра може підвищити продуктивність + Увімкнути модуль ядра + Користувацький простір повільніший, проте може покращити стабільність + Вимкнути модуль ядра + Щось пішло не так. Спробуйте ще раз + Експериментальний модуль ядра може підвищити продуктивність + Немає доступних модулів для вашого пристрою + Завантажити та встановити модуль ядра + Завантаження та встановлення… + Не вдалося визначити версію модуля ядра + MTU + Увімкнення одного тунелю призведе до вимкнення інших + Декілька тунелів можуть бути увімкнені одночасно + Дозволити кілька одночасних тунелів + Назва + Спроба підняти тунель без конфігурації + Немає конфігурацій + Немає тунелів + рядок + ІР-адреса + кінцева точка + IP мережа + число + Не вдалося обробити %1$s \"%2$s\" + Пір + керувати тунелями WireGuard, вмикати та вимикати тунелі на свій розсуд, потенційно перешкоджаючи інтернет-трафіку + керування тунелями WireGuard + Постійне з\'єднання + Pre-shared ключ + увімкнений + Приватний ключ + Публічний ключ + Порада: згенеруйте за допомогою `qrencode -t ansiutf8 < tunnel.conf`. + Додати плитку для панелі швидких налаштувань + Плитка швидкого доступу перемикає останній тунель + Неможливо додати плитку (ярлик) швидкого доступу. Код помилки: %d + Перемкнути тунель + Не піднімати увімкнені тунелі при запуску + Піднімати увімкнені тунелі при запуску + Відновлювати при запуску + Зберегти + Вибрати всі + Налаштування + Shell не може прочитати статус виходу + Очікувалось 4 маркери, отримано %d + Не вдалося запустити в оболонці: %d + Успішно виконано. Додаток буде перезапущено… + Перемкнути всі + Помилка перемикання тунелю: %s + wg та wg-quick вже встановлено + Не вдалося встановити інструменти командного рядка (немає root?) + Встановити додаткові інструменти для сценаріїв + Встановити додаткові інструменти для сценаріїв в якості Magisk модуля + Встановити додаткові інструменти для сценаріїв в системний розділ + wg і wg-quick встагновлено як Magisk модуль (необхідне перезавантаження) + wg та wg-quick встановлено у системній розділ + Встановити інструменти командного рядка + Встановити wg та wg-quick + Необхідні інструменти недоступні + Передано + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Не вдалося створити tun інтерфейс + Не вдалося налаштувати тунель (wg-quick return %d) + Не вдалося створити тунель: %s + Тунель успішно створено “%s” + Тунель \"%s\" вже існує + Неприпустиме ім\'я + Додайте тунель, використовуючи кнопку нижче + Назва тунелю + Неможливо ввімкнути тунель (wgTurnon returned %d) + Не вдалося знайти DNS хост: “%s + Не вдалося перейменувати тунель: %s + Тунель успішно перейменовано на \"%s\" + Go userspace + Модуль ядра + Невідома помилка + Доступне оновлення додатка. Будь ласка, оновіть. + Завантажити та оновити + Отримання оновлених метаданих… + Завантаження оновлення: %1$s / %2$s (%3$.2f%%) + Завантаження оновлення: %s + Встановдення оновлення… + Помилка оновлення: %s. За мить спробуємо знову… + Додаток пошкоджено + Цей додаток пошкоджений. Будь ласка, повторно завантажте APK-файл із сайту нижче. Після цього, видаліть цей додаток і перевстановіть його з завантаженого APK. + Перейти на сайт + %1$s backend %2$s + Перевірка %s backend версії + Невідома версія %s + WireGuard for Android v%s + Служба VPN не авторизована користувачем + Не вдалося запустити службу Android VPN + Не вдалося експортувати тунелі: %s + Збережено до “%s” + Zip-файл буде збережено до теки завантажень + Експортувати тунелі в zip-файл + Авторизуватись для експорту тунелів + Авторизуйтеся для перегляду закритого ключа + Помилка автентифікації + Помилка автентифікації: %s + 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 0000000..5d83cee --- /dev/null +++ b/ui/src/main/res/values-v27/styles.xml @@ -0,0 +1,12 @@ + + + + diff --git a/ui/src/main/res/values-vi-rVN/strings.xml b/ui/src/main/res/values-vi-rVN/strings.xml new file mode 100644 index 0000000..79d8d6c --- /dev/null +++ b/ui/src/main/res/values-vi-rVN/strings.xml @@ -0,0 +1,139 @@ + + + + Không thể xóa %d tunnel(s): %s + + + Đã xóa thành công %d tunnel(s) + + + Đã chọn %d tunnel(s) + + + Đã nhập %1$d trong số %2$d tunnel(s) + + + Đã nhập %d tunnel(s) + + + %d Ứng dụng được loại trừ + + + %d Ứng dụng được bao gồm + + + Đã loại trừ %d + + + Đã thêm vào %d + + Tất cả các ứng dụng + Ngoại trừ + Chỉ bao gồm + + Thêm vào %d ứng dụng + + + Loại trừ %d ứng dụng + + + Mỗi %d giây + + + Giây + + Xài dùng tất cả app + Thêm cộng tác viên + Địa chỉ + Ứng dụng + Các ứng dụng bên ngoài không thể bật/tắt tunnels (khuyến nghị) + Các ứng dụng bên ngoài có thể bật/tắt tunnels (nâng cao) + Cho phép điều khiển ứng dụng từ xa + IP Cho phép + %1$s / %2$s + %s + %1$s trong %2$s + : Phải là số dương và không lớn hơn 65535 + Giá trị phải là số dương + : Phải là port UDP hợp lý + Khoá không hợp lệ + Số không hợp lệ + Giá trị không hợp lệ + Thuộc tính bị thiếu + Phần bị thiếu + Lỗi cú pháp + Thuộc tính không tồn tại + Mục không xác định + Giá trị vượt ngoài khoảng cho phép + File phải là .conf hoặc .zip + Không tìm thấy QR code trong ảnh + Kiểm tra checksum QR code không thành công + Hủy + Không thể xóa file cấu hình \"%s\" + Cấu hình cho \"%s\" đã tồn tại + File cấu hình cho \"%s\" đã tồn tại + Không tìm thấy file cấu hình \"%s\" + Không thể xóa file cấu hình \"%s\" + Không thể lưu cấu hình cho \"%1$s\": %2$s + Đã lưu cấu hình thành công cho \"%s\" + Tạo ra Wireguard VPN + Không thế tạo local binary directory + Không thể tạo file trong thư mục download + Làm lại từ đầu + Nhập từ file hoặc archive + Quét mã QR + Không thể tạo tập tin xuất ra + Không thế tạo local binary directory + Tạo VPN + %s đã sao chép vào bộ nhớ tạm + Đang sử dụng ánh sáng (ngày) + Đang sử dụng đề tối (ban đêm) + Sử dụng đề tối + Xóa + Chọn tunnel để xóa + Chọn bộ lưu trữ + Vui lòng cài đặt tệp tiện ích lưu trữ để tìm kiếm các tệp + Thêm một tunnel để bắt đầu + ❤️ Đóng góp cho Dự án Wireguard + Mọi đóng góp đều giúp ích + Cảm ơn bạn đã ủng hộ WireGuard!\n\nThật tiếc, dựa trên điều khoản của Google, chúng tôi không thể đưa vào liên kết dẫn đến trang đóng góp ở trang chủ của dự án. Mong rằng bạn có thể tìm cách cho việc này!\n\nXin cảm ơn bạn một lần nữa vì đã đóng góp. + Vô hiệu hóa xuất cấu hình + Vô hiệu hóa xuất cấu hình sẽ giúp giảm khả năng truy cập vào private keys + DNS servers + Tên miền của DNS tìm kiếm + Chỉnh sửa + Đầu cuối + Có lỗi khi tắt tunnel: %s + Lỗi khi lấy danh sách ứng dụng: %s + Vui lòng truy cập bằng quyền root và thử lại + Lỗi khi chuẩn bị tunnel: %s + Có lỗi khi bật tunnel: %s + Loại trừ IPs private + Tạo private key mới + Lỗi \"%s\" không xác định + (tự động) + (được tạo tự động) + (tùy chọn) + (tùy chọn, không khuyến khích) + (ngẫu nhiên) + Tên file không hợp lệ \"%s\" + Không thể nhập tunnel: %s + Nhập tunnel từ mã QR + Đã nhập \"%s\" + Giao diện + Kí tự không hợp lệ trong khoá + Độ dài khoá không hợp lệ + : Khoá WireGuard base64 phải đủ 44 ký tự (32 bytes) + : Khoá WireGuard phải đủ 32 bytes + : Khoá WireGuard hex phải đủ 64 ký tự (32 bytes) + Lần bắt tay cuối + %s giây trước + Cổng + Không thể xuất nhật ký: %s + File nhật ký WireGuard Android + Đã lưu vào \"%s\" + Xuất file nhật ký + Lưu nhật ký + Địa chỉ IP + Đồng trang lứa + 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 0000000..7757e2f --- /dev/null +++ b/ui/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,246 @@ + + + + 无法删除 %d 项:%s + + + 成功删除了 %d 项 + + + 已选择 %d 个隧道 + + + 导入了 %2$d 项中的 %1$d 项 + + + 导入了 %d 个隧道 + + + 对 %d 个应用不生效 + + + 对 %d 个应用生效 + + + 对 %d 个应用不生效 + + + 对 %d 个应用生效 + + 应用过滤 + 不生效 + 生效 + + 选定 %d 个应用 + + + 选定 %d 个应用 + + + 每隔 %d 秒 + + + + + 不设定过滤 + 添加节点 + 局域网 IP 地址 + 应用过滤 + 不允许外部应用控制隧道(推荐) + 允许外部应用控制隧道(面向高级用户) + 授权外部控制 + 路由的 IP 地址(段) + %1$s 的 %2$s 字段 + %s + 在 %2$s发生了%1$s的问题 + :必须为不超过 65535 的正整数 + :必须为正整数 + :必须为有效的 UDP 端口号 + 密钥无效 + 数字无效 + 数值无效 + 属性缺失 + 节缺失 + 语法错误 + 属性未知 + 节未知 + 数值超出范围 + 扩展名必须为 .conf 或 .zip + 图片中未发现二维码 + 二维码校验失败 + 取消 + 无法删除配置 “%s” + “%s” 的配置已存在 + 配置 “%s” 已存在 + 找不到配置 “%s” + 无法重命名配置 “%s” + 无法保存 “%1$s” 的配置:%2$s + 已保存 “%s” 的配置 + 创建 WireGuard 隧道 + 无法创建本地二进制文件目录 + 无法在下载目录中创建文件 + 手动创建 + 导入配置或压缩包 + 扫描二维码 + 无法创建输出目录 + 无法创建本地临时目录 + 创建隧道 + %s 已复制到剪贴板 + 正在使用亮色(白昼)主题 + 正在使用暗色(黑夜)主题 + 使用暗色主题 + 删除 + 选择要删除的隧道 + 选择一个存储驱动器 + 请安装一个文件管理工具以浏览文件 + 添加第一条网络隧道 + ♥ 为 WireGuard 捐赠 + 无论多寡,聚沙成塔 + 感谢您对 WireGuard 项目的支持!\n\n只可惜,受谷歌的政策所限,我们不能在此展示项目捐赠页面的链接,还望您自行访问捐赠页面!\n\n再次感谢您的贡献。 + 禁止导出配置 + 禁止导出配置可降低私钥泄露的风险 + DNS 服务器 + 搜索域名 + 编辑 + 对端 + 断开连接时出错:%s + 获取应用列表时出错:%s + 请获取 root 权限并重试 + 准备连接时出错:%s + 建立连接时出错:%s + 排除局域网 + 生成新的私钥 + 未知的 “%s” 错误 + (自动) + (生成) + (可选) + (可选,不建议设置) + (随机) + 文件名 “%s” 不合法 + 无法导入隧道:%s + 从二维码导入隧道 + 导入了 “%s” + 本地(Interface) + 密钥中含有错误字符 + 密钥长度错误 + :WireGuard 的 Base64 密钥长度必须为 44 个字符(32 字节) + :WireGuard 密钥大小必须为 32 字节 + :WireGuard 的十六进制密钥长度必须为 64 个字符(32 字节) + 上次握手时间 + %s之前 + 监听端口 + 无法导出日志:%s + WireGuard 日志文件 + 已保存至 “%s” + 导出日志文件 + 保存日志 + 日志信息有助于调试 + 查看应用日志 + 日志 + 无法运行 logcat: + 内核模块(实验性)能够增强性能,启用时需谨慎 + 启用内核模块 + 用户空间的模块性能较弱,但稳定性更好 + 停用内核模块 + 发生错误,请重试 + 使用内核模块可以提升性能(实验性) + 没有可用于此设备的模块 + 下载并安装内核模块 + 正在下载安装… + 无法确定内核模块版本 + MTU + 当前一次只能开启一条隧道 + 当前允许同时开启多条隧道 + 同时开启多条隧道 + 名称 + 尝试在无配置情况下建立连接 + 未找到配置 + 无隧道 + 字符串 + \u0020IP 地址 + 对端 + \u0020IP 网络 + 数字 + 无法解析%1$s “%2$s”\u0020 + 远程(Peer) + 自由控制 WireGuard 隧道的开启或关闭,但可能会导致流量误传 + 控制 WireGuard 隧道 + 连接保活间隔 + 预共享密钥 + 已启用 + 私钥 + 公钥 + 提示:使用命令 `qrencode -t ansiutf8 < tunnel.conf` 生成二维码 + 添加磁贴到快速设置面板 + 通过快捷磁贴开启/关闭上次使用的隧道 + 无法添加快捷磁贴:错误 %d + 开启/关闭隧道 + 未启用 + 设备启动时自动开启上次使用的隧道 + 启动时恢复 + 保存 + 全选 + 设置 + Shell 无法读取退出状态 + Shell 应获取 4 个标记,获取到 %d 个 + Shell 启动失败:%d + 成功,应用即将重启… + 全选 + 切换隧道状态时出错:%s + wg 与 wg-quick 已安装 + 无法安装命令行工具(尚未获取 root 权限?) + 安装命令行工具(可选) + 安装命令行工具为 Magisk 模块(可选) + 安装命令行工具至系统分区(可选) + wg 与 wg-quick 已安装为 Magisk 模块(重启后生效) + wg 与 wg-quick 已安装至系统分区 + 安装命令行工具 + 正在安装 wg 与 wg-quick… + 所需工具不可用 + 流量 + %d B + %.2f GiB + %.2f KiB + %.2f MiB + 接收:%1$s,发送:%2$s + %.2f TiB + 无法创建 tun 设备 + 无法配置隧道(wg-quick returned %d) + 无法创建隧道:%s + 成功创建隧道 “%s” + 名称 “%s” 已存在 + 名称无效 + 点击下方按钮添加隧道 + 隧道名称 + 无法开启隧道(wgTurnOn returned %d) + 无法解析 DNS 主机名:“%s” + 无法重命名隧道:%s + 隧道已重命名为 “%s” + Go userspace + Kernel module + 未知错误 + WireGuard 可以更新了,请立即更新。 + 下载 & 更新 + 正在获取更新元数据… + 正在下载更新:%1$s / %2$s (%3$.2f%%) + 正在下载更新:%s + 正在安装更新… + 更新失败:%s。将在稍后重试… + 应用损坏 + 此应用已损坏。请从下方链接的网站中重新下载 APK,然后卸载此应用并重新安装。 + 打开网站 + %1$s backend %2$s + 正在检查 %s backend 版本 + 未知的 %s 版本 + WireGuard for Android v%s + 用户未授权 VPN 服务 + 无法启动 Android VPN 服务 + 无法导出隧道配置:%s + 已保存至 “%s” + zip 压缩包将保存至下载文件夹 + 导出隧道配置 + 导出配置前需要通过认证 + 查看私钥前需要通过认证 + 认证失败 + 认证失败:%s + diff --git a/ui/src/main/res/values-zh-rTW/strings.xml b/ui/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..332fede --- /dev/null +++ b/ui/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,246 @@ + + + + 無法刪除 %d 個通道: %s + + + 成功刪除 %d 個通道 + + + 已選擇 %d 個通道 + + + 已匯入 %1$d 個通道 (共 %2$d 個) + + + 已匯入 %d 個通道 + + + 已排除 %d 個應用程式 + + + 套用到 %d 個應用程式 + + + 已排除 %d + + + 已套用 %d + + 套用到所有應用程式 + 排除 + 只套用到 + + 已套用 %d 個應用程式 + + + 已排除 %d 個應用程式 + + + 每隔 %d 秒 + + + + + 套用到所有應用程式 + 新增端點 + 位址 + 應用程式 + 外部程式無法控制通道 (推薦) + 外部程式可以控制通道 (進階) + 允許遠端控制應用程式 + 允許的 IPs + 錯誤設定: %1$s 的 %2$s + 頂層錯誤設定: %s + 設定有誤: %1$s 在 %2$s + : 必須是不大於 65535 的正整數 + : 必須是正整數 + : 必須是一個有效的 UDP 埠號 + 無效的密鑰 + 無效的號碼 + 無效值 + 缺少屬性 + 缺少章節 + 語法錯誤 + 未知屬性 + 未知章節 + 內容值超出範圍 + 必須是 .conf 或 .zip 的檔案 + 圖片中找不到二維碼 + 二維碼校驗和驗證失敗 + 取消 + 無法刪除設定檔 %s + 設定「%s」已經存在 + 設定檔「%s」已經存在 + 找不到設定檔 「%s」 + 無法重新命名設定檔「%s」 + 無法儲存設定「%1$s」: %2$s + 成功儲存設定「%s」 + 新建 WireGuard 通道 + 無法建立本地二進位目錄 + 無法在下載目錄建立檔案 + 從空白開始建立 + 從檔案或壓縮檔匯入 + 掃描 QR code + 無法建立輸出目錄 + 無法建立本地暫存目錄 + 新建通道 + %s 已複製到剪貼簿 + 目前使用明亮(白晝)主題 + 目前使用暗黑(夜晚)主題 + 使用暗黑主題 + 刪除 + 選擇要刪除的通道 + 選擇一個儲存裝置 + 請安裝一個檔案管理程式以便瀏覽檔案 + 新增第一個通道 + ♥ 捐贈給 WireGuard 專案 + 任何的貢獻都能幫助我們 + 感謝您支援 Wireguard 計畫!\n\n由於 Google 的政策不允許我們將計畫官網的捐款連結放在應用程式中,希望您能自行造訪我們的官網來進行捐款!\n\n再次感謝您的貢獻。 + 禁止匯出設定檔 + 禁止匯出設定檔可以降低私鑰被存取的機會 + DNS 伺服器 + 搜尋網域 + 編輯 + 終端點 + 徹下通道錯誤: %s + 汲取應用程式清單錯誤: %s + 請取得 root 存取權後再試一次 + 啟動通道錯誤: %s + 啟動通道錯誤: %s + 排除私有 IPs + 產生新的私鑰 + 未知錯誤「%s」 + (自動) + (產生的) + (可選的) + (選填,不建議) + (隨機) + 不合規定的檔名「%s」 + 無法匯入通道: %s + 從 QR Code 匯入通道 + 已匯入「%s」 + 界面 + 密鑰包含錯誤的字元 + 密鑰長度不正確 + : WireGuard 的 base64 密鑰必須是 44 個字元 (32 個位元組) + : WireGuard 密鑰必須是 32 個位元組 + : WireGuard 的16進位密鑰必須是 64 個字元 (32 個位元組) + 上次交握時間 + %s 之前 + 監聽連接埠 + 無法匯出日誌: %s + WireGuard Android 日誌檔 + 儲存到「%s」 + 匯出日誌檔 + 儲存日誌 + 程式的日誌對除錯有幫助 + 檢視應用程式日誌 + 日誌 + 無法執行 logcat: + 使用實驗階段的核心模組以提升效能 + 啟用後端核心模組 + 較慢的用戶空間後端可以提升穩定性 + 停用後端核心模組 + 未知錯誤,請重試﹗ + 使用實驗階段的核心模組以提升效能 + 此裝置沒有可用的模組 + 下載並安裝核心模組 + 下載並安裝中... + 無法確認核心模組版本 + 最大傳輸單元 + 開啟通道將自動停用其它的通道 + 多個通道可能同時被開啟 + 允許同時使用多個通道 + 名稱 + 嘗試在沒有設定檔的情況下啟動通道 + 找不到設定檔 + 沒有任何通道 + 字串 + IP 位址 + 連接點 + IP 網路 + 號碼 + 無法解析 %1$s “%2$s” + 用戶 + 允許控制 WireGuard 隧道,這將隨意啟用和禁用隧道,可能會誤導 Internet 流量 + 控制 WireGuard 通道 + 保持連線 + 預分享金鑰 + 已啟用 + 私鑰 + 公鑰 + 提示: 使用 `qrencode -t ansiutf8 < tunnel.conf` 來產生 + 新增至快捷設定選單 + 透過快捷設定來開啟 / 關閉最近使用的通道 + 無法新增快捷設定:錯誤 %d + 開啟 / 關閉通道 + 開機時不會啟動已啟用的通道 + 開機時啟動已啟用的通道 + 開機時復原 + 儲存 + 全選 + 設定 + Shell 無法讀取停止狀態 + 殼牌預計有 4 個標記,但收到了 %d + Shell 啟動失敗: %d + 成功。 應用程式即將重新啟動… + 切換全部 + 切換 WireGuard 通道時發生錯誤:%s + wg 及 wg-quick 已安裝 + 無法安裝命令行工具 (沒有 root 存取權?) + 安裝額外的腳本工具 + 安裝額外的腳本工具作為 Magisk 模組 + 安裝額外的腳本工具到系統分區 + 將 wg 及 wg-quick 安裝為 Magisk 模組 (需要重新開機) + 將 wg 及 wg-quick 安裝到系統分區 + 安裝命令行工具 + 正在安裝 wg 及 wg-quick + 所需的工具不可用 + 傳輸 + %d B + %.2f GiB + %.2f KiB + %.2f MiB + 已接收:%1$s, 已傳送:%2$s + %.2f TiB + 無法建立通道裝置 + 無法設定通道 (wg-quick 回傳值:%d) + 無法建立通道: %s + 成功建立通道 \"%s\" + 通道 \"%s\" 已經存在 + 無效的名稱 + 使用下方按鈕添加通道 + 通道名稱 + 無法開啟通道 (wgTurnOn 回傳值: %d) + 無法解析 DNS 主機名稱: \"%s\" + 無法重新命名通道: %s + 成功重新命名通道 \"%s\" + Go 使用者空間 + 核心模組 + 未知的錯誤 + 已有新的更新,請立刻更新。 + 下載 & 更新 + 正在下載更新描述檔... + 正在下載更新:%1$s / %2$s (%3$.2f%%) + 正在下載更新:%s + 安裝更新中 + 更新失敗:%s。稍後將自動重試... + 應用程式已損毀 + 此應用程式已損毀。請從下方的網站連結重新下載 APK 安裝檔。在解除安裝此應用程式後,再使用下載的 APK 安裝檔重新安裝。 + 開啟網站 + %1$s 後端 %2$s + 檢查 %s 後端版本 + 未知的版本 %s + WireGuard 於 Android 版本 %s + 使用者未授權 VPN 服務 + 無法開啟 Android VPN 服務 + 無法匯入通道: %s + 儲存到「%s」 + Zip 檔將被儲存到「下載」資料夾內 + 匯出通道設定至 zip 檔 + 驗證匯出的通道 + 驗證私鑰 + 驗證失敗 + 驗證失敗: %s + diff --git a/ui/src/main/res/values/attrs.xml b/ui/src/main/res/values/attrs.xml new file mode 100644 index 0000000..ee7cd44 --- /dev/null +++ b/ui/src/main/res/values/attrs.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/ui/src/main/res/values/bools.xml b/ui/src/main/res/values/bools.xml new file mode 100644 index 0000000..24cd9f5 --- /dev/null +++ b/ui/src/main/res/values/bools.xml @@ -0,0 +1,8 @@ + + + true + true + diff --git a/ui/src/main/res/values/colors.xml b/ui/src/main/res/values/colors.xml new file mode 100644 index 0000000..f278706 --- /dev/null +++ b/ui/src/main/res/values/colors.xml @@ -0,0 +1,67 @@ + + + #1a73e8 + #005BC0 + #FFFFFF + #D8E2FF + #001A41 + #565E71 + #FFFFFF + #DBE2F9 + #131B2C + #715574 + #FFFFFF + #FBD7FC + #29132D + #BA1A1A + #FFDAD6 + #FFFFFF + #410002 + #FEFBFF + #1B1B1F + #FEFBFF + #1B1B1F + #E1E2EC + #44474F + #74777F + #F2F0F4 + #303033 + #ADC7FF + #000000 + #005BC0 + #C4C6D0 + #000000 + #ADC7FF + #002E68 + #004493 + #D8E2FF + #BFC6DC + #283041 + #3F4759 + #DBE2F9 + #DEBCDF + #402843 + #583E5B + #FBD7FC + #FFB4AB + #93000A + #690005 + #FFDAD6 + #1B1B1F + #E3E2E6 + #1B1B1F + #E3E2E6 + #44474F + #C4C6D0 + #8E9099 + #1B1B1F + #E3E2E6 + #005BC0 + #000000 + #ADC7FF + #44474F + #000000 + diff --git a/ui/src/main/res/values/dimens.xml b/ui/src/main/res/values/dimens.xml new file mode 100644 index 0000000..c2bf245 --- /dev/null +++ b/ui/src/main/res/values/dimens.xml @@ -0,0 +1,12 @@ + + + 16dp + 56dp + 8dp + 8dp + 16dp + 16dp + 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 0000000..500aaab --- /dev/null +++ b/ui/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,7 @@ + + + #871719 + diff --git a/ui/src/main/res/values/ids.xml b/ui/src/main/res/values/ids.xml new file mode 100644 index 0000000..a1e5deb --- /dev/null +++ b/ui/src/main/res/values/ids.xml @@ -0,0 +1,7 @@ + + + + diff --git a/ui/src/main/res/values/logviewer_colors.xml b/ui/src/main/res/values/logviewer_colors.xml new file mode 100644 index 0000000..6834e62 --- /dev/null +++ b/ui/src/main/res/values/logviewer_colors.xml @@ -0,0 +1,10 @@ + + + #444444 + #aa0000 + #00aa00 + #aaaa00 + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml new file mode 100644 index 0000000..32e797e --- /dev/null +++ b/ui/src/main/res/values/strings.xml @@ -0,0 +1,263 @@ + + + + Unable to delete %d tunnel: %s + Unable to delete %d tunnels: %s + + + Successfully deleted %d tunnel + Successfully deleted %d tunnels + + + %d tunnel selected + %d tunnels selected + + + Imported %1$d of %2$d tunnels + Imported %1$d of %2$d tunnels + + + Imported %d tunnel + Imported %d tunnels + + + %d Excluded Application + %d Excluded Applications + + + %d Included Application + %d Included Applications + + + %d excluded + %d excluded + + + %d included + %d included + + All Applications + Exclude + Include only + + Include %d app + Include %d apps + + + Exclude %d app + Exclude %d apps + + + every second + every %d seconds + + + second + seconds + + Use all apps + Add peer + Addresses + Applications + External apps may not toggle tunnels (recommended) + External apps may toggle tunnels (advanced) + Allow remote control apps + Allowed IPs + WireGuard + %1$s\'s %2$s + %s + %1$s in %2$s + : Must be positive and no more than 65535 + : Must be positive + : Must be a valid UDP port number + Invalid key + Invalid number + Invalid value + Missing attribute + Missing section + Syntax error + Unknown attribute + Unknown section + Value out of range + File must be .conf or .zip + QR code not found in image + QR code checksum verification failed + Cancel + Cannot delete configuration file %s + Configuration for “%s” already exists + Configuration file “%s” already exists + Configuration file “%s” not found + Cannot rename configuration file “%s” + Cannot save configuration for “%1$s”: %2$s + Successfully saved configuration for “%s” + Create WireGuard Tunnel + Cannot create local binary directory + Cannot create file in downloads directory + Create from scratch + Import from file or archive + Scan from QR code + Cannot create output directory + Cannot create local temporary directory + Create Tunnel + %s copied to clipboard + Currently using light (day) theme + Currently using dark (night) theme + Use dark theme + Delete + Select tunnel to delete + Select a storage drive + Please install a file management utility to browse files + Add a tunnel to get started + ♥ Donate to the WireGuard Project + Every contribution helps + Thank you for supporting the WireGuard Project!\n\nUnfortunately, due to Google\'s policies, we\'re not allowed to link to the part of the project webpage where you can make a donation. Hopefully you can figure this out!\n\nThanks again for your contribution. + Disable config exporting + Disabling config exporting makes private keys less accessible + DNS servers + Search domains + Edit + Endpoint + Error bringing down tunnel: %s + Error fetching apps list: %s + Please obtain root access and try again + Error preparing tunnel: %s + Error bringing up tunnel: %s + Exclude private IPs + Generate new private key + Unknown “%s” error + (auto) + (generated) + (optional) + (optional, not recommended) + (random) + Illegal file name “%s” + Unable to import tunnel: %s + Import Tunnel from QR Code + Imported “%s” + Interface + Bad characters in key + Incorrect key length + : WireGuard base64 keys must be 44 characters (32 bytes) + : WireGuard keys must be 32 bytes + : WireGuard hex keys must be 64 characters (32 bytes) + Latest handshake + %s ago + Listen port + Unable to export log: %s + WireGuard Android Log File + Saved to “%s” + Export log file + Save log + Logs may assist with debugging + View application log + Log + Unable to run logcat: + The experimental kernel module can improve performance + Enable kernel module backend + The slower userspace backend may improve stability + Disable kernel module backend + Something went wrong. Please try again + The experimental kernel module can improve performance + No modules are available for your device + Download and install kernel module + Downloading and installing… + Unable to determine kernel module version + MTU + Turning on one tunnel will turn off others + Multiple tunnels may be turned on simultaneously + Allow multiple simultaneous tunnels + Name + Trying to bring up a tunnel with no config + No configurations found + No tunnels exist + string + IP address + endpoint + IP network + number + Cannot parse %1$s “%2$s” + Peer + control WireGuard tunnels, enabling and disabling tunnels at will, potentially misdirecting Internet traffic + control WireGuard tunnels + Persistent keepalive + Pre-shared key + enabled + Private key + Public key + Tip: generate with `qrencode -t ansiutf8 < tunnel.conf`. + Add tile to quick settings panel + The shortcut tile toggles the most recent tunnel + Unable to add shortcut tile: error %d + Toggle tunnel + Will not bring up enabled tunnels at boot + Will bring up enabled tunnels at boot + Restore on boot + Save + Select all + Settings + Shell cannot read exit status + Shell expected 4 markers, received %d + Shell failed to start: %d + Success. The application will now restart… + Toggle All + Error toggling WireGuard tunnel: %s + wg and wg-quick are already installed + Unable to install command-line tools (no root?) + Install optional tools for scripting + Install optional tools for scripting as Magisk module + Install optional tools for scripting into the system partition + wg and wg-quick installed as a Magisk module (reboot required) + wg and wg-quick installed into the system partition + Install command line tools + Installing wg and wg-quick + Required tools unavailable + Transfer + %d B + %.2f GiB + %.2f KiB + %.2f MiB + rx: %1$s, tx: %2$s + %.2f TiB + Unable to create tun device + Unable to configure tunnel (wg-quick returned %d) + Unable to create tunnel: %s + Successfully created tunnel “%s” + Tunnel “%s” already exists + Invalid name + Add a tunnel using the button below + Tunnel Name + Unable to turn tunnel on (wgTurnOn returned %d) + Unable to resolve DNS hostname: “%s” + Unable to rename tunnel: %s + Successfully renamed tunnel to “%s” + Go userspace + Kernel module + Unknown error + An application update is available. Please update now. + Download & Update + Fetching update metadata… + Downloading update: %1$s / %2$s (%3$.2f%%) + Downloading update: %s + Installing update… + Update failure: %s. Will retry momentarily… + Application Corrupt + This application is corrupt. Please re-download the APK from the website linked below. After, uninstall this application, and reinstall it from the downloaded APK. + Open Website + %1$s backend %2$s + Checking %s backend version + Unknown %s version + WireGuard for Android v%s + VPN service not authorized by user + Unable to start Android VPN service + Unable to export tunnels: %s + Saved to “%s” + Zip file will be saved to downloads folder + Export tunnels to zip file + Authenticate to export tunnels + Authenticate to view private key + Authentication failure + Authentication failure: %s + diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml new file mode 100644 index 0000000..28e9fc2 --- /dev/null +++ b/ui/src/main/res/values/styles.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/values/themes.xml b/ui/src/main/res/values/themes.xml new file mode 100644 index 0000000..b5bc675 --- /dev/null +++ b/ui/src/main/res/values/themes.xml @@ -0,0 +1,34 @@ + + + + diff --git a/ui/src/main/res/xml/app_restrictions.xml b/ui/src/main/res/xml/app_restrictions.xml new file mode 100644 index 0000000..1e7b44b --- /dev/null +++ b/ui/src/main/res/xml/app_restrictions.xml @@ -0,0 +1,12 @@ + + + + diff --git a/ui/src/main/res/xml/preferences.xml b/ui/src/main/res/xml/preferences.xml new file mode 100644 index 0000000..a7f9151 --- /dev/null +++ b/ui/src/main/res/xml/preferences.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + +