[PATCH] feat: add Import from Clipboard and Paste/Type config options

[email protected] Wed, 13 May 2026 19:32:35 +0300
Newsgroups com.zx2c4.lists.wireguard
Message-ID <[email protected]>
Changes:
- AddTunnelsSheet: two new buttons + REQUEST_CLIPBOARD / REQUEST_TEXT
- TunnelListFragment: handles both new request methods; showSnackbar
  promoted to internal so PasteConfigDialogFragment can call it
- PasteConfigDialogFragment: new dialog fragment with auto-fill and
  inline validation
- dialog_paste_config.xml: monospace multi-line TextInputEditText
- add_tunnels_bottom_sheet.xml: two new Material buttons wired up
- strings.xml: all new user-visible strings added
---
 .../android/fragment/AddTunnelsSheet.kt       |  18 ++++
 .../fragment/PasteConfigDialogFragment.kt     | 102 ++++++++++++++++++
 .../android/fragment/TunnelListFragment.kt    |  25 ++++-
 .../res/layout/add_tunnels_bottom_sheet.xml   |  60 +++++++++--
 .../main/res/layout/dialog_paste_config.xml   |  35 ++++++
 ui/src/main/res/values/strings.xml            |   8 ++
 6 files changed, 241 insertions(+), 7 deletions(-)
 create mode 100644 
ui/src/main/java/com/wireguard/android/fragment/PasteConfigDialogFragment.kt
 create mode 100644 ui/src/main/res/layout/dialog_paste_config.xml

diff --git 
a/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt 
b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
index 161709c3..c0e10afd 100644
--- a/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
+++ b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
@@ -68,6 +68,14 @@ class AddTunnelsSheet : BottomSheetDialogFragment() {
                     dismiss()
                     onRequestScanQRCode()
                 }
+               
 dialog.findViewById<View>(R.id.create_from_clipboard)?.setOnClickListener 
{
+                    dismiss()
+                    onRequestImportFromClipboard()
+                }
+               
 dialog.findViewById<View>(R.id.create_from_text)?.setOnClickListener {
+                    dismiss()
+                    onRequestImportFromText()
+                }
             }
         })
         val gradientDrawable = GradientDrawable().apply {
@@ -93,11 +101,21 @@ class AddTunnelsSheet : BottomSheetDialogFragment() 
{
         setFragmentResult(REQUEST_KEY_NEW_TUNNEL, Bundle().apply { 
putString(REQUEST_METHOD, REQUEST_SCAN) })
     }
 
+    private fun onRequestImportFromClipboard() {
+        setFragmentResult(REQUEST_KEY_NEW_TUNNEL, Bundle().apply { 
putString(REQUEST_METHOD, REQUEST_CLIPBOARD) })
+    }
+
+    private fun onRequestImportFromText() {
+        setFragmentResult(REQUEST_KEY_NEW_TUNNEL, Bundle().apply { 
putString(REQUEST_METHOD, REQUEST_TEXT) })
+    }
+
     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"
+        const val REQUEST_CLIPBOARD = "request_clipboard"
+        const val REQUEST_TEXT = "request_text"
     }
 }
