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/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/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/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/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..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,18 +34,22 @@ 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
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 +63,25 @@ 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
+ @Inject
+ lateinit var settings: Settings
+
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 +100,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 +141,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 +165,20 @@ 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)
+ // 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) {
+ 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 +291,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 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))
+ }
+ }
+ }
+ }
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 +426,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/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/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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
@@ -249,6 +251,11 @@
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.