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.