Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/src/main/java/com/wireguard/android/QuickTileService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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())
Expand All @@ -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()
Expand All @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,44 @@ 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)
else
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
Expand Down
44 changes: 39 additions & 5 deletions ui/src/main/java/com/wireguard/android/model/TunnelManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, ObservableTunnel> = 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) {
Expand All @@ -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) }
Expand Down Expand Up @@ -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) }
Expand All @@ -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
Expand Down
121 changes: 121 additions & 0 deletions ui/src/main/java/com/wireguard/android/util/DisplayNameStore.kt
Original file line number Diff line number Diff line change
@@ -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<String, String>? = null

private fun file(context: Context): File = File(context.filesDir, FILENAME)

private fun ensureLoaded(context: Context): MutableMap<String, String> {
cache?.let { return it }
val map = mutableMapOf<String, String>()
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<String, String>) {
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
}
}
21 changes: 14 additions & 7 deletions ui/src/main/java/com/wireguard/android/widget/NameInputFilter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion ui/src/main/res/layout/config_naming_dialog_fragment.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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()}">

<requestFocus />
Expand Down
2 changes: 1 addition & 1 deletion ui/src/main/res/layout/tunnel_editor_fragment.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
2 changes: 1 addition & 1 deletion ui/src/main/res/layout/tunnel_list_item.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />

Expand Down
2 changes: 1 addition & 1 deletion ui/src/main/res/layout/tv_tunnel_list_item.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down