From 503ffb2179ab8c535cfc53561ec4cb93a6863b5f Mon Sep 17 00:00:00 2001 From: Turtlecute33 <1satoshi@riseup.net> Date: Sun, 15 Mar 2026 16:43:27 +0000 Subject: [PATCH] ui: add support for emoji and Unicode in tunnel display names Tunnel names in WireGuard are constrained to valid Linux interface names ([a-zA-Z0-9_=+.-]{1,15}). This makes it impossible for users to give tunnels friendly, memorable names using emoji or non-ASCII characters. This commit introduces a display name layer that decouples the user-visible tunnel label from the underlying interface name: - New DisplayNameStore persists a JSON mapping of interface name to display name in app-private storage - ObservableTunnel gains a displayName property that falls back to the interface name when no display name is set - When a user enters a name containing emoji or Unicode characters, a valid interface name is auto-generated (ASCII transliteration + hash) - NameInputFilter now allows any non-control Unicode character (up to 80 chars) instead of restricting to interface-name characters - The inputType for the name field is changed from textVisiblePassword to textNoSuggestions, enabling the emoji keyboard - All UI surfaces (tunnel list, TV list, editor, quick tile, naming dialog) display the friendly name The backend, config file storage, and WireGuard protocol layer are completely unchanged. Existing tunnels without a display name show their interface name as before. Signed-off-by: Turtlecute33 <1satoshi@riseup.net> --- .../com/wireguard/android/QuickTileService.kt | 2 +- .../android/fragment/TunnelEditorFragment.kt | 14 +- .../android/model/ObservableTunnel.kt | 25 ++++ .../wireguard/android/model/TunnelManager.kt | 44 ++++++- .../android/util/DisplayNameStore.kt | 121 ++++++++++++++++++ .../android/widget/NameInputFilter.kt | 21 ++- .../layout/config_naming_dialog_fragment.xml | 2 +- .../res/layout/tunnel_editor_fragment.xml | 2 +- ui/src/main/res/layout/tunnel_list_item.xml | 2 +- .../main/res/layout/tv_tunnel_list_item.xml | 2 +- 10 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 ui/src/main/java/com/wireguard/android/util/DisplayNameStore.kt diff --git a/ui/src/main/java/com/wireguard/android/QuickTileService.kt b/ui/src/main/java/com/wireguard/android/QuickTileService.kt index a849c4811..d64e1bfeb 100644 --- a/ui/src/main/java/com/wireguard/android/QuickTileService.kt +++ b/ui/src/main/java/com/wireguard/android/QuickTileService.kt @@ -167,7 +167,7 @@ class QuickTileService : TileService() { tile.icon = iconOff } else -> { - tile.label = tunnel.name + tile.label = tunnel.displayName 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 } diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt index f5d28ad5b..f05960504 100644 --- a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt @@ -30,6 +30,7 @@ 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.DisplayNameStore import com.wireguard.android.util.ErrorMessages import com.wireguard.android.viewmodel.ConfigProxy import com.wireguard.config.Config @@ -119,7 +120,7 @@ class TunnelEditorFragment : BaseFragment(), MenuProvider { binding!!.config!!.resolve() } catch (e: Throwable) { val error = ErrorMessages[e] - val tunnelName = if (tunnel == null) binding!!.name else tunnel!!.name + val tunnelName = if (tunnel == null) binding!!.name else tunnel!!.displayName val message = getString(R.string.config_save_error, tunnelName, error) Log.e(TAG, message, e) Snackbar.make(binding!!.mainContainer, error, Snackbar.LENGTH_LONG).show() @@ -138,10 +139,10 @@ class TunnelEditorFragment : BaseFragment(), MenuProvider { } } - tunnel!!.name != binding!!.name -> { + tunnel!!.displayName != binding!!.name -> { Log.d(TAG, "Attempting to rename tunnel to " + binding!!.name) try { - tunnel!!.setNameAsync(binding!!.name!!) + tunnel!!.setDisplayNameAsync(binding!!.name!!) onTunnelRenamed(tunnel!!, newConfig, null) } catch (e: Throwable) { onTunnelRenamed(tunnel!!, newConfig, e) @@ -211,7 +212,8 @@ class TunnelEditorFragment : BaseFragment(), MenuProvider { if (binding == null) return binding!!.config = ConfigProxy() if (tunnel != null) { - binding!!.name = tunnel!!.name + // Show display name in the editor, not the interface name + binding!!.name = tunnel!!.displayName lifecycleScope.launch { try { onConfigLoaded(tunnel!!.getConfigAsync()) @@ -227,7 +229,7 @@ class TunnelEditorFragment : BaseFragment(), MenuProvider { val ctx = activity ?: Application.get() if (throwable == null) { tunnel = newTunnel - val message = ctx.getString(R.string.tunnel_create_success, tunnel!!.name) + val message = ctx.getString(R.string.tunnel_create_success, tunnel!!.displayName) Log.d(TAG, message) Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() onFinished() @@ -249,7 +251,7 @@ class TunnelEditorFragment : BaseFragment(), MenuProvider { ) { val ctx = activity ?: Application.get() if (throwable == null) { - val message = ctx.getString(R.string.tunnel_rename_success, renamedTunnel.name) + val message = ctx.getString(R.string.tunnel_rename_success, renamedTunnel.displayName) Log.d(TAG, message) // Now save the rest of configuration changes. Log.d(TAG, "Attempting to save config of renamed tunnel " + tunnel!!.name) diff --git a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt index 227c12910..7b6e6aec7 100644 --- a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt +++ b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt @@ -32,6 +32,17 @@ class ObservableTunnel internal constructor( @Bindable override fun getName() = name + /** + * User-facing display name that may contain emoji and other Unicode characters. + * Falls back to the interface name if no display name is set. + */ + @get:Bindable + var displayName: String = name + internal set(value) { + field = value + notifyPropertyChanged(BR.displayName) + } + suspend fun setNameAsync(name: String): String = withContext(Dispatchers.Main.immediate) { if (name != this@ObservableTunnel.name) manager.setTunnelName(this@ObservableTunnel, name) @@ -39,12 +50,26 @@ class ObservableTunnel internal constructor( this@ObservableTunnel.name } + /** + * Set a new display name for this tunnel. If the display name contains characters + * not valid for Linux interface names, the interface name will be auto-generated. + */ + suspend fun setDisplayNameAsync(newDisplayName: String): String = withContext(Dispatchers.Main.immediate) { + manager.setTunnelDisplayName(this@ObservableTunnel, newDisplayName) + } + fun onNameChanged(name: String): String { this.name = name notifyPropertyChanged(BR.name) return name } + fun onDisplayNameChanged(displayName: String): String { + this.displayName = displayName + notifyPropertyChanged(BR.displayName) + return displayName + } + @get:Bindable var state = state diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt index e08623d12..040c207e9 100644 --- a/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt +++ b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt @@ -21,6 +21,7 @@ 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.DisplayNameStore import com.wireguard.android.util.ErrorMessages import com.wireguard.android.util.UserKnobs import com.wireguard.android.util.applicationScope @@ -45,18 +46,34 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { private fun addToList(name: String, config: Config?, state: Tunnel.State): ObservableTunnel { val tunnel = ObservableTunnel(this, name, config, state) + // Load display name from store + val displayName = DisplayNameStore.getDisplayName(context, name) + if (displayName != null) { + tunnel.displayName = displayName + } 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)) + /** + * Create a tunnel. If displayName contains characters not valid for a Linux interface + * name, an interface name is auto-generated and the displayName is stored separately. + */ + suspend fun create(displayName: String, config: Config?): ObservableTunnel = withContext(Dispatchers.Main.immediate) { + val interfaceName = DisplayNameStore.generateInterfaceName(displayName) ?: displayName + if (Tunnel.isNameInvalid(interfaceName)) 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) + if (tunnelMap.containsKey(interfaceName)) + throw IllegalArgumentException(context.getString(R.string.tunnel_error_already_exists, displayName)) + val tunnel = addToList(interfaceName, withContext(Dispatchers.IO) { configStore.create(interfaceName, config!!) }, Tunnel.State.DOWN) + // If display name differs from interface name, persist it + if (displayName != interfaceName) { + DisplayNameStore.setDisplayName(context, interfaceName, displayName) + tunnel.onDisplayNameChanged(displayName) + } + tunnel } suspend fun delete(tunnel: ObservableTunnel) = withContext(Dispatchers.Main.immediate) { @@ -71,6 +88,7 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.DOWN, null) } try { withContext(Dispatchers.IO) { configStore.delete(tunnel.name) } + DisplayNameStore.delete(context, tunnel.name) } catch (e: Throwable) { if (originalState == Tunnel.State.UP) withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.UP, tunnel.config) } @@ -177,6 +195,7 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { if (originalState == Tunnel.State.UP) withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.DOWN, null) } withContext(Dispatchers.IO) { configStore.rename(tunnel.name, name) } + DisplayNameStore.rename(context, tunnel.name, name) newName = tunnel.onNameChanged(name) if (originalState == Tunnel.State.UP) withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.UP, tunnel.config) } @@ -194,6 +213,21 @@ class TunnelManager(private val configStore: ConfigStore) : BaseObservable() { newName!! } + /** + * Update the display name of a tunnel. If the new display name requires a different + * interface name, the tunnel is renamed at the backend level too. + */ + suspend fun setTunnelDisplayName(tunnel: ObservableTunnel, newDisplayName: String): String = withContext(Dispatchers.Main.immediate) { + val newInterfaceName = DisplayNameStore.generateInterfaceName(newDisplayName) + if (newInterfaceName != null && newInterfaceName != tunnel.name) { + // Need to rename the underlying interface too + setTunnelName(tunnel, newInterfaceName) + } + DisplayNameStore.setDisplayName(context, tunnel.name, newDisplayName) + tunnel.onDisplayNameChanged(newDisplayName) + newDisplayName + } + suspend fun setTunnelState(tunnel: ObservableTunnel, state: Tunnel.State): Tunnel.State = withContext(Dispatchers.Main.immediate) { var newState = tunnel.state var throwable: Throwable? = null diff --git a/ui/src/main/java/com/wireguard/android/util/DisplayNameStore.kt b/ui/src/main/java/com/wireguard/android/util/DisplayNameStore.kt new file mode 100644 index 000000000..bc8ebf498 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/util/DisplayNameStore.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 android.content.Context +import android.util.Log +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.nio.charset.StandardCharsets + +/** + * Persists a mapping of interface name to user-facing display name. + * Display names may contain any Unicode characters including emoji. + * The underlying interface name remains restricted to [a-zA-Z0-9_=+.-]{1,15}. + */ +object DisplayNameStore { + private const val TAG = "WireGuard/DisplayNameStore" + private const val FILENAME = "display_names.json" + + private var cache: MutableMap? = null + + private fun file(context: Context): File = File(context.filesDir, FILENAME) + + private fun ensureLoaded(context: Context): MutableMap { + cache?.let { return it } + val map = mutableMapOf() + val f = file(context) + if (f.exists()) { + try { + val json = JSONObject(f.readText(StandardCharsets.UTF_8)) + for (key in json.keys()) { + map[key] = json.getString(key) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load display names", e) + } + } + cache = map + return map + } + + private fun persist(context: Context, map: Map) { + val json = JSONObject() + for ((k, v) in map) { + json.put(k, v) + } + try { + file(context).writeText(json.toString(), StandardCharsets.UTF_8) + } catch (e: IOException) { + Log.e(TAG, "Failed to persist display names", e) + } + } + + fun getDisplayName(context: Context, interfaceName: String): String? { + return ensureLoaded(context)[interfaceName] + } + + fun setDisplayName(context: Context, interfaceName: String, displayName: String?) { + val map = ensureLoaded(context) + if (displayName == null || displayName == interfaceName) { + map.remove(interfaceName) + } else { + map[interfaceName] = displayName + } + persist(context, map) + } + + fun rename(context: Context, oldInterfaceName: String, newInterfaceName: String) { + val map = ensureLoaded(context) + val displayName = map.remove(oldInterfaceName) + if (displayName != null) { + map[newInterfaceName] = displayName + } + persist(context, map) + } + + fun delete(context: Context, interfaceName: String) { + val map = ensureLoaded(context) + if (map.remove(interfaceName) != null) { + persist(context, map) + } + } + + /** + * Generate a valid Linux interface name from a display name that may contain + * Unicode/emoji characters. Returns null if the display name is already a valid + * interface name. + */ + fun generateInterfaceName(displayName: String): String? { + // If already valid, no generation needed + if (displayName.matches(Regex("[a-zA-Z0-9_=+.-]{1,15}"))) { + return null + } + + // Transliterate: keep ASCII alphanumeric and allowed chars, skip the rest + val sb = StringBuilder() + for (c in displayName) { + if (Character.isLetterOrDigit(c) && c.code < 128) { + sb.append(c) + } else if ("_=+.-".indexOf(c) >= 0) { + sb.append(c) + } + } + + // If we got something usable, use it (truncated) + val base = if (sb.isNotEmpty()) { + sb.toString().take(11) + } else { + "tun" + } + + // Append a short hash to avoid collisions + val hash = displayName.hashCode().toUInt().toString(36).take(4) + val name = "${base}_$hash" + return if (name.length > 15) name.take(15) else name + } +} diff --git a/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt index 93b77ba9e..80d1f4bca 100644 --- a/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt +++ b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt @@ -7,10 +7,11 @@ 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). + * InputFilter for entering WireGuard tunnel display names. + * Allows Unicode characters including emoji. The underlying interface name + * is auto-generated when the display name contains non-ASCII characters. */ class NameInputFilter : InputFilter { override fun filter( @@ -25,10 +26,9 @@ class NameInputFilter : InputFilter { 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 + // Allow any non-control character. Display name length limit is 80 characters. + if (dIndex < DISPLAY_NAME_MAX_LENGTH && isAllowed(c) && + dLength + (sIndex - sStart) < DISPLAY_NAME_MAX_LENGTH ) { ++rIndex } else { @@ -40,7 +40,14 @@ class NameInputFilter : InputFilter { } companion object { - private fun isAllowed(c: Char) = Character.isLetterOrDigit(c) || "_=+.-".indexOf(c) >= 0 + const val DISPLAY_NAME_MAX_LENGTH = 80 + + private fun isAllowed(c: Char): Boolean { + // Allow anything that isn't a control character or the path separators / and \ + if (Character.isISOControl(c)) return false + if (c == '/' || c == '\\') return false + return true + } @JvmStatic fun newInstance() = NameInputFilter() diff --git a/ui/src/main/res/layout/config_naming_dialog_fragment.xml b/ui/src/main/res/layout/config_naming_dialog_fragment.xml index 32d556abf..9127ac83a 100644 --- a/ui/src/main/res/layout/config_naming_dialog_fragment.xml +++ b/ui/src/main/res/layout/config_naming_dialog_fragment.xml @@ -26,7 +26,7 @@ android:layout_height="wrap_content" android:hint="@string/tunnel_name" android:imeOptions="actionDone" - android:inputType="textNoSuggestions|textVisiblePassword" + android:inputType="textNoSuggestions" app:filter="@{NameInputFilter.newInstance()}"> diff --git a/ui/src/main/res/layout/tunnel_editor_fragment.xml b/ui/src/main/res/layout/tunnel_editor_fragment.xml index f25d28323..d8f99f166 100644 --- a/ui/src/main/res/layout/tunnel_editor_fragment.xml +++ b/ui/src/main/res/layout/tunnel_editor_fragment.xml @@ -80,7 +80,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:imeOptions="actionNext" - android:inputType="textNoSuggestions|textVisiblePassword" + android:inputType="textNoSuggestions" android:nextFocusDown="@id/private_key_text" android:nextFocusForward="@id/private_key_text" android:text="@={name}" diff --git a/ui/src/main/res/layout/tunnel_list_item.xml b/ui/src/main/res/layout/tunnel_list_item.xml index 2b5ecece3..fa10b3929 100644 --- a/ui/src/main/res/layout/tunnel_list_item.xml +++ b/ui/src/main/res/layout/tunnel_list_item.xml @@ -48,7 +48,7 @@ android:layout_centerVertical="true" android:ellipsize="end" android:maxLines="1" - android:text="@{key}" + android:text="@{item.displayName}" android:textAppearance="?attr/textAppearanceBodyLarge" tools:text="@sample/interface_names.json/names/names/name" /> diff --git a/ui/src/main/res/layout/tv_tunnel_list_item.xml b/ui/src/main/res/layout/tv_tunnel_list_item.xml index 08336e0cd..de3c15f0a 100644 --- a/ui/src/main/res/layout/tv_tunnel_list_item.xml +++ b/ui/src/main/res/layout/tv_tunnel_list_item.xml @@ -52,7 +52,7 @@ android:id="@+id/tunnel_name" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@{item.name}" + android:text="@{item.displayName}" android:textAppearance="?attr/textAppearanceTitleLarge" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"