diff --git 
a/ui/src/main/java/com/wireguard/android/fragment/PasteConfigDialogFragment.kt 
b/ui/src/main/java/com/wireguard/android/fragment/PasteConfigDialogFragment.kt
new file mode 100644
index 00000000..f6e86236
--- /dev/null
+++ 
b/ui/src/main/java/com/wireguard/android/fragment/PasteConfigDialogFragment.kt
@@ -0,0 +1,102 @@
+/*
+ * 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.content.ClipboardManager
+import android.content.Context
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.widget.Button
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+import com.wireguard.android.R
+import com.wireguard.android.util.TunnelImporter
+import kotlinx.coroutines.launch
+
+/**
+ * Dialog that lets the user paste or manually type a WireGuard config 
INI block.
+ * Fields ([Interface] / [Peer] sections) are auto-detected from the 
input text.
+ * On "Import", the config is validated and handed to [TunnelImporter].
+ */
+class PasteConfigDialogFragment : DialogFragment() {
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val inflater = LayoutInflater.from(requireContext())
+        val view = inflater.inflate(R.layout.dialog_paste_config, null)
+
+        val textInputLayout = 
view.findViewById<TextInputLayout>(R.id.config_text_input_layout)
+        val editText = 
view.findViewById<TextInputEditText>(R.id.config_edit_text)
+
+        // Pre-fill from clipboard if it looks like a WireGuard config
+        val clipboard = 
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as 
ClipboardManager
+        val clipText = 
clipboard.primaryClip?.getItemAt(0)?.coerceToText(requireContext())?.toString()
+        if (!clipText.isNullOrBlank() && 
looksLikeWireGuardConfig(clipText)) {
+            editText.setText(clipText)
+        }
+
+        val dialog = AlertDialog.Builder(requireContext())
+            .setTitle(R.string.paste_config_dialog_title)
+            .setView(view)
+            .setPositiveButton(R.string.paste_config_import, null) // 
set below to prevent auto-dismiss on error
+            .setNegativeButton(android.R.string.cancel, null)
+            .create()
+
+        dialog.setOnShowListener {
+            val importButton: Button = 
dialog.getButton(AlertDialog.BUTTON_POSITIVE)
+
+            // Validate on every keystroke — highlight error inline
+            editText.addTextChangedListener(object : TextWatcher {
+                override fun beforeTextChanged(s: CharSequence?, start: 
Int, count: Int, after: Int) = Unit
+                override fun onTextChanged(s: CharSequence?, start: 
Int, before: Int, count: Int) = Unit
+                override fun afterTextChanged(s: Editable?) {
+                    textInputLayout.error = null
+                    importButton.isEnabled = !s.isNullOrBlank()
+                }
+            })
+            importButton.isEnabled = !editText.text.isNullOrBlank()
+
+            importButton.setOnClickListener {
+                val configText = 
editText.text?.toString().orEmpty().trim()
+                if (configText.isEmpty()) {
+                    textInputLayout.error = 
getString(R.string.paste_config_empty_error)
+                    return@setOnClickListener
+                }
+                lifecycleScope.launch {
+                    TunnelImporter.importTunnel(parentFragmentManager, 
configText) { message ->
+                        // Show result via the parent fragment's 
snackbar mechanism
+                        (parentFragment as? TunnelListFragment)?.let {
+                            it.showSnackbar(message)
+                        }
+                    }
+                }
+                dismiss()
+            }
+        }
+
+        return dialog
+    }
+
+    companion object {
+        /**
+         * Heuristic: a string contains at least one [Interface] or 
[Peer] section header
+         * and at least one key = value pair typical of WireGuard 
configs.
+         */
+        fun looksLikeWireGuardConfig(text: String): Boolean {
+            val hasSection = text.contains("[Interface]", ignoreCase = 
true) ||
+                text.contains("[Peer]", ignoreCase = true)
+            val hasKeyValue = text.contains("PrivateKey", ignoreCase = 
true) ||
+                text.contains("PublicKey", ignoreCase = true) ||
+                text.contains("Address", ignoreCase = true) ||
+                text.contains("Endpoint", ignoreCase = true)
+            return hasSection && hasKeyValue
+        }
+    }
+}
diff --git 
a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt 
b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt
index 119b6afe..6ade6279 100644
--- 
a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt
+++ 
b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt
@@ -4,6 +4,8 @@
  */
 package com.wireguard.android.fragment
 
+import android.content.ClipboardManager
+import android.content.Context
 import android.content.Intent
 import android.content.res.Resources
 import android.os.Bundle
@@ -122,6 +124,15 @@ class TunnelListFragment : BaseFragment() {
                                    
 .setPrompt(getString(R.string.qr_code_hint))
                             )
                         }
+
+                        AddTunnelsSheet.REQUEST_CLIPBOARD -> {
+                            onRequestImportFromClipboard()
+                        }
+
+                        AddTunnelsSheet.REQUEST_TEXT -> {
+                            PasteConfigDialogFragment()
+                                .show(childFragmentManager, 
"PASTE_CONFIG")
+                        }
                     }
                 }
                 bottomSheet.showNow(childFragmentManager, 
"BOTTOM_SHEET")
@@ -154,6 +165,18 @@ class TunnelListFragment : BaseFragment() {
         }
     }
 
