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"