From e65e815759126fadc6febcbf76ed437b3dd11d2b Mon Sep 17 00:00:00 2001
From: Tamim Hossain <132823494+codewithtamim@users.noreply.github.com>
Date: Tue, 2 Dec 2025 02:57:17 +0600
Subject: [PATCH 1/3] feat: display real-time server load and enable manual
server selection
feat: display real-time server load and enable manual server selection
---
.../main/java/net/ivpn/core/common/Mapper.kt | 12 +++
.../core/common/prefs/ServersPreference.kt | 31 ++++++++
.../core/common/prefs/ServersRepository.kt | 21 +++++
.../core/v2/serverlist/AdapterListener.java | 5 ++
.../v2/serverlist/OnServerExpandListener.kt | 34 ++++++++
.../all/AllServersRecyclerViewAdapter.kt | 62 +++++++++++++--
.../v2/serverlist/all/ServerListFragment.kt | 2 +
.../FavouriteServerListRecyclerViewAdapter.kt | 3 +-
.../v2/serverlist/holders/HostViewHolder.kt | 58 ++++++++++++++
.../v2/serverlist/holders/ServerViewHolder.kt | 16 +++-
.../ivpn/core/v2/serverlist/items/HostItem.kt | 77 ++++++++++++++++++
.../core/v2/viewmodel/ServerListViewModel.kt | 28 +++++++
.../ivpn/core/vpn/wireguard/ConfigManager.kt | 24 +++++-
core/src/main/res/drawable/ic_expand_less.xml | 14 ++++
core/src/main/res/drawable/ic_expand_more.xml | 14 ++++
core/src/main/res/layout/host_item.xml | 79 +++++++++++++++++++
core/src/main/res/layout/server_item.xml | 18 +++++
core/src/main/res/values/strings.xml | 5 ++
18 files changed, 491 insertions(+), 12 deletions(-)
create mode 100644 core/src/main/java/net/ivpn/core/v2/serverlist/OnServerExpandListener.kt
create mode 100644 core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt
create mode 100644 core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt
create mode 100644 core/src/main/res/drawable/ic_expand_less.xml
create mode 100644 core/src/main/res/drawable/ic_expand_more.xml
create mode 100644 core/src/main/res/layout/host_item.xml
diff --git a/core/src/main/java/net/ivpn/core/common/Mapper.kt b/core/src/main/java/net/ivpn/core/common/Mapper.kt
index 6dc4715ad..903445a34 100644
--- a/core/src/main/java/net/ivpn/core/common/Mapper.kt
+++ b/core/src/main/java/net/ivpn/core/common/Mapper.kt
@@ -27,6 +27,7 @@ import com.google.gson.JsonSyntaxException
import com.google.gson.reflect.TypeToken
import net.ivpn.core.rest.data.ServersListResponse
import net.ivpn.core.rest.data.model.AntiTracker
+import net.ivpn.core.rest.data.model.Host
import net.ivpn.core.rest.data.model.Port
import net.ivpn.core.rest.data.model.Server
import net.ivpn.core.rest.data.session.SessionErrorResponse
@@ -35,6 +36,17 @@ import net.ivpn.core.vpn.model.V2RaySettings
import java.util.*
object Mapper {
+ fun hostFrom(json: String?): Host? {
+ return if (json == null || json.isEmpty()) null else try {
+ Gson().fromJson(json, Host::class.java)
+ } catch (_: JsonSyntaxException) {
+ null
+ }
+ }
+
+ fun stringFromHost(host: Host?): String {
+ return Gson().toJson(host)
+ }
fun from(json: String?): Server? {
return if (json == null) null else Gson().fromJson(json, Server::class.java)
}
diff --git a/core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt b/core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt
index 01e89e51a..f2390b911 100644
--- a/core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt
+++ b/core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt
@@ -3,6 +3,7 @@ package net.ivpn.core.common.prefs
import android.content.SharedPreferences
import net.ivpn.core.common.Mapper
import net.ivpn.core.common.dagger.ApplicationScope
+import net.ivpn.core.rest.data.model.Host
import net.ivpn.core.rest.data.model.Server
import net.ivpn.core.rest.data.model.ServerLocation
import net.ivpn.core.rest.data.model.ServerLocation.Companion.from
@@ -45,6 +46,8 @@ class ServersPreference @Inject constructor(
companion object {
private const val CURRENT_ENTER_SERVER = "CURRENT_ENTER_SERVER"
private const val CURRENT_EXIT_SERVER = "CURRENT_EXIT_SERVER"
+ private const val CURRENT_ENTER_HOST = "CURRENT_ENTER_HOST"
+ private const val CURRENT_EXIT_HOST = "CURRENT_EXIT_HOST"
private const val SERVERS_LIST = "SERVERS_LIST"
private const val LOCATION_LIST = "LOCATION_LIST"
private const val FAVOURITES_SERVERS_LIST = "FAVOURITES_SERVERS_LIST"
@@ -200,6 +203,34 @@ class ServersPreference @Inject constructor(
return Mapper.from(sharedPreferences.getString(serverKey, null))
}
+ fun setCurrentHost(serverType: ServerType?, host: Host?) {
+ if (serverType == null) return
+ val hostKey =
+ if (serverType == ServerType.ENTRY) CURRENT_ENTER_HOST else CURRENT_EXIT_HOST
+ preference.serversSharedPreferences.edit {
+ putString(hostKey, Mapper.stringFromHost(host))
+ }
+ preference.wireguardServersSharedPreferences.edit {
+ putString(hostKey, Mapper.stringFromHost(host))
+ }
+ }
+
+ fun getCurrentHost(serverType: ServerType?): Host? {
+ if (serverType == null) return null
+ val sharedPreferences = properSharedPreference
+ val hostKey =
+ if (serverType == ServerType.ENTRY) CURRENT_ENTER_HOST else CURRENT_EXIT_HOST
+ return Mapper.hostFrom(sharedPreferences.getString(hostKey, null))
+ }
+
+ fun clearCurrentHost(serverType: ServerType?) {
+ if (serverType == null) return
+ val hostKey =
+ if (serverType == ServerType.ENTRY) CURRENT_ENTER_HOST else CURRENT_EXIT_HOST
+ preference.serversSharedPreferences.edit { remove(hostKey) }
+ preference.wireguardServersSharedPreferences.edit { remove(hostKey) }
+ }
+
fun addFavouriteServer(server: Server?) {
val openvpnServer = openvpnServersList?.first { it == server }
val wireguardServer = wireguardServersList?.first { it == server }
diff --git a/core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt b/core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt
index 30444f781..ed5e70b52 100644
--- a/core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt
+++ b/core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt
@@ -30,6 +30,7 @@ import net.ivpn.core.rest.RequestListener
import net.ivpn.core.rest.data.ServersListResponse
import net.ivpn.core.rest.data.model.AntiTracker
import net.ivpn.core.rest.data.model.Config
+import net.ivpn.core.rest.data.model.Host
import net.ivpn.core.rest.data.model.Server
import net.ivpn.core.rest.data.model.ServerLocation
import net.ivpn.core.rest.data.model.ServerType
@@ -276,11 +277,31 @@ class ServersRepository @Inject constructor(
serversPreference.putSettingFastestServer(false)
serversPreference.putSettingRandomServer(false, type)
setCurrentServer(type, server)
+ // Clear host selection when a different server is selected
+ serversPreference.clearCurrentHost(type)
for (listener in onServerChangedListeners) {
listener.onServerChanged()
}
}
+ fun hostSelected(server: Server?, host: Host?, type: ServerType) {
+ serversPreference.putSettingFastestServer(false)
+ serversPreference.putSettingRandomServer(false, type)
+ setCurrentServer(type, server)
+ serversPreference.setCurrentHost(type, host)
+ for (listener in onServerChangedListeners) {
+ listener.onServerChanged()
+ }
+ }
+
+ fun getCurrentHost(serverType: ServerType): Host? {
+ return serversPreference.getCurrentHost(serverType)
+ }
+
+ fun clearCurrentHost(serverType: ServerType) {
+ serversPreference.clearCurrentHost(serverType)
+ }
+
private fun tryUpdateServerListOffline() {
LOGGER.info("Trying update server list offline from cache...")
if (getCachedServers() != null) {
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java b/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java
index d749ac867..ecc2abfeb 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java
@@ -22,6 +22,7 @@
along with the IVPN Android app. If not, see .
*/
+import net.ivpn.core.rest.data.model.Host;
import net.ivpn.core.rest.data.model.Server;
public interface AdapterListener {
@@ -37,4 +38,8 @@ public interface AdapterListener {
void onRandomServerSelected();
void changeFavouriteStateFor(Server server, boolean isFavourite);
+
+ void onHostSelected(Host host, Server parentServer, Server forbiddenServer);
+
+ void onServerExpandToggle(Server server);
}
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/OnServerExpandListener.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/OnServerExpandListener.kt
new file mode 100644
index 000000000..b5df0fb91
--- /dev/null
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/OnServerExpandListener.kt
@@ -0,0 +1,34 @@
+package net.ivpn.core.v2.serverlist
+
+/*
+ IVPN Android app
+ https://github.com/ivpn/android-app
+
+ Created by Tamim Hossain.
+ Copyright (c) 2025 IVPN Limited.
+
+ This file is part of the IVPN Android app.
+
+ The IVPN Android app is free software: you can redistribute it and/or
+ modify it under the terms of the GNU General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any later version.
+
+ The IVPN Android app is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+ details.
+
+ You should have received a copy of the GNU General Public License
+ along with the IVPN Android app. If not, see .
+*/
+
+import net.ivpn.core.rest.data.model.Server
+
+/**
+ * Listener for server expansion toggle events in the server list.
+ * Used to handle expanding/collapsing server items to show individual hosts.
+ */
+interface OnServerExpandListener {
+ fun onServerExpandToggle(server: Server)
+}
+
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt
index 277ef6d8a..ef51d9d6f 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt
@@ -35,17 +35,20 @@ import net.ivpn.core.common.distance.DistanceProvider
import net.ivpn.core.common.distance.OnDistanceChangedListener
import net.ivpn.core.common.pinger.PingResultFormatter
import net.ivpn.core.databinding.FastestServerItemBinding
+import net.ivpn.core.databinding.HostItemBinding
import net.ivpn.core.databinding.RandomServerItemBinding
import net.ivpn.core.databinding.SearchItemBinding
import net.ivpn.core.databinding.ServerItemBinding
import net.ivpn.core.rest.data.model.Server
import net.ivpn.core.v2.serverlist.AdapterListener
import net.ivpn.core.v2.serverlist.FavouriteServerListener
+import net.ivpn.core.v2.serverlist.OnServerExpandListener
import net.ivpn.core.v2.serverlist.ServerBasedRecyclerViewAdapter
import net.ivpn.core.v2.serverlist.dialog.Filters
import net.ivpn.core.v2.serverlist.holders.*
import net.ivpn.core.v2.serverlist.items.ConnectionOption
import net.ivpn.core.v2.serverlist.items.FastestServerItem
+import net.ivpn.core.v2.serverlist.items.HostItem
import net.ivpn.core.v2.serverlist.items.RandomServerItem
import net.ivpn.core.v2.serverlist.items.SearchServerItem
import org.slf4j.LoggerFactory
@@ -59,18 +62,22 @@ class AllServersRecyclerViewAdapter(
private val isFastestServerAllowed: Boolean,
private var filter: Filters?,
private var isIPv6Enabled: Boolean
-) : RecyclerView.Adapter(), ServerBasedRecyclerViewAdapter, FavouriteServerListener {
+) : RecyclerView.Adapter(), ServerBasedRecyclerViewAdapter, FavouriteServerListener, OnServerExpandListener {
@Inject
lateinit var distanceProvider: DistanceProvider
private var bindings = HashMap()
+ private var hostBindings = HashMap()
private var searchBinding: SearchItemBinding? = null
private var servers = arrayListOf()
private var filteredServers = arrayListOf()
private var displayServers = arrayListOf()
private var forbiddenServer: Server? = null
private var isFiltering = false
+
+ // Track expanded servers by their city (unique identifier)
+ private var expandedServerCities = mutableSetOf()
val distanceChangedListener = object : OnDistanceChangedListener {
override fun onDistanceChanged() {
@@ -89,6 +96,11 @@ class AllServersRecyclerViewAdapter(
private var pings: Map? = null
override fun getItemViewType(position: Int): Int {
+ val item = displayServers.getOrNull(position)
+ if (item is HostItem) {
+ return HOST_ITEM
+ }
+
if (isFiltering) {
return when (position) {
0 -> SEARCH_ITEM
@@ -125,6 +137,10 @@ class AllServersRecyclerViewAdapter(
val binding = FastestServerItemBinding.inflate(layoutInflater, parent, false)
FastestServerViewHolder(binding, navigator)
}
+ HOST_ITEM -> {
+ val binding = HostItemBinding.inflate(layoutInflater, parent, false)
+ HostViewHolder(binding, navigator)
+ }
else -> {
val binding = ServerItemBinding.inflate(layoutInflater, parent, false)
ServerViewHolder(binding, navigator)
@@ -145,11 +161,19 @@ class AllServersRecyclerViewAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ServerViewHolder) {
- val server: ConnectionOption = getServerFor(position)
- if (server is Server) {
- bindings[holder.binding] = server
- setPing(holder.binding, server)
- holder.bind(server, forbiddenServer, isIPv6Enabled, filter)
+ val item: ConnectionOption = getServerFor(position)
+ if (item is Server) {
+ bindings[holder.binding] = item
+ setPing(holder.binding, item)
+ val isExpanded = expandedServerCities.contains(item.city)
+ val showExpandButton = !isFiltering // Only show expand in non-search mode
+ holder.bind(item, forbiddenServer, isIPv6Enabled, filter, isExpanded, showExpandButton)
+ }
+ } else if (holder is HostViewHolder) {
+ val item: ConnectionOption = getServerFor(position)
+ if (item is HostItem) {
+ hostBindings[holder.binding] = item
+ holder.bind(item, forbiddenServer)
}
} else if (holder is SearchViewHolder) {
searchBinding = holder.binding
@@ -262,10 +286,33 @@ class AllServersRecyclerViewAdapter(
}
}
sortServers(servers)
- listToShow.addAll(servers)
+
+ // Add servers and their hosts if expanded
+ for (server in servers) {
+ listToShow.add(server)
+
+ // If this server is expanded, add its hosts
+ if (expandedServerCities.contains(server.city) && !isFiltering) {
+ server.hosts?.let { hosts ->
+ for (host in hosts) {
+ listToShow.add(HostItem(host, server))
+ }
+ }
+ }
+ }
return listToShow
}
+
+ override fun onServerExpandToggle(server: Server) {
+ val city = server.city
+ if (expandedServerCities.contains(city)) {
+ expandedServerCities.remove(city)
+ } else {
+ expandedServerCities.add(city)
+ }
+ applyFilter()
+ }
override fun setForbiddenServer(server: Server?) {
forbiddenServer = server
@@ -374,5 +421,6 @@ class AllServersRecyclerViewAdapter(
private const val SERVER_ITEM = 1
private const val SEARCH_ITEM = 2
private const val RANDOM_ITEM = 3
+ private const val HOST_ITEM = 4
}
}
\ No newline at end of file
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/all/ServerListFragment.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/all/ServerListFragment.kt
index f963d4874..372955044 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/all/ServerListFragment.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/all/ServerListFragment.kt
@@ -121,6 +121,7 @@ class ServerListFragment : Fragment(),
super.onDestroy()
if (this::adapter.isInitialized) {
viewmodel.favouriteListeners.remove(adapter)
+ viewmodel.expandListeners.remove(adapter)
}
filterViewModel.listeners.remove(this)
adapter.release()
@@ -141,6 +142,7 @@ class ServerListFragment : Fragment(),
binding.swipeRefreshLayout.setColorSchemeResources(R.color.colorPrimary,
R.color.colorAccent)
viewmodel.favouriteListeners.add(adapter)
+ viewmodel.expandListeners.add(adapter)
}
fun cancel() {
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt
index 66a293029..17e32ded5 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt
@@ -106,7 +106,8 @@ class FavouriteServerListRecyclerViewAdapter(
val server: Server = getServerFor(position)
bindings[holder.binding] = server
setPing(holder.binding, server)
- holder.bind(server, forbiddenServer, isIPv6BadgeEnabled, filter)
+ // For favourite servers, don't show expand button (hosts are shown in main list)
+ holder.bind(server, forbiddenServer, isIPv6BadgeEnabled, filter, false, false)
}
}
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt
new file mode 100644
index 000000000..8071b1bcf
--- /dev/null
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt
@@ -0,0 +1,58 @@
+package net.ivpn.core.v2.serverlist.holders
+
+/*
+ IVPN Android app
+ https://github.com/ivpn/android-app
+
+ Created by Tamim Hossain.
+ Copyright (c) 2025 IVPN Limited.
+
+ This file is part of the IVPN Android app.
+
+ The IVPN Android app is free software: you can redistribute it and/or
+ modify it under the terms of the GNU General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any later version.
+
+ The IVPN Android app is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+ details.
+
+ You should have received a copy of the GNU General Public License
+ along with the IVPN Android app. If not, see .
+*/
+
+import androidx.recyclerview.widget.RecyclerView
+import net.ivpn.core.R
+import net.ivpn.core.databinding.HostItemBinding
+import net.ivpn.core.rest.data.model.Server
+import net.ivpn.core.v2.serverlist.AdapterListener
+import net.ivpn.core.v2.serverlist.items.HostItem
+
+class HostViewHolder(
+ val binding: HostItemBinding,
+ val navigator: AdapterListener
+) : RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(hostItem: HostItem, forbiddenServer: Server?) {
+ binding.hostItem = hostItem
+ binding.navigator = navigator
+
+ // Set load indicator color based on load percentage
+ val load = hostItem.getLoad()
+ val loadIndicatorRes = when {
+ load < 50 -> R.drawable.ping_green_light
+ load < 80 -> R.drawable.ping_yellow_light
+ else -> R.drawable.ping_red_light
+ }
+ binding.loadIndicator.setImageResource(loadIndicatorRes)
+
+ // Handle click to select this specific host
+ binding.hostLayout.setOnClickListener {
+ navigator.onHostSelected(hostItem.host, hostItem.parentServer, forbiddenServer)
+ }
+
+ binding.executePendingBindings()
+ }
+}
+
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt
index 8fac62655..55ce843c5 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt
@@ -35,7 +35,8 @@ class ServerViewHolder(
val navigator: AdapterListener
) : RecyclerView.ViewHolder(binding.root) {
- fun bind(server: Server, forbiddenServer: Server?, isIPv6Enabled: Boolean, filter: Filters?) {
+ fun bind(server: Server, forbiddenServer: Server?, isIPv6Enabled: Boolean, filter: Filters?,
+ isExpanded: Boolean = false, showExpandButton: Boolean = false) {
binding.server = server
binding.forbiddenServer = forbiddenServer
binding.navigator = navigator
@@ -52,6 +53,19 @@ class ServerViewHolder(
}
binding.ipv6Badge.isVisible = server.isIPv6Enabled && isIPv6Enabled
binding.filter = filter
+
+ // Handle expand button visibility and state
+ val hasMultipleHosts = server.hosts != null && server.hosts.size > 1
+ binding.expandLayout.isVisible = showExpandButton && hasMultipleHosts
+ if (hasMultipleHosts) {
+ binding.expandIcon.setImageResource(
+ if (isExpanded) R.drawable.ic_expand_less else R.drawable.ic_expand_more
+ )
+ binding.expandLayout.setOnClickListener {
+ navigator.onServerExpandToggle(server)
+ }
+ }
+
binding.executePendingBindings()
}
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt
new file mode 100644
index 000000000..6d215e227
--- /dev/null
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt
@@ -0,0 +1,77 @@
+package net.ivpn.core.v2.serverlist.items
+
+/*
+ IVPN Android app
+ https://github.com/ivpn/android-app
+
+ Created by Tamim Hossain.
+ Copyright (c) 2025 IVPN Limited.
+
+ This file is part of the IVPN Android app.
+
+ The IVPN Android app is free software: you can redistribute it and/or
+ modify it under the terms of the GNU General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any later version.
+
+ The IVPN Android app is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+ details.
+
+ You should have received a copy of the GNU General Public License
+ along with the IVPN Android app. If not, see .
+*/
+
+import net.ivpn.core.rest.data.model.Host
+import net.ivpn.core.rest.data.model.Server
+
+/**
+ * Represents a host item in the server list, used for displaying individual
+ * hosts when a server is expanded. This allows users to select a specific
+ * host for consistent IP address connections.
+ */
+data class HostItem(
+ val host: Host,
+ val parentServer: Server
+) : ConnectionOption {
+
+ /**
+ * Returns the host name (e.g., "gb-lon-wg-001.relays.ivpn.net")
+ */
+ fun getHostName(): String {
+ return host.hostname ?: ""
+ }
+
+ /**
+ * Returns a shortened host name for display (e.g., "gb-lon-wg-001")
+ */
+ fun getShortHostName(): String {
+ val hostname = host.hostname ?: return ""
+ return hostname.substringBefore(".relays")
+ }
+
+ /**
+ * Returns the server load as a formatted percentage string
+ */
+ fun getLoadPercentage(): String {
+ return "${host.load.toInt()}%"
+ }
+
+ /**
+ * Returns the raw load value (0-100)
+ */
+ fun getLoad(): Double {
+ return host.load
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is HostItem) return false
+ return host.hostname == other.host.hostname
+ }
+
+ override fun hashCode(): Int {
+ return host.hostname?.hashCode() ?: 0
+ }
+}
+
diff --git a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt
index e9e058fa6..c4dec4b3b 100644
--- a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt
+++ b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt
@@ -33,10 +33,12 @@ import net.ivpn.core.common.prefs.OnServerListUpdatedListener
import net.ivpn.core.rest.data.model.ServerType
import net.ivpn.core.common.prefs.ServersRepository
import net.ivpn.core.common.prefs.Settings
+import net.ivpn.core.rest.data.model.Host
import net.ivpn.core.rest.data.model.Server
import net.ivpn.core.v2.dialog.Dialogs
import net.ivpn.core.v2.serverlist.AdapterListener
import net.ivpn.core.v2.serverlist.FavouriteServerListener
+import net.ivpn.core.v2.serverlist.OnServerExpandListener
import javax.inject.Inject
@ApplicationScope
@@ -57,6 +59,7 @@ class ServerListViewModel @Inject constructor(
val dataLoading = ObservableBoolean()
val navigators = arrayListOf()
val favouriteListeners = arrayListOf()
+ val expandListeners = arrayListOf()
val adapterListener = object : AdapterListener {
override fun onServerLongClick(server: Server) {
@@ -99,6 +102,25 @@ class ServerListViewModel @Inject constructor(
navigators[0].onServerSelected()
}
}
+
+ override fun onHostSelected(host: Host, parentServer: Server, forbiddenServer: Server?) {
+ if (parentServer.canBeUsedAsMultiHopWith(forbiddenServer)) {
+ setCurrentServerAndHost(parentServer, host)
+ if (navigators.isNotEmpty()) {
+ navigators[0].onServerSelected()
+ }
+ } else {
+ if (navigators.isNotEmpty()) {
+ navigators[0].showDialog(Dialogs.INCOMPATIBLE_SERVERS)
+ }
+ }
+ }
+
+ override fun onServerExpandToggle(server: Server) {
+ for (listener in expandListeners) {
+ listener.onServerExpandToggle(server)
+ }
+ }
}
private var listener: OnServerListUpdatedListener = object : OnServerListUpdatedListener {
@@ -137,6 +159,12 @@ class ServerListViewModel @Inject constructor(
}
}
+ fun setCurrentServerAndHost(server: Server?, host: Host?) {
+ serverType?.let {
+ serversRepository.hostSelected(server, host, it)
+ }
+ }
+
fun start(serverType: ServerType?) {
if (serverType == null) return
diff --git a/core/src/main/java/net/ivpn/core/vpn/wireguard/ConfigManager.kt b/core/src/main/java/net/ivpn/core/vpn/wireguard/ConfigManager.kt
index a1f5aad71..f85c7b969 100644
--- a/core/src/main/java/net/ivpn/core/vpn/wireguard/ConfigManager.kt
+++ b/core/src/main/java/net/ivpn/core/vpn/wireguard/ConfigManager.kt
@@ -150,7 +150,12 @@ class ConfigManager @Inject constructor(
return null
}
- val host = if (v2rayController.isV2RayEnabled()) {
+ // Check if a specific host was selected for consistent IP address
+ val selectedHost = serversRepository.getCurrentHost(ServerType.ENTRY)
+ val host = if (selectedHost != null && server.hosts.any { it.hostname == selectedHost.hostname }) {
+ LOGGER.info("Using user-selected specific host: ${selectedHost.hostname}")
+ selectedHost
+ } else if (v2rayController.isV2RayEnabled()) {
val candidates = server.hosts.filter { it.v2ray != null && it.v2ray.isNotEmpty() }
val selected = candidates.randomOrNull() ?: server.hosts.random()
if (candidates.isEmpty()) {
@@ -190,7 +195,14 @@ class ConfigManager @Inject constructor(
return null
}
- val entryHost = if (v2rayController.isV2RayEnabled()) {
+ // Check if specific hosts were selected
+ val selectedEntryHost = serversRepository.getCurrentHost(ServerType.ENTRY)
+ val selectedExitHost = serversRepository.getCurrentHost(ServerType.EXIT)
+
+ val entryHost = if (selectedEntryHost != null && entryServer.hosts.any { it.hostname == selectedEntryHost.hostname }) {
+ LOGGER.info("Using user-selected specific entry host: ${selectedEntryHost.hostname}")
+ selectedEntryHost
+ } else if (v2rayController.isV2RayEnabled()) {
val candidates = entryServer.hosts.filter { it.v2ray != null && it.v2ray.isNotEmpty() }
val selected = candidates.randomOrNull() ?: entryServer.hosts.random()
if (candidates.isEmpty()) {
@@ -200,7 +212,13 @@ class ConfigManager @Inject constructor(
} else {
entryServer.hosts.random()
}
- val exitHost = exitServer.hosts.random()
+
+ val exitHost = if (selectedExitHost != null && exitServer.hosts.any { it.hostname == selectedExitHost.hostname }) {
+ LOGGER.info("Using user-selected specific exit host: ${selectedExitHost.hostname}")
+ selectedExitHost
+ } else {
+ exitServer.hosts.random()
+ }
LOGGER.info("Multi-hop: Entry server: ${entryHost.hostname} (${entryHost.host})")
LOGGER.info("Multi-hop: Exit server: ${exitHost.hostname} (${exitHost.host})")
diff --git a/core/src/main/res/drawable/ic_expand_less.xml b/core/src/main/res/drawable/ic_expand_less.xml
new file mode 100644
index 000000000..42a6378dd
--- /dev/null
+++ b/core/src/main/res/drawable/ic_expand_less.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/core/src/main/res/drawable/ic_expand_more.xml b/core/src/main/res/drawable/ic_expand_more.xml
new file mode 100644
index 000000000..52d82e957
--- /dev/null
+++ b/core/src/main/res/drawable/ic_expand_more.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/core/src/main/res/layout/host_item.xml b/core/src/main/res/layout/host_item.xml
new file mode 100644
index 000000000..df24c8c6b
--- /dev/null
+++ b/core/src/main/res/layout/host_item.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/main/res/layout/server_item.xml b/core/src/main/res/layout/server_item.xml
index 8717dff53..c99a54589 100755
--- a/core/src/main/res/layout/server_item.xml
+++ b/core/src/main/res/layout/server_item.xml
@@ -105,6 +105,24 @@
+
+
+
+
+
+
Your favourite servers will be\n displayed here
Save your time by creating your own list of servers
+
+ Server load
+ Server host
+ Select a specific server to get the same IP address on every connection
+
Choose which servers can be used as the fastest.
At least one server should be selected.
From 3a1599aeaa8963c9278cbdc501d18a6dcbc3a016 Mon Sep 17 00:00:00 2001
From: Tamim Hossain <132823494+codewithtamim@users.noreply.github.com>
Date: Thu, 4 Dec 2025 19:01:28 +0600
Subject: [PATCH 2/3] feat: add select individual servers toggle in settings
---
.../prefs/EncryptedSettingsPreference.kt | 11 ++++
.../net/ivpn/core/common/prefs/Settings.kt | 6 ++
.../all/AllServersRecyclerViewAdapter.kt | 11 +++-
.../core/v2/viewmodel/ServersViewModel.kt | 17 ++++++
.../res/layout/settings_section_server.xml | 59 +++++++++++++++++++
core/src/main/res/values/strings.xml | 2 +
6 files changed, 103 insertions(+), 3 deletions(-)
diff --git a/core/src/main/java/net/ivpn/core/common/prefs/EncryptedSettingsPreference.kt b/core/src/main/java/net/ivpn/core/common/prefs/EncryptedSettingsPreference.kt
index 6ff545711..f431674f2 100644
--- a/core/src/main/java/net/ivpn/core/common/prefs/EncryptedSettingsPreference.kt
+++ b/core/src/main/java/net/ivpn/core/common/prefs/EncryptedSettingsPreference.kt
@@ -59,6 +59,7 @@ class EncryptedSettingsPreference @Inject constructor(val preference: Preference
private const val SETTINGS_BYPASS_LOCAL = "SETTINGS_BYPASS_LOCAL"
private const val SETTINGS_IPV6 = "SETTINGS_IPV6"
private const val IPV6_SHOW_ALL_SERVERS = "IPV6_SHOW_ALL_SERVERS"
+ private const val SETTINGS_SELECT_HOST = "SETTINGS_SELECT_HOST"
private const val OV_PORT = "OV_PORT"
private const val WG_PORT = "WG_PORT"
@@ -177,6 +178,10 @@ class EncryptedSettingsPreference @Inject constructor(val preference: Preference
return sharedPreferences.getBoolean(SETTINGS_MULTI_HOP, false)
}
+ fun getSettingSelectHost(): Boolean {
+ return sharedPreferences.getBoolean(SETTINGS_SELECT_HOST, false)
+ }
+
fun getSettingStartOnBoot(): Boolean {
return sharedPreferences.getBoolean(SETTINGS_START_ON_BOOT, false)
}
@@ -241,6 +246,12 @@ class EncryptedSettingsPreference @Inject constructor(val preference: Preference
}
}
+ fun putSettingSelectHost(value: Boolean) {
+ sharedPreferences.edit {
+ putBoolean(SETTINGS_SELECT_HOST, value)
+ }
+ }
+
fun putSettingCustomDNS(value: Boolean) {
sharedPreferences.edit {
putBoolean(SETTINGS_CUSTOM_DNS, value)
diff --git a/core/src/main/java/net/ivpn/core/common/prefs/Settings.kt b/core/src/main/java/net/ivpn/core/common/prefs/Settings.kt
index c60d27244..8444cce63 100644
--- a/core/src/main/java/net/ivpn/core/common/prefs/Settings.kt
+++ b/core/src/main/java/net/ivpn/core/common/prefs/Settings.kt
@@ -126,6 +126,12 @@ class Settings @Inject constructor(
settingsPreference.putSettingMultiHop(value)
}
+ var isSelectHostEnabled: Boolean
+ get() = settingsPreference.getSettingSelectHost()
+ set(value) {
+ settingsPreference.putSettingSelectHost(value)
+ }
+
var isMultiHopSameProviderAllowed: Boolean
get() = settingsPreference.isMultiHopSameProviderAllowed
set(value) {
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt
index ef51d9d6f..e16f10b1d 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt
@@ -34,6 +34,7 @@ import net.ivpn.core.R
import net.ivpn.core.common.distance.DistanceProvider
import net.ivpn.core.common.distance.OnDistanceChangedListener
import net.ivpn.core.common.pinger.PingResultFormatter
+import net.ivpn.core.common.prefs.Settings
import net.ivpn.core.databinding.FastestServerItemBinding
import net.ivpn.core.databinding.HostItemBinding
import net.ivpn.core.databinding.RandomServerItemBinding
@@ -67,6 +68,9 @@ class AllServersRecyclerViewAdapter(
@Inject
lateinit var distanceProvider: DistanceProvider
+ @Inject
+ lateinit var settings: Settings
+
private var bindings = HashMap()
private var hostBindings = HashMap()
private var searchBinding: SearchItemBinding? = null
@@ -166,7 +170,8 @@ class AllServersRecyclerViewAdapter(
bindings[holder.binding] = item
setPing(holder.binding, item)
val isExpanded = expandedServerCities.contains(item.city)
- val showExpandButton = !isFiltering // Only show expand in non-search mode
+ // Only show expand button when "Select individual servers" is enabled and not filtering
+ val showExpandButton = settings.isSelectHostEnabled && !isFiltering
holder.bind(item, forbiddenServer, isIPv6Enabled, filter, isExpanded, showExpandButton)
}
} else if (holder is HostViewHolder) {
@@ -291,8 +296,8 @@ class AllServersRecyclerViewAdapter(
for (server in servers) {
listToShow.add(server)
- // If this server is expanded, add its hosts
- if (expandedServerCities.contains(server.city) && !isFiltering) {
+ // If this server is expanded and "Select individual servers" is enabled, add its hosts
+ if (settings.isSelectHostEnabled && expandedServerCities.contains(server.city) && !isFiltering) {
server.hosts?.let { hosts ->
for (host in hosts) {
listToShow.add(HostItem(host, server))
diff --git a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServersViewModel.kt b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServersViewModel.kt
index 8326a79c7..c38226583 100644
--- a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServersViewModel.kt
+++ b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServersViewModel.kt
@@ -37,6 +37,7 @@ import net.ivpn.core.v2.connect.createSession.ConnectionState
import net.ivpn.core.vpn.controller.DefaultVPNStateListener
import net.ivpn.core.vpn.controller.VpnBehaviorController
import net.ivpn.core.vpn.controller.VpnStateListener
+import android.widget.CompoundButton
import javax.inject.Inject
@ApplicationScope
@@ -53,6 +54,12 @@ class ServersViewModel @Inject constructor(
val fastestServerSetting = ObservableBoolean()
val entryServerVisibility = ObservableBoolean()
val exitServerVisibility = ObservableBoolean()
+ val selectHostEnabled = ObservableBoolean()
+
+ val enableSelectHostListener =
+ CompoundButton.OnCheckedChangeListener { _: CompoundButton?, value: Boolean ->
+ enableSelectHost(value)
+ }
val entryServer = ObservableField()
val exitServer = ObservableField()
@@ -88,6 +95,7 @@ class ServersViewModel @Inject constructor(
fastestServerSetting.set(isFastestServerEnabled())
entryRandomServer.set(getSettingsRandomServer(ServerType.ENTRY))
exitRandomServer.set(getSettingsRandomServer(ServerType.EXIT))
+ selectHostEnabled.set(settings.isSelectHostEnabled)
entryServerVisibility.set(!fastestServerSetting.get() && !entryRandomServer.get())
exitServerVisibility.set(!exitRandomServer.get())
@@ -221,4 +229,13 @@ class ServersViewModel @Inject constructor(
return settings.ipv6Setting && settings.showAllServersSetting && it.isIPv6Enabled
} ?: return false
}
+
+ private fun enableSelectHost(value: Boolean) {
+ settings.isSelectHostEnabled = value
+ selectHostEnabled.set(value)
+ }
+
+ fun isSelectHostEnabled(): Boolean {
+ return settings.isSelectHostEnabled
+ }
}
\ No newline at end of file
diff --git a/core/src/main/res/layout/settings_section_server.xml b/core/src/main/res/layout/settings_section_server.xml
index 28b92db3b..f30a24796 100644
--- a/core/src/main/res/layout/settings_section_server.xml
+++ b/core/src/main/res/layout/settings_section_server.xml
@@ -100,6 +100,65 @@
android:background="@color/color_section_divider"
android:visibility="@{multihop.isSupported() ? View.VISIBLE : View.GONE}" />
+
+
+
+
+
+
+
+
+
+
+
+
Multi-hop connection
Multi-hop same provider restriction
Allow to use servers from the same provider to build Multi-hop chain.
+ Select individual servers
+ Connect to a specific server rather than a random server in the location
Kill switch
AntiTracker
Custom DNS
From e975e299f09bdcab9eac5527194523ef1d52576c Mon Sep 17 00:00:00 2001
From: Tamim Hossain <132823494+codewithtamim@users.noreply.github.com>
Date: Tue, 10 Feb 2026 23:57:35 +0600
Subject: [PATCH 3/3] New changes
New changes
---
.../RecyclerViewItemsBindingAdapter.java | 9 ++
.../core/common/prefs/ServersPreference.kt | 65 ++++++++++++
.../core/common/prefs/ServersRepository.kt | 34 ++++++-
.../net/ivpn/core/rest/data/model/Server.java | 48 +++++++++
.../core/v2/serverlist/AdapterListener.java | 2 +
.../all/AllServersRecyclerViewAdapter.kt | 9 +-
.../FavouriteServerListRecyclerViewAdapter.kt | 98 +++++++++++++------
.../v2/serverlist/holders/HostViewHolder.kt | 19 ++--
.../v2/serverlist/holders/ServerViewHolder.kt | 5 +-
.../ivpn/core/v2/serverlist/items/HostItem.kt | 16 ++-
.../core/v2/viewmodel/ServerListViewModel.kt | 24 ++++-
.../core/v2/viewmodel/ServersViewModel.kt | 47 +++++++++
.../fragment_favourite_servers_list.xml | 2 +-
core/src/main/res/layout/host_item.xml | 64 +++++++-----
core/src/main/res/layout/server_item.xml | 36 +++----
.../res/layout/settings_section_server.xml | 67 ++++++-------
.../main/res/layout/view_sliding_panel.xml | 4 +-
core/src/main/res/values/strings.xml | 2 +-
18 files changed, 421 insertions(+), 130 deletions(-)
diff --git a/core/src/main/java/net/ivpn/core/common/bindings/RecyclerViewItemsBindingAdapter.java b/core/src/main/java/net/ivpn/core/common/bindings/RecyclerViewItemsBindingAdapter.java
index ec3039485..36c41f680 100644
--- a/core/src/main/java/net/ivpn/core/common/bindings/RecyclerViewItemsBindingAdapter.java
+++ b/core/src/main/java/net/ivpn/core/common/bindings/RecyclerViewItemsBindingAdapter.java
@@ -37,6 +37,8 @@
import net.ivpn.core.v2.splittunneling.items.ApplicationItem;
import net.ivpn.core.v2.network.NetworkRecyclerViewAdapter;
import net.ivpn.core.v2.serverlist.ServerBasedRecyclerViewAdapter;
+import net.ivpn.core.v2.serverlist.favourite.FavouriteServerListRecyclerViewAdapter;
+import net.ivpn.core.v2.serverlist.items.ConnectionOption;
import net.ivpn.core.vpn.model.WifiItem;
import java.util.ArrayList;
@@ -57,6 +59,13 @@ public static void setItems(RecyclerView recyclerView, List items) {
}
}
+ @BindingAdapter("favouriteItems")
+ public static void setFavouriteItems(RecyclerView recyclerView, List items) {
+ if (recyclerView.getAdapter() instanceof FavouriteServerListRecyclerViewAdapter) {
+ ((FavouriteServerListRecyclerViewAdapter) recyclerView.getAdapter()).replaceDataConnectionOptions(items != null ? items : new ArrayList<>());
+ }
+ }
+
@BindingAdapter("pings")
public static void setPings(RecyclerView recyclerView, Map pings) {
ServerBasedRecyclerViewAdapter adapter = (ServerBasedRecyclerViewAdapter) recyclerView.getAdapter();
diff --git a/core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt b/core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt
index f2390b911..1f7168c09 100644
--- a/core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt
+++ b/core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt
@@ -56,6 +56,7 @@ class ServersPreference @Inject constructor(
private const val SETTINGS_RANDOM_ENTER_SERVER = "SETTINGS_RANDOM_ENTER_SERVER"
private const val SETTINGS_RANDOM_EXIT_SERVER = "SETTINGS_RANDOM_EXIT_SERVER"
private const val V2RAY_SETTINGS = "V2RAY_SETTINGS"
+ private const val FAVOURITES_HOSTS_LIST = "FAVOURITES_HOSTS_LIST"
}
var listeners = ArrayList()
@@ -267,6 +268,70 @@ class ServersPreference @Inject constructor(
.putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(wireguardServers)).apply()
}
+ private fun getFavouriteHostsKey(server: Server): String {
+ return "${server.city}_${server.countryCode}"
+ }
+
+ private fun getFavouriteHostsSet(server: Server): MutableSet {
+ val key = getFavouriteHostsKey(server)
+ val currentProtocol = protocolController.currentProtocol
+ val prefs = if (currentProtocol == Protocol.WIREGUARD) {
+ preference.wireguardServersSharedPreferences
+ } else {
+ preference.serversSharedPreferences
+ }
+ return prefs.getStringSet("${FAVOURITES_HOSTS_LIST}_$key", mutableSetOf())?.toMutableSet() ?: mutableSetOf()
+ }
+
+ private fun saveFavouriteHostsSet(server: Server, hosts: Set) {
+ val key = getFavouriteHostsKey(server)
+ val currentProtocol = protocolController.currentProtocol
+ val prefs = if (currentProtocol == Protocol.WIREGUARD) {
+ preference.wireguardServersSharedPreferences
+ } else {
+ preference.serversSharedPreferences
+ }
+ prefs.edit().putStringSet("${FAVOURITES_HOSTS_LIST}_$key", hosts).apply()
+ }
+
+ fun addFavouriteHost(host: Host, parentServer: Server) {
+ val hostname = host.hostname ?: return
+ val favouriteHosts = getFavouriteHostsSet(parentServer)
+ if (!favouriteHosts.contains(hostname)) {
+ favouriteHosts.add(hostname)
+ saveFavouriteHostsSet(parentServer, favouriteHosts)
+ }
+ }
+
+ fun removeFavouriteHost(host: Host, parentServer: Server) {
+ val hostname = host.hostname ?: return
+ val favouriteHosts = getFavouriteHostsSet(parentServer)
+ if (favouriteHosts.contains(hostname)) {
+ favouriteHosts.remove(hostname)
+ saveFavouriteHostsSet(parentServer, favouriteHosts)
+ }
+ }
+
+ fun isHostFavourite(host: Host, parentServer: Server): Boolean {
+ val hostname = host.hostname ?: return false
+ val favouriteHosts = getFavouriteHostsSet(parentServer)
+ return favouriteHosts.contains(hostname)
+ }
+
+
+ fun getFavouriteHosts(servers: List): List> {
+ val result = mutableListOf>()
+ for (server in servers) {
+ val hostnames = getFavouriteHostsSet(server)
+ server.hosts?.forEach { host ->
+ if (host.hostname != null && hostnames.contains(host.hostname)) {
+ result.add(Pair(host, server))
+ }
+ }
+ }
+ return result
+ }
+
fun addToExcludedServersList(server: Server?) {
val openvpnServer = openvpnServersList?.first { it == server }
val wireguardServer = wireguardServersList?.first { it == server }
diff --git a/core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt b/core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt
index ed5e70b52..acd08e621 100644
--- a/core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt
+++ b/core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt
@@ -24,6 +24,7 @@ along with the IVPN Android app. If not, see .
import net.ivpn.core.common.Mapper
import net.ivpn.core.common.dagger.ApplicationScope
+import net.ivpn.core.common.prefs.OnServerChangedListener
import net.ivpn.core.rest.HttpClientFactory
import net.ivpn.core.rest.IVPNApi
import net.ivpn.core.rest.RequestListener
@@ -35,6 +36,7 @@ import net.ivpn.core.rest.data.model.Server
import net.ivpn.core.rest.data.model.ServerLocation
import net.ivpn.core.rest.data.model.ServerType
import net.ivpn.core.rest.requests.common.Request
+import net.ivpn.core.v2.serverlist.items.HostItem
import net.ivpn.core.rest.requests.common.RequestWrapper
import net.ivpn.core.vpn.Protocol
import net.ivpn.core.vpn.ProtocolController
@@ -62,7 +64,7 @@ class ServersRepository @Inject constructor(
private val currentServers = EnumMap>(Protocol::class.java)
private var onFavouritesChangedListeners: MutableList = ArrayList()
private var onServerListUpdatedListeners: MutableList = ArrayList()
- private var onServerChangedListeners: List = ArrayList()
+ private var onServerChangedListeners: MutableList = ArrayList()
private var request: Request? = null
init {
@@ -180,6 +182,14 @@ class ServersRepository @Inject constructor(
return serversPreference.favouritesServersList
}
+
+ fun getFavouriteHosts(): List {
+ val servers = getServers(false) ?: return emptyList()
+ return serversPreference.getFavouriteHosts(servers).map { (host, server) ->
+ HostItem(host, server, isFavourite = true)
+ }
+ }
+
fun addFavouritesServer(server: Server) {
LOGGER.info("addFavouritesServer server = $server")
serversPreference.addFavouriteServer(server)
@@ -452,6 +462,14 @@ class ServersRepository @Inject constructor(
onServerListUpdatedListeners.remove(listener)
}
+ fun addOnServerChangedListener(listener: OnServerChangedListener) {
+ onServerChangedListeners.add(listener)
+ }
+
+ fun removeOnServerChangedListener(listener: OnServerChangedListener) {
+ onServerChangedListeners.remove(listener)
+ }
+
private val currentProtocolType: Protocol
get() = protocolController.currentProtocol
@@ -475,4 +493,18 @@ class ServersRepository @Inject constructor(
private fun notifyFavouriteServerRemoved(server: Server) {
onFavouritesChangedListeners.forEach { it.notifyFavouriteServerRemoved(server) }
}
+
+ fun addFavouriteHost(host: Host, parentServer: Server) {
+ LOGGER.info("addFavouriteHost host = ${host.hostname}, server = $parentServer")
+ serversPreference.addFavouriteHost(host, parentServer)
+ }
+
+ fun removeFavouriteHost(host: Host, parentServer: Server) {
+ LOGGER.info("removeFavouriteHost host = ${host.hostname}, server = $parentServer")
+ serversPreference.removeFavouriteHost(host, parentServer)
+ }
+
+ fun isHostFavourite(host: Host, parentServer: Server): Boolean {
+ return serversPreference.isHostFavourite(host, parentServer)
+ }
}
diff --git a/core/src/main/java/net/ivpn/core/rest/data/model/Server.java b/core/src/main/java/net/ivpn/core/rest/data/model/Server.java
index 680519f00..0b3c200d0 100644
--- a/core/src/main/java/net/ivpn/core/rest/data/model/Server.java
+++ b/core/src/main/java/net/ivpn/core/rest/data/model/Server.java
@@ -134,6 +134,54 @@ public String getDescription(Filters filter) {
return getDescription();
}
+ /**
+ * Returns description with host prefix if a host is provided.
+ * Format: "City (hostPrefix), CountryCode" e.g., "Vienna (at1), AT"
+ */
+ public String getDescriptionWithHostPrefix(Host host) {
+ if (host == null || host.getHostname() == null) {
+ return getDescription();
+ }
+
+ String hostPrefix = extractHostPrefix(host.getHostname());
+ if (hostPrefix.isEmpty()) {
+ return getDescription();
+ }
+
+ return city + " (" + hostPrefix + "), " + countryCode;
+ }
+
+ /**
+ * Extracts short host prefix from hostname for display (e.g. "at1", "at").
+ * Examples:
+ * "at1.wg.ivpn.net" -> "at1"
+ * "at-vie-wg-001.relays.ivpn.net" -> "at"
+ * "at1-vie-wg-001.relays.ivpn.net" -> "at1"
+ */
+ private String extractHostPrefix(String hostname) {
+ if (hostname == null || hostname.isEmpty()) {
+ return "";
+ }
+
+ String shortName = hostname;
+ int relaysIndex = hostname.indexOf(".relays");
+ if (relaysIndex > 0) {
+ shortName = hostname.substring(0, relaysIndex);
+ }
+
+ int dashIndex = shortName.indexOf('-');
+ if (dashIndex > 0) {
+ return shortName.substring(0, dashIndex);
+ }
+
+ int dotIndex = shortName.indexOf('.');
+ if (dotIndex > 0) {
+ return shortName.substring(0, dotIndex);
+ }
+
+ return shortName;
+ }
+
public List getHosts() {
return hosts;
}
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java b/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java
index ecc2abfeb..055f7d221 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java
@@ -42,4 +42,6 @@ public interface AdapterListener {
void onHostSelected(Host host, Server parentServer, Server forbiddenServer);
void onServerExpandToggle(Server server);
+
+ void changeFavouriteStateForHost(Host host, Server parentServer, boolean isFavourite);
}
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt
index e16f10b1d..ea429677b 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt
@@ -34,6 +34,7 @@ import net.ivpn.core.R
import net.ivpn.core.common.distance.DistanceProvider
import net.ivpn.core.common.distance.OnDistanceChangedListener
import net.ivpn.core.common.pinger.PingResultFormatter
+import net.ivpn.core.common.prefs.ServersRepository
import net.ivpn.core.common.prefs.Settings
import net.ivpn.core.databinding.FastestServerItemBinding
import net.ivpn.core.databinding.HostItemBinding
@@ -71,6 +72,9 @@ class AllServersRecyclerViewAdapter(
@Inject
lateinit var settings: Settings
+ @Inject
+ lateinit var serversRepository: ServersRepository
+
private var bindings = HashMap()
private var hostBindings = HashMap()
private var searchBinding: SearchItemBinding? = null
@@ -292,15 +296,14 @@ class AllServersRecyclerViewAdapter(
}
sortServers(servers)
- // Add servers and their hosts if expanded
for (server in servers) {
listToShow.add(server)
- // If this server is expanded and "Select individual servers" is enabled, add its hosts
if (settings.isSelectHostEnabled && expandedServerCities.contains(server.city) && !isFiltering) {
server.hosts?.let { hosts ->
for (host in hosts) {
- listToShow.add(HostItem(host, server))
+ val isFavourite = serversRepository.isHostFavourite(host, server)
+ listToShow.add(HostItem(host, server, isFavourite))
}
}
}
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt
index 17e32ded5..4804e7193 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt
@@ -35,6 +35,7 @@ import net.ivpn.core.common.distance.OnDistanceChangedListener
import net.ivpn.core.common.pinger.OnPingFinishListener
import net.ivpn.core.common.pinger.PingProvider
import net.ivpn.core.common.pinger.PingResultFormatter
+import net.ivpn.core.databinding.HostItemBinding
import net.ivpn.core.databinding.ServerItemBinding
import net.ivpn.core.rest.data.model.Server
import net.ivpn.core.v2.serverlist.AdapterListener
@@ -42,8 +43,10 @@ import net.ivpn.core.v2.serverlist.FavouriteServerListener
import net.ivpn.core.v2.serverlist.ServerBasedRecyclerViewAdapter
import net.ivpn.core.v2.serverlist.dialog.Filters
import net.ivpn.core.v2.serverlist.holders.HolderListener
+import net.ivpn.core.v2.serverlist.holders.HostViewHolder
import net.ivpn.core.v2.serverlist.holders.ServerViewHolder
import net.ivpn.core.v2.serverlist.items.ConnectionOption
+import net.ivpn.core.v2.serverlist.items.HostItem
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@@ -59,9 +62,10 @@ class FavouriteServerListRecyclerViewAdapter(
lateinit var distanceProvider: DistanceProvider
private var bindings = HashMap()
+ private var hostBindings = HashMap()
- private var rawServers = arrayListOf()
- private var serversToDisplay = arrayListOf()
+ private var rawItems = arrayListOf()
+ private var itemsToDisplay = arrayListOf()
private var forbiddenServer: Server? = null
val distanceChangedListener = object : OnDistanceChangedListener {
@@ -81,13 +85,24 @@ class FavouriteServerListRecyclerViewAdapter(
private var pings: Map? = null
override fun getItemViewType(position: Int): Int {
- return SERVER_ITEM
+ return when (itemsToDisplay.getOrNull(position)) {
+ is HostItem -> HOST_ITEM
+ else -> SERVER_ITEM
+ }
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
- val binding = ServerItemBinding.inflate(layoutInflater, parent, false)
- return ServerViewHolder(binding, navigator)
+ return when (viewType) {
+ HOST_ITEM -> {
+ val binding = HostItemBinding.inflate(layoutInflater, parent, false)
+ HostViewHolder(binding, navigator)
+ }
+ else -> {
+ val binding = ServerItemBinding.inflate(layoutInflater, parent, false)
+ ServerViewHolder(binding, navigator)
+ }
+ }
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList) {
@@ -102,12 +117,17 @@ class FavouriteServerListRecyclerViewAdapter(
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
- if (holder is ServerViewHolder) {
- val server: Server = getServerFor(position)
- bindings[holder.binding] = server
- setPing(holder.binding, server)
- // For favourite servers, don't show expand button (hosts are shown in main list)
- holder.bind(server, forbiddenServer, isIPv6BadgeEnabled, filter, false, false)
+ val item = getItemFor(position)
+ when {
+ holder is ServerViewHolder && item is Server -> {
+ bindings[holder.binding] = item
+ setPing(holder.binding, item)
+ holder.bind(item, forbiddenServer, isIPv6BadgeEnabled, filter, false, false)
+ }
+ holder is HostViewHolder && item is HostItem -> {
+ hostBindings[holder.binding] = item
+ holder.bind(item, forbiddenServer, isFavouritesEntry = true)
+ }
}
}
@@ -122,27 +142,38 @@ class FavouriteServerListRecyclerViewAdapter(
}
override fun getItemCount(): Int {
- return serversToDisplay.size
+ return itemsToDisplay.size
+ }
+
+ private fun getItemFor(position: Int): ConnectionOption {
+ return itemsToDisplay[position]
}
private fun removeFavouriteServer(server: Server) {
- if (!rawServers.contains(server)) {
- return
- }
- rawServers.remove(server)
+ if (!rawItems.contains(server)) return
+ rawItems.remove(server)
applyFilter()
}
private fun addFavouriteServer(server: Server) {
- if (rawServers.contains(server)) {
- return
- }
- rawServers.add(server)
+ if (rawItems.contains(server)) return
+ rawItems.add(server)
+ applyFilter()
+ }
+
+ /**
+ * Replaces the list with servers and/or host items (ConnectionOption).
+ * Used by the favouriteItems binding.
+ */
+ fun replaceDataConnectionOptions(items: List) {
+ rawItems = ArrayList(items)
+ setDistances()
+ setLatencies()
applyFilter()
}
private fun setServers(servers: ArrayList) {
- rawServers = servers
+ rawItems = ArrayList(servers)
setDistances()
setLatencies()
applyFilter()
@@ -177,7 +208,7 @@ class FavouriteServerListRecyclerViewAdapter(
}
override fun replaceData(items: List) {
- setServers(ArrayList(items))
+ replaceDataConnectionOptions(ArrayList(items))
}
override fun setFilter(filter: Filters?) {
@@ -205,8 +236,8 @@ class FavouriteServerListRecyclerViewAdapter(
}
private fun setLatencies() {
- pings?.let{pingsObj ->
- rawServers.forEach {
+ pings?.let { pingsObj ->
+ rawItems.filterIsInstance().forEach {
it.latency = pingsObj[it]?.ping ?: Long.MAX_VALUE
}
}
@@ -214,7 +245,7 @@ class FavouriteServerListRecyclerViewAdapter(
private fun setDistances() {
val distances = distanceProvider.distances
- rawServers.forEach {
+ rawItems.filterIsInstance().forEach {
it.distance = distances[it] ?: Float.MAX_VALUE
}
}
@@ -232,23 +263,28 @@ class FavouriteServerListRecyclerViewAdapter(
}, updateInterval)
} else {
isUpdating = true
- val oldList = serversToDisplay
- serversToDisplay = ArrayList(rawServers)
- sortServers(serversToDisplay)
- notifyChanges(oldList, serversToDisplay)
+ val oldList = itemsToDisplay
+ val serversOnly = ArrayList(rawItems.filterIsInstance())
+ sortServers(serversOnly)
+ itemsToDisplay = ArrayList(rawItems.size).apply {
+ addAll(serversOnly)
+ addAll(rawItems.filterIsInstance())
+ }
+ notifyChanges(oldList, itemsToDisplay)
}
}
private fun getPositionFor(server: Server): Int {
- return serversToDisplay.indexOf(server)
+ return itemsToDisplay.indexOf(server)
}
private fun getServerFor(position: Int): Server {
- return serversToDisplay[position]
+ return getItemFor(position) as Server
}
companion object {
private const val SERVER_ITEM = 1
+ private const val HOST_ITEM = 2
}
fun release() {
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt
index 8071b1bcf..2dd3e7606 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt
@@ -34,20 +34,19 @@ class HostViewHolder(
val navigator: AdapterListener
) : RecyclerView.ViewHolder(binding.root) {
- fun bind(hostItem: HostItem, forbiddenServer: Server?) {
+ fun bind(hostItem: HostItem, forbiddenServer: Server?, isFavouritesEntry: Boolean = false) {
binding.hostItem = hostItem
binding.navigator = navigator
-
- // Set load indicator color based on load percentage
- val load = hostItem.getLoad()
- val loadIndicatorRes = when {
- load < 50 -> R.drawable.ping_green_light
- load < 80 -> R.drawable.ping_yellow_light
- else -> R.drawable.ping_red_light
+ binding.isFavouritesEntry = isFavouritesEntry
+
+ binding.star.setImageResource(if (hostItem.isFavourite) R.drawable.ic_star_on else R.drawable.ic_star_off)
+
+ binding.starLayout.setOnClickListener {
+ hostItem.isFavourite = !hostItem.isFavourite
+ binding.star.setImageResource(if (hostItem.isFavourite) R.drawable.ic_star_on else R.drawable.ic_star_off)
+ navigator.changeFavouriteStateForHost(hostItem.host, hostItem.parentServer, hostItem.isFavourite)
}
- binding.loadIndicator.setImageResource(loadIndicatorRes)
- // Handle click to select this specific host
binding.hostLayout.setOnClickListener {
navigator.onHostSelected(hostItem.host, hostItem.parentServer, forbiddenServer)
}
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt
index 55ce843c5..b713cc998 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt
@@ -55,9 +55,8 @@ class ServerViewHolder(
binding.filter = filter
// Handle expand button visibility and state
- val hasMultipleHosts = server.hosts != null && server.hosts.size > 1
- binding.expandLayout.isVisible = showExpandButton && hasMultipleHosts
- if (hasMultipleHosts) {
+ binding.expandLayout.isVisible = showExpandButton
+ if (showExpandButton) {
binding.expandIcon.setImageResource(
if (isExpanded) R.drawable.ic_expand_less else R.drawable.ic_expand_more
)
diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt
index 6d215e227..1389ec794 100644
--- a/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt
+++ b/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt
@@ -32,7 +32,8 @@ import net.ivpn.core.rest.data.model.Server
*/
data class HostItem(
val host: Host,
- val parentServer: Server
+ val parentServer: Server,
+ var isFavourite: Boolean = false
) : ConnectionOption {
/**
@@ -64,6 +65,19 @@ data class HostItem(
return host.load
}
+ /**
+ * Returns whether to show the favorite star (only when server has more than 1 host)
+ */
+ val showFavoriteStar: Boolean
+ get() {
+ val hosts = parentServer.hosts
+ return hosts != null && hosts.size > 1
+ }
+
+
+ val displayTitleForFavourites: String
+ get() = parentServer.getDescriptionWithHostPrefix(host)
+
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is HostItem) return false
diff --git a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt
index c4dec4b3b..f714f25ac 100644
--- a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt
+++ b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt
@@ -38,6 +38,8 @@ import net.ivpn.core.rest.data.model.Server
import net.ivpn.core.v2.dialog.Dialogs
import net.ivpn.core.v2.serverlist.AdapterListener
import net.ivpn.core.v2.serverlist.FavouriteServerListener
+import net.ivpn.core.v2.serverlist.items.ConnectionOption
+import net.ivpn.core.v2.serverlist.items.HostItem
import net.ivpn.core.v2.serverlist.OnServerExpandListener
import javax.inject.Inject
@@ -52,7 +54,7 @@ class ServerListViewModel @Inject constructor(
val pings = pingProvider.pings
val all = ObservableArrayList()
- val favourites = ObservableArrayList()
+ val favourites = ObservableArrayList()
val forbiddenServer = ObservableField()
val dataRefreshing = ObservableBoolean()
@@ -121,6 +123,16 @@ class ServerListViewModel @Inject constructor(
listener.onServerExpandToggle(server)
}
}
+
+ override fun changeFavouriteStateForHost(host: Host, parentServer: Server, isFavourite: Boolean) {
+ if (isFavourite) {
+ serversRepository.addFavouriteHost(host, parentServer)
+ favourites.add(HostItem(host, parentServer, isFavourite = true))
+ } else {
+ serversRepository.removeFavouriteHost(host, parentServer)
+ favourites.removeAll { it is HostItem && it.host == host && it.parentServer == parentServer }
+ }
+ }
}
private var listener: OnServerListUpdatedListener = object : OnServerListUpdatedListener {
@@ -172,6 +184,7 @@ class ServerListViewModel @Inject constructor(
forbiddenServer.set(getForbiddenServer(serverType))
favourites.clear()
favourites.addAll(serversRepository.getFavouritesServers())
+ favourites.addAll(serversRepository.getFavouriteHosts())
if (isServersListExist()) {
getCachedServersList()?.let {
@@ -199,16 +212,20 @@ class ServerListViewModel @Inject constructor(
}
private fun applyFavourites() {
+ val favouriteServers = favourites.filterIsInstance()
+ val favouriteHostsByServer = favourites.filterIsInstance().groupBy { it.parentServer }
for (server in all) {
- server.isFavourite = favourites.contains(server)
+ server.isFavourite = favouriteServers.contains(server) ||
+ favouriteHostsByServer.containsKey(server)
}
- for (server in favourites) {
+ for (server in favouriteServers) {
server.isFavourite = true
}
}
private fun addToFavourites(server: Server) {
serversRepository.addFavouritesServer(server)
+ favourites.add(server)
for (listener in favouriteListeners) {
listener.onChangeState(server, true)
}
@@ -216,6 +233,7 @@ class ServerListViewModel @Inject constructor(
private fun removeFromFavourites(server: Server) {
serversRepository.removeFavouritesServer(server)
+ favourites.remove(server)
for (listener in favouriteListeners) {
listener.onChangeState(server, false)
}
diff --git a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServersViewModel.kt b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServersViewModel.kt
index c38226583..bc1fe440e 100644
--- a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServersViewModel.kt
+++ b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServersViewModel.kt
@@ -28,6 +28,7 @@ import androidx.lifecycle.ViewModel
import net.ivpn.core.common.dagger.ApplicationScope
import net.ivpn.core.common.multihop.MultiHopController
import net.ivpn.core.common.pinger.PingProvider
+import net.ivpn.core.common.prefs.OnServerChangedListener
import net.ivpn.core.common.prefs.ServersRepository
import net.ivpn.core.common.prefs.Settings
import net.ivpn.core.rest.data.model.Server
@@ -65,12 +66,20 @@ class ServersViewModel @Inject constructor(
val exitServer = ObservableField()
val mapServer = ObservableField()
val fastestServer = pingProvider.fastestServer
+
+ val entryServerDescription = ObservableField()
+ val exitServerDescription = ObservableField()
private var isBackgroundUpdateDone = false
init {
multiHopController.addListener(getOnMultihopValueChanges())
vpnBehaviorController.addVpnStateListener(getVPNStateListener())
+ serversRepository.addOnServerChangedListener(object : OnServerChangedListener {
+ override fun onServerChanged() {
+ updateServerDescriptions()
+ }
+ })
}
fun onResume() {
@@ -99,6 +108,13 @@ class ServersViewModel @Inject constructor(
entryServerVisibility.set(!fastestServerSetting.get() && !entryRandomServer.get())
exitServerVisibility.set(!exitRandomServer.get())
+
+ updateServerDescriptions()
+ }
+
+ private fun updateServerDescriptions() {
+ entryServerDescription.set(computeEntryServerDescription())
+ exitServerDescription.set(computeExitServerDescription())
}
private fun updateServersInBackground() {
@@ -139,6 +155,7 @@ class ServersViewModel @Inject constructor(
override fun notifyServerAsFastest(server: Server) {
entryServer.set(server)
mapServer.set(if (multiHopController.isEnabled) exitServer.get() else entryServer.get())
+ updateServerDescriptions()
}
override fun notifyServerAsRandom(server: Server, serverType: ServerType) {
@@ -147,6 +164,7 @@ class ServersViewModel @Inject constructor(
ServerType.EXIT -> exitServer.set(server)
}
mapServer.set(if (multiHopController.isEnabled) exitServer.get() else entryServer.get())
+ updateServerDescriptions()
}
}
}
@@ -186,6 +204,7 @@ class ServersViewModel @Inject constructor(
serversRepository.serverSelected(serverToConnect, ServerType.ENTRY)
}
mapServer.set(if (multiHopController.isEnabled) exitServer.get() else entryServer.get())
+ updateServerDescriptions()
}
private fun getServerFor(serverLocation: ServerLocation): Server? {
@@ -238,4 +257,32 @@ class ServersViewModel @Inject constructor(
fun isSelectHostEnabled(): Boolean {
return settings.isSelectHostEnabled
}
+
+ /**
+ * Computes entry server description with host prefix if a host is selected.
+ * (Named to avoid conflicting with ObservableField getter in data binding.)
+ */
+ private fun computeEntryServerDescription(): String {
+ val server = entryServer.get() ?: return ""
+ val host = serversRepository.getCurrentHost(ServerType.ENTRY)
+ return if (host != null) {
+ server.getDescriptionWithHostPrefix(host)
+ } else {
+ server.getDescription()
+ }
+ }
+
+ /**
+ * Computes exit server description with host prefix if a host is selected.
+ * (Named to avoid conflicting with ObservableField getter in data binding.)
+ */
+ private fun computeExitServerDescription(): String {
+ val server = exitServer.get() ?: return ""
+ val host = serversRepository.getCurrentHost(ServerType.EXIT)
+ return if (host != null) {
+ server.getDescriptionWithHostPrefix(host)
+ } else {
+ server.getDescription()
+ }
+ }
}
\ No newline at end of file
diff --git a/core/src/main/res/layout/fragment_favourite_servers_list.xml b/core/src/main/res/layout/fragment_favourite_servers_list.xml
index 0080f74ec..37b9a3fb6 100644
--- a/core/src/main/res/layout/fragment_favourite_servers_list.xml
+++ b/core/src/main/res/layout/fragment_favourite_servers_list.xml
@@ -21,7 +21,7 @@
android:layout_height="match_parent"
android:layout_marginTop="8dp"
app:forbiddenItem="@{viewmodel.forbiddenServer}"
- app:items="@{viewmodel.favourites}" />
+ app:favouriteItems="@{viewmodel.favourites}" />
+
+
+ android:orientation="horizontal">
+
+
-
+ android:layout_marginEnd="8dp"
+ android:fontFamily="sans-serif"
+ android:letterSpacing="-0.03"
+ android:text="@{hostItem.loadPercentage}"
+ android:textColor="@color/servers_text_color"
+ android:textSize="13sp"
+ android:textStyle="normal"
+ android:alpha="0.6" />
+
+
+ android:id="@+id/star"
+ android:layout_width="16dp"
+ android:layout_height="16dp"
+ android:layout_gravity="center"
+ android:src="@{hostItem.isFavourite ? @drawable/ic_star_on : @drawable/ic_star_off}" />
-
-
+
+ android:layout_height="match_parent"
+ android:visibility="@{hostItem.showFavoriteStar ? View.GONE : View.VISIBLE}" />
diff --git a/core/src/main/res/layout/server_item.xml b/core/src/main/res/layout/server_item.xml
index c99a54589..079f85092 100755
--- a/core/src/main/res/layout/server_item.xml
+++ b/core/src/main/res/layout/server_item.xml
@@ -63,6 +63,24 @@
android:textSize="15sp"
android:textStyle="normal" />
+
+
+
+
+
+
-
-
-
-
-
-
+
+ android:layout_height="wrap_content">
+ app:layout_constraintTop_toBottomOf="@+id/select_host_title" />
@@ -100,26 +100,24 @@
android:background="@color/color_section_divider"
android:visibility="@{multihop.isSupported() ? View.VISIBLE : View.GONE}" />
-
+ android:layout_height="wrap_content"
+ android:visibility="@{multihop.isSupported() ? View.VISIBLE : View.GONE}">
+ app:layout_constraintTop_toBottomOf="@+id/textView10" />
@@ -157,7 +157,8 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="16dp"
- android:background="@color/color_section_divider" />
+ android:background="@color/color_section_divider"
+ android:visibility="@{multihop.isSupported() ? View.VISIBLE : View.GONE}" />
+ android:text="@{servers.entryServerDescription}" />
+ android:text="@{servers.exitServerDescription}" />
@@ -503,7 +503,7 @@
style="@style/AppTheme.ServerNameText"
android:layout_marginStart="8dp"
android:layout_marginBottom="2dp"
- android:text="@{servers.exitServer.description}"
+ android:text="@{servers.exitServerDescription}"
app:layout_constraintBottom_toBottomOf="@+id/exit_server_flag"
app:layout_constraintStart_toEndOf="@+id/exit_server_flag"
app:layout_constraintTop_toTopOf="@+id/exit_server_flag" />
diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml
index b2adeaf1f..ab0ad9e61 100644
--- a/core/src/main/res/values/strings.xml
+++ b/core/src/main/res/values/strings.xml
@@ -47,7 +47,7 @@
Multi-hop same provider restriction
Allow to use servers from the same provider to build Multi-hop chain.
Select individual servers
- Connect to a specific server rather than a random server in the location
+ Enable selection of individual servers in server selection list
Kill switch
AntiTracker
Custom DNS