+    private fun onRequestImportFromClipboard() {
+        val clipboard = 
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as 
ClipboardManager
+        val text = 
clipboard.primaryClip?.getItemAt(0)?.coerceToText(requireContext())?.toString()
+        if (text.isNullOrBlank()) {
+            showSnackbar(getString(R.string.clipboard_empty_error))
+            return
+        }
+        lifecycleScope.launch {
+            TunnelImporter.importTunnel(parentFragmentManager, text) { 
showSnackbar(it) }
+        }
+    }
+
     private fun onTunnelDeletionFinished(count: Int, throwable: 
Throwable?) {
         val message: String
         val ctx = activity ?: Application.get()
@@ -194,7 +217,7 @@ class TunnelListFragment : BaseFragment() {
         }
     }
 
-    private fun showSnackbar(message: CharSequence) {
+    internal fun showSnackbar(message: CharSequence) {
         val binding = binding
         if (binding != null)
             Snackbar.make(binding.mainContainer, message, 
Snackbar.LENGTH_LONG)
diff --git a/ui/src/main/res/layout/add_tunnels_bottom_sheet.xml 
b/ui/src/main/res/layout/add_tunnels_bottom_sheet.xml
index 0ad1ef23..c2224f83 100644
--- a/ui/src/main/res/layout/add_tunnels_bottom_sheet.xml
+++ b/ui/src/main/res/layout/add_tunnels_bottom_sheet.xml
@@ -42,22 +42,22 @@
         android:layout_marginEnd="@dimen/normal_margin"
         android:layout_marginRight="@dimen/normal_margin"
         android:nextFocusUp="@id/create_from_file"
-        android:nextFocusDown="@id/create_empty"
-        android:nextFocusForward="@id/create_empty"
+        android:nextFocusDown="@id/create_from_clipboard"
+        android:nextFocusForward="@id/create_from_clipboard"
         android:text="@string/create_from_qr_code"
         android:textAlignment="viewStart"
         android:textColor="?attr/colorOnSurface"
         app:icon="@drawable/ic_action_scan_qr_code"
         app:iconPadding="@dimen/bottom_sheet_icon_padding"
         app:iconTint="?attr/colorSecondary"
-        app:layout_constraintBottom_toBottomOf="@+id/create_empty"
+       
 app:layout_constraintBottom_toTopOf="@+id/create_from_clipboard"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toBottomOf="@+id/create_from_file"
         app:rippleColor="?attr/colorSecondary" />
 
     <com.google.android.material.button.MaterialButton
-        android:id="@+id/create_empty"
+        android:id="@+id/create_from_clipboard"
         style="@style/Widget.Material3.Button.TextButton.Icon"
         android:layout_width="match_parent"
         android:layout_height="@dimen/bottom_sheet_item_height"
@@ -66,16 +66,64 @@
         android:layout_marginEnd="@dimen/normal_margin"
         android:layout_marginRight="@dimen/normal_margin"
         android:nextFocusUp="@id/create_from_qrcode"
-        android:text="@string/create_empty"
+        android:nextFocusDown="@id/create_from_text"
+        android:nextFocusForward="@id/create_from_text"
+        android:text="@string/create_from_clipboard"
+        android:textAlignment="viewStart"
+        android:textColor="?attr/colorOnSurface"
+        app:icon="@drawable/ic_action_copy"
+        app:iconPadding="@dimen/bottom_sheet_icon_padding"
+        app:iconTint="?attr/colorSecondary"
+        app:layout_constraintBottom_toTopOf="@+id/create_from_text"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/create_from_qrcode"
+        app:rippleColor="?attr/colorSecondary" />
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/create_from_text"
+        style="@style/Widget.Material3.Button.TextButton.Icon"
+        android:layout_width="match_parent"
+        android:layout_height="@dimen/bottom_sheet_item_height"
+        android:layout_marginStart="@dimen/normal_margin"
+        android:layout_marginLeft="@dimen/normal_margin"
+        android:layout_marginEnd="@dimen/normal_margin"
+        android:layout_marginRight="@dimen/normal_margin"
+        android:nextFocusUp="@id/create_from_clipboard"
+        android:nextFocusDown="@id/create_empty"
+        android:nextFocusForward="@id/create_empty"
+        android:text="@string/create_from_text"
         android:textAlignment="viewStart"
         android:textColor="?attr/colorOnSurface"
         app:icon="@drawable/ic_action_edit"
         app:iconPadding="@dimen/bottom_sheet_icon_padding"
         app:iconTint="?attr/colorSecondary"
+        app:layout_constraintBottom_toTopOf="@+id/create_empty"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+       
 app:layout_constraintTop_toBottomOf="@+id/create_from_clipboard"
+        app:rippleColor="?attr/colorSecondary" />
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/create_empty"
+        style="@style/Widget.Material3.Button.TextButton.Icon"
+        android:layout_width="match_parent"
+        android:layout_height="@dimen/bottom_sheet_item_height"
+        android:layout_marginStart="@dimen/normal_margin"
+        android:layout_marginLeft="@dimen/normal_margin"
+        android:layout_marginEnd="@dimen/normal_margin"
+        android:layout_marginRight="@dimen/normal_margin"
+        android:nextFocusUp="@id/create_from_text"
+        android:text="@string/create_empty"
+        android:textAlignment="viewStart"
+        android:textColor="?attr/colorOnSurface"
+        app:icon="@drawable/ic_action_add"
+        app:iconPadding="@dimen/bottom_sheet_icon_padding"
+        app:iconTint="?attr/colorSecondary"
         app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/create_from_qrcode"
+        app:layout_constraintTop_toBottomOf="@+id/create_from_text"
         app:rippleColor="?attr/colorSecondary" />
 
 </androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/ui/src/main/res/layout/dialog_paste_config.xml 
b/ui/src/main/res/layout/dialog_paste_config.xml
new file mode 100644
index 00000000..75ae5133
--- /dev/null
+++ b/ui/src/main/res/layout/dialog_paste_config.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+  ~ SPDX-License-Identifier: Apache-2.0
+  -->
+<LinearLayout 
xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:paddingStart="@dimen/normal_margin"
+    android:paddingEnd="@dimen/normal_margin"
+    android:paddingTop="8dp"
+    android:paddingBottom="8dp">
+
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/config_text_input_layout"
+        style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:hint="@string/paste_config_hint">
+
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/config_edit_text"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:fontFamily="monospace"
+            android:gravity="top|start"
+            android:inputType="textMultiLine|textNoSuggestions"
+            android:minLines="10"
+            android:maxLines="20"
+            android:scrollbars="vertical"
+            android:textSize="12sp" />
+
+    </com.google.android.material.textfield.TextInputLayout>
+
+</LinearLayout>
diff --git a/ui/src/main/res/values/strings.xml 
b/ui/src/main/res/values/strings.xml
index 32e797e6..6b07c79b 100644
--- a/ui/src/main/res/values/strings.xml
+++ b/ui/src/main/res/values/strings.xml
@@ -97,8 +97,16 @@
     <string name="create_bin_dir_error">Cannot create local binary 
directory</string>
     <string name="create_downloads_file_error">Cannot create file in 
downloads directory</string>
     <string name="create_empty">Create from scratch</string>
+    <string name="create_from_clipboard">Import from clipboard</string>
     <string name="create_from_file">Import from file or 
archive</string>
     <string name="create_from_qr_code">Scan from QR code</string>
+    <string name="create_from_text">Paste or type config</string>
+    <string name="paste_config_dialog_title">Import tunnel 
configuration</string>
+    <string name="paste_config_hint">Paste or type your WireGuard 
config here…</string>
+    <string name="paste_config_import">Import</string>
+    <string name="paste_config_empty_error">Configuration text cannot 
be empty</string>
+    <string name="clipboard_empty_error">Clipboard does not contain any 
text</string>
+    <string name="clipboard_import_success">Tunnel imported from 
clipboard</string>
     <string name="create_output_dir_error">Cannot create output 
directory</string>
     <string name="create_temp_dir_error">Cannot create local temporary 
directory</string>
     <string name="create_tunnel">Create Tunnel</string>
-- 
2.43.0