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/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/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..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 @@ -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" @@ -53,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() @@ -200,6 +204,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 } @@ -236,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 30444f781..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,16 +24,19 @@ 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 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 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 @@ -61,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 { @@ -179,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) @@ -276,11 +287,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) { @@ -431,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 @@ -454,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/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/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 d749ac867..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 @@ -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,10 @@ public interface AdapterListener { void onRandomServerSelected(); void changeFavouriteStateFor(Server server, boolean isFavourite); + + 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/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..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,18 +34,23 @@ 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 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 +64,28 @@ 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 + + @Inject + lateinit var serversRepository: ServersRepository + 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 +104,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 +145,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 +169,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 +295,32 @@ class AllServersRecyclerViewAdapter( } } sortServers(servers) - listToShow.addAll(servers) + + for (server in servers) { + listToShow.add(server) + + if (settings.isSelectHostEnabled && expandedServerCities.contains(server.city) && !isFiltering) { + server.hosts?.let { hosts -> + for (host in hosts) { + val isFavourite = serversRepository.isHostFavourite(host, server) + listToShow.add(HostItem(host, server, isFavourite)) + } + } + } + } 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 +429,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..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,11 +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) - holder.bind(server, forbiddenServer, isIPv6BadgeEnabled, filter) + 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) + } } } @@ -121,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() @@ -176,7 +208,7 @@ class FavouriteServerListRecyclerViewAdapter( } override fun replaceData(items: List) { - setServers(ArrayList(items)) + replaceDataConnectionOptions(ArrayList(items)) } override fun setFilter(filter: Filters?) { @@ -204,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 } } @@ -213,7 +245,7 @@ class FavouriteServerListRecyclerViewAdapter( private fun setDistances() { val distances = distanceProvider.distances - rawServers.forEach { + rawItems.filterIsInstance().forEach { it.distance = distances[it] ?: Float.MAX_VALUE } } @@ -231,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 new file mode 100644 index 000000000..2dd3e7606 --- /dev/null +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt @@ -0,0 +1,57 @@ +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?, isFavouritesEntry: Boolean = false) { + binding.hostItem = hostItem + binding.navigator = navigator + 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.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..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 @@ -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,18 @@ class ServerViewHolder( } binding.ipv6Badge.isVisible = server.isIPv6Enabled && isIPv6Enabled binding.filter = filter + + // Handle expand button visibility and state + binding.expandLayout.isVisible = showExpandButton + if (showExpandButton) { + 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..1389ec794 --- /dev/null +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt @@ -0,0 +1,91 @@ +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, + var isFavourite: Boolean = false +) : 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 + } + + /** + * 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 + 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..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 @@ -33,10 +33,14 @@ 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.items.ConnectionOption +import net.ivpn.core.v2.serverlist.items.HostItem +import net.ivpn.core.v2.serverlist.OnServerExpandListener import javax.inject.Inject @ApplicationScope @@ -50,13 +54,14 @@ class ServerListViewModel @Inject constructor( val pings = pingProvider.pings val all = ObservableArrayList() - val favourites = ObservableArrayList() + val favourites = ObservableArrayList() val forbiddenServer = ObservableField() val dataRefreshing = ObservableBoolean() val dataLoading = ObservableBoolean() val navigators = arrayListOf() val favouriteListeners = arrayListOf() + val expandListeners = arrayListOf() val adapterListener = object : AdapterListener { override fun onServerLongClick(server: Server) { @@ -99,6 +104,35 @@ 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) + } + } + + 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 { @@ -137,6 +171,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 @@ -144,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 { @@ -171,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) } @@ -188,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 8326a79c7..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 @@ -37,6 +38,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,17 +55,31 @@ 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() 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() { @@ -88,9 +104,17 @@ 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()) + + updateServerDescriptions() + } + + private fun updateServerDescriptions() { + entryServerDescription.set(computeEntryServerDescription()) + exitServerDescription.set(computeExitServerDescription()) } private fun updateServersInBackground() { @@ -131,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) { @@ -139,6 +164,7 @@ class ServersViewModel @Inject constructor( ServerType.EXIT -> exitServer.set(server) } mapServer.set(if (multiHopController.isEnabled) exitServer.get() else entryServer.get()) + updateServerDescriptions() } } } @@ -178,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? { @@ -221,4 +248,41 @@ 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 + } + + /** + * 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/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/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}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/res/layout/server_item.xml b/core/src/main/res/layout/server_item.xml index 8717dff53..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: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 afa6acbb1..ab0ad9e61 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -46,6 +46,8 @@ Multi-hop connection Multi-hop same provider restriction Allow to use servers from the same provider to build Multi-hop chain. + Select individual servers + Enable selection of individual servers in server selection list 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.