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..dc69bff64 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,8 @@ 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.FavoriteIdentifier +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 +37,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) } @@ -132,4 +145,20 @@ object Mapper { null } } + + fun favoriteIdentifierListFrom(json: String?): MutableList? { + if (json == null) return null + return try { + val type = object : TypeToken>() {}.type + Gson().fromJson(json, type) + } catch (_: JsonSyntaxException) { + null + } catch (_: IllegalStateException) { + null + } + } + + fun stringFromFavoriteIdentifiers(identifiers: List?): String { + return Gson().toJson(identifiers) + } } \ No newline at end of file diff --git a/core/src/main/java/net/ivpn/core/common/migration/MigrationController.kt b/core/src/main/java/net/ivpn/core/common/migration/MigrationController.kt index 28131d049..68da27a8d 100644 --- a/core/src/main/java/net/ivpn/core/common/migration/MigrationController.kt +++ b/core/src/main/java/net/ivpn/core/common/migration/MigrationController.kt @@ -72,6 +72,7 @@ class MigrationController @Inject constructor( return when (version) { 2 -> UF1T2(userPreference, protocolController) 3 -> UF2T3(repository, serversPreference) + 4 -> UF3T4(serversPreference) else -> null } } diff --git a/core/src/main/java/net/ivpn/core/common/migration/UF3T4.kt b/core/src/main/java/net/ivpn/core/common/migration/UF3T4.kt new file mode 100644 index 000000000..85045416f --- /dev/null +++ b/core/src/main/java/net/ivpn/core/common/migration/UF3T4.kt @@ -0,0 +1,48 @@ +package net.ivpn.core.common.migration + +/* + 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.common.prefs.ServersPreference +import org.slf4j.LoggerFactory + +/** + * Migration from version 3 to 4. + * Migrates per-protocol favorites (OpenVPN and WireGuard) to unified favorites storage. + * The unified favorites use protocol-agnostic identifiers (gateway prefix for locations, + * dns_name for hosts) which allows favorites to be shared across all VPN protocols. + */ +class UF3T4( + private val serversPreference: ServersPreference +) : Update { + + companion object { + private val LOGGER = LoggerFactory.getLogger(UF3T4::class.java) + } + + override fun update() { + LOGGER.info("Migrating favorites to unified storage") + serversPreference.migrateOldFavouritesToUnified() + LOGGER.info("Favorites migration completed") + } +} + diff --git a/core/src/main/java/net/ivpn/core/common/prefs/Preference.kt b/core/src/main/java/net/ivpn/core/common/prefs/Preference.kt index cd1c582e5..1d5392bc6 100644 --- a/core/src/main/java/net/ivpn/core/common/prefs/Preference.kt +++ b/core/src/main/java/net/ivpn/core/common/prefs/Preference.kt @@ -35,7 +35,7 @@ import javax.inject.Inject class Preference @Inject constructor() { companion object { - const val LAST_LOGIC_VERSION = 3 + const val LAST_LOGIC_VERSION = 4 private const val CURRENT_LOGIC_VERSION = "CURRENT_LOGIC_VERSION" private const val COMMON_PREF = "COMMON_PREF" private const val TRUSTED_WIFI_PREF = "TRUSTED_WIFI_PREF" 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..757513fa8 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,8 @@ 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.FavoriteIdentifier +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,9 +47,12 @@ 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" + private const val UNIFIED_FAVOURITES_LIST = "UNIFIED_FAVOURITES_LIST" private const val EXCLUDED_FASTEST_SERVERS = "EXCLUDED_FASTEST_SERVERS" private const val SETTINGS_FASTEST_SERVER = "SETTINGS_FASTEST_SERVER" private const val SETTINGS_RANDOM_ENTER_SERVER = "SETTINGS_RANDOM_ENTER_SERVER" @@ -91,28 +96,75 @@ class ServersPreference @Inject constructor( return Mapper.serverListFrom(sharedPreferences.getString(SERVERS_LIST, null)) } + /** + * Returns the list of favorite servers for the current protocol. + * This uses the unified favorites system where favorites are stored as + * protocol-agnostic identifiers (gateway prefix for locations, dns_name for hosts). + * When retrieving, it matches these identifiers against the current protocol's servers. + */ val favouritesServersList: MutableList get() { - val sharedPreferences = properSharedPreference - val servers = - Mapper.serverListFrom(sharedPreferences.getString(FAVOURITES_SERVERS_LIST, null)) - return servers ?: ArrayList() + val identifiers = unifiedFavouritesList + val currentServers = serversList ?: return ArrayList() + + val favourites = ArrayList() + for (server in currentServers) { + for (identifier in identifiers) { + if (identifier.matches(server)) { + favourites.add(server) + break + } + } + } + return favourites + } + + /** + * Returns the unified list of favorite identifiers. + * These identifiers are protocol-agnostic and work across OpenVPN and WireGuard. + */ + val unifiedFavouritesList: MutableList + get() { + // First try to get from unified storage + val sharedPreferences = preference.stickySharedPreferences + val identifiers = Mapper.favoriteIdentifierListFrom( + sharedPreferences.getString(UNIFIED_FAVOURITES_LIST, null) + ) + return identifiers ?: ArrayList() } val openvpnFavouritesServersList: MutableList get() { - val sharedPreferences = preference.serversSharedPreferences - val servers = - Mapper.serverListFrom(sharedPreferences.getString(FAVOURITES_SERVERS_LIST, null)) - return servers ?: ArrayList() + val identifiers = unifiedFavouritesList + val currentServers = openvpnServersList ?: return ArrayList() + + val favourites = ArrayList() + for (server in currentServers) { + for (identifier in identifiers) { + if (identifier.matches(server)) { + favourites.add(server) + break + } + } + } + return favourites } val wireguardFavouritesServersList: MutableList get() { - val sharedPreferences = preference.wireguardServersSharedPreferences - val servers = - Mapper.serverListFrom(sharedPreferences.getString(FAVOURITES_SERVERS_LIST, null)) - return servers ?: ArrayList() + val identifiers = unifiedFavouritesList + val currentServers = wireguardServersList ?: return ArrayList() + + val favourites = ArrayList() + for (server in currentServers) { + for (identifier in identifiers) { + if (identifier.matches(server)) { + favourites.add(server) + break + } + } + } + return favourites } val excludedServersList: MutableList @@ -200,40 +252,138 @@ 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) } + } + + /** + * Adds a server to the unified favorites list. + * The server is stored as a protocol-agnostic identifier: + * - For locations: normalized gateway (with .wg. replaced by .gw.) + * - For hosts: dns_name + */ fun addFavouriteServer(server: Server?) { - val openvpnServer = openvpnServersList?.first { it == server } - val wireguardServer = wireguardServersList?.first { it == server } - if (server == null || openvpnServer == null || wireguardServer == null) { + if (server == null) return + + val identifier = FavoriteIdentifier.fromServer(server) + val identifiers = unifiedFavouritesList + + // Check if already in favorites + if (identifiers.any { it == identifier }) { return } - val openvpnServers = openvpnFavouritesServersList - val wireguardServers = wireguardFavouritesServersList - if (!openvpnServers.contains(openvpnServer)) { - openvpnServers.add(openvpnServer) - preference.serversSharedPreferences.edit() - .putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(openvpnServers)).apply() + + identifiers.add(identifier) + saveUnifiedFavourites(identifiers) + } + + /** + * Removes a server from the unified favorites list. + */ + fun removeFavouriteServer(server: Server) { + val identifier = FavoriteIdentifier.fromServer(server) + val identifiers = unifiedFavouritesList + + identifiers.removeAll { it == identifier } + saveUnifiedFavourites(identifiers) + } + + /** + * Adds a specific host to favorites by dns_name. + */ + fun addFavouriteHost(dnsName: String) { + val identifier = FavoriteIdentifier.forHost(dnsName) + val identifiers = unifiedFavouritesList + + if (identifiers.any { it == identifier }) { + return } - if (!wireguardServers.contains(wireguardServer)) { - wireguardServers.add(wireguardServer) - preference.wireguardServersSharedPreferences.edit() - .putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(wireguardServers)).apply() + + identifiers.add(identifier) + saveUnifiedFavourites(identifiers) + } + + /** + * Removes a specific host from favorites by dns_name. + */ + fun removeFavouriteHost(dnsName: String) { + val identifier = FavoriteIdentifier.forHost(dnsName) + val identifiers = unifiedFavouritesList + + identifiers.removeAll { it == identifier } + saveUnifiedFavourites(identifiers) + } + + /** + * Checks if a server is in the favorites list. + */ + fun isFavourite(server: Server): Boolean { + val identifier = FavoriteIdentifier.fromServer(server) + return unifiedFavouritesList.any { it == identifier } + } + + /** + * Saves the unified favorites list. + */ + private fun saveUnifiedFavourites(identifiers: List) { + preference.stickySharedPreferences.edit { + putString(UNIFIED_FAVOURITES_LIST, Mapper.stringFromFavoriteIdentifiers(identifiers)) } } - fun removeFavouriteServer(server: Server) { - val openvpnServer = openvpnServersList?.first { it == server } - val wireguardServer = wireguardServersList?.first { it == server } - if (openvpnServer == null || wireguardServer == null) { + /** + * Migrates old per-protocol favorites to the new unified format. + * This should be called once during app upgrade. + */ + fun migrateOldFavouritesToUnified() { + // Check if already migrated + if (preference.stickySharedPreferences.contains(UNIFIED_FAVOURITES_LIST)) { return } - val openvpnServers = openvpnFavouritesServersList - val wireguardServers = wireguardFavouritesServersList - openvpnServers.remove(openvpnServer) - wireguardServers.remove(wireguardServer) - preference.serversSharedPreferences.edit() - .putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(openvpnServers)).apply() - preference.wireguardServersSharedPreferences.edit() - .putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(wireguardServers)).apply() + + val identifiers = mutableSetOf() + + val oldOpenvpnFavourites = Mapper.serverListFrom( + preference.serversSharedPreferences.getString(FAVOURITES_SERVERS_LIST, null) + ) + oldOpenvpnFavourites?.forEach { server -> + identifiers.add(FavoriteIdentifier.fromServer(server)) + } + + val oldWireguardFavourites = Mapper.serverListFrom( + preference.wireguardServersSharedPreferences.getString(FAVOURITES_SERVERS_LIST, null) + ) + oldWireguardFavourites?.forEach { server -> + identifiers.add(FavoriteIdentifier.fromServer(server)) + } + + if (identifiers.isNotEmpty()) { + saveUnifiedFavourites(identifiers.toList()) + } } fun addToExcludedServersList(server: 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/rest/data/model/FavoriteIdentifier.kt b/core/src/main/java/net/ivpn/core/rest/data/model/FavoriteIdentifier.kt new file mode 100644 index 000000000..547beb2cc --- /dev/null +++ b/core/src/main/java/net/ivpn/core/rest/data/model/FavoriteIdentifier.kt @@ -0,0 +1,138 @@ +package net.ivpn.core.rest.data.model + +/* + 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 com.google.gson.annotations.Expose +import com.google.gson.annotations.SerializedName + +/** + * Represents a protocol-agnostic favorite identifier. + * This allows favorites to be shared across VPN protocols (OpenVPN and WireGuard). + * + * - For locations: stores normalized gateway (with .wg. replaced by .gw.) + * - For specific hosts: stores the dns_name + */ +data class FavoriteIdentifier( + /** + * The normalized gateway for location-based favorites. + * Example: "gb.gw.ivpn.net" (normalized from either "gb.wg.ivpn.net" or "gb.gw.ivpn.net") + * This is stored with .wg. replaced by .gw. to be protocol-agnostic. + */ + @SerializedName("gateway") + @Expose + val gateway: String? = null, + + /** + * The dns_name for host-based favorites. + * This is used when a specific host is favorited. + * Example: "us-ca1.dns.ivpn.net" + */ + @SerializedName("dns_name") + @Expose + val dnsName: String? = null, + + /** + * Flag to indicate if this is a host favorite. + * Mirrors iOS implementation where hosts have country == "" && gateway != "" + */ + @SerializedName("is_host") + @Expose + val isHost: Boolean = false +) { + /** + * Checks if this identifier represents a host favorite (specific server). + */ + val isHostFavorite: Boolean + get() = isHost && !dnsName.isNullOrEmpty() + + /** + * Checks if this identifier represents a location favorite. + */ + val isLocationFavorite: Boolean + get() = !isHost && !gateway.isNullOrEmpty() + + /** + * Checks if this identifier matches the given server. + * + * For location favorites: matches if normalized gateway matches + * For host favorites: matches if dns_name matches + */ + fun matches(server: Server): Boolean { + return when { + isHostFavorite -> server.hasHostWithDnsName(dnsName) + isLocationFavorite -> server.getNormalizedGateway().equals(gateway, ignoreCase = true) + else -> false + } + } + + companion object { + /** + * Creates a FavoriteIdentifier for a location (server with all hosts). + * Stores the normalized gateway (with .wg. replaced by .gw.) + */ + fun forLocation(server: Server): FavoriteIdentifier { + return FavoriteIdentifier( + gateway = server.getNormalizedGateway(), + isHost = false + ) + } + + /** + * Creates a FavoriteIdentifier for a specific host by dns_name. + */ + fun forHost(dnsName: String): FavoriteIdentifier { + return FavoriteIdentifier( + dnsName = dnsName, + isHost = true + ) + } + + /** + * Creates a FavoriteIdentifier from a server. + * The server model in Android represents locations, not individual hosts. + */ + fun fromServer(server: Server): FavoriteIdentifier { + return forLocation(server) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is FavoriteIdentifier) return false + + return when { + isHostFavorite && other.isHostFavorite -> dnsName.equals(other.dnsName, ignoreCase = true) + isLocationFavorite && other.isLocationFavorite -> + gateway.equals(other.gateway, ignoreCase = true) + else -> false + } + } + + override fun hashCode(): Int { + return when { + isHostFavorite -> dnsName?.lowercase().hashCode() + isLocationFavorite -> gateway?.lowercase().hashCode() + else -> 0 + } + } +} 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..251bf40ab 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 @@ -203,6 +203,59 @@ public boolean canBeUsedAsMultiHopWith(Server server) { return !this.countryCode.equalsIgnoreCase(server.countryCode); } + /** + * Returns the normalized gateway, replacing .wg. with .gw. + * This makes the gateway protocol-agnostic, matching iOS implementation. + * Example: "gb.wg.ivpn.net" -> "gb.gw.ivpn.net" + */ + public String getNormalizedGateway() { + if (gateway == null || gateway.isEmpty()) return ""; + return gateway.replace(".wg.", ".gw."); + } + + /** + * Extracts the location prefix from the gateway. + * For example, "gb.wg.ivpn.net" -> "gb" + * or "us-ca.gw.ivpn.net" -> "us-ca" + */ + public String getGatewayPrefix() { + if (gateway == null || gateway.isEmpty()) return ""; + String[] parts = gateway.split("\\."); + if (parts.length > 0) return parts[0]; + return ""; + } + + /** + * Checks if this server matches the given gateway prefix. + * Normalizes the gateway by removing protocol-specific parts (.wg. -> .gw.) + */ + public boolean matchesGatewayPrefix(String prefix) { + if (prefix == null || prefix.isEmpty()) return false; + return getGatewayPrefix().equalsIgnoreCase(prefix); + } + + /** + * Checks if this server has a host with the given dns_name. + */ + public boolean hasHostWithDnsName(String dnsName) { + if (dnsName == null || dnsName.isEmpty() || hosts == null) return false; + for (Host host : hosts) { + if (dnsName.equals(host.getDnsName())) { + return true; + } + } + return false; + } + + /** + * Gets the first host's dns_name if available. + * Used for single-host servers as the favorite identifier. + */ + public String getFirstHostDnsName() { + if (hosts == null || hosts.isEmpty()) return null; + return hosts.get(0).getDnsName(); + } + public boolean isPingInfoSameWith(Server server) { return this.equals(server) && this.latency == server.latency; } diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java b/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java index d749ac867..ecc2abfeb 100644 --- a/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java @@ -22,6 +22,7 @@ along with the IVPN Android app. If not, see . */ +import net.ivpn.core.rest.data.model.Host; import net.ivpn.core.rest.data.model.Server; public interface AdapterListener { @@ -37,4 +38,8 @@ public interface AdapterListener { void onRandomServerSelected(); void changeFavouriteStateFor(Server server, boolean isFavourite); + + void onHostSelected(Host host, Server parentServer, Server forbiddenServer); + + void onServerExpandToggle(Server server); } diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/OnServerExpandListener.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/OnServerExpandListener.kt new file mode 100644 index 000000000..b5df0fb91 --- /dev/null +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/OnServerExpandListener.kt @@ -0,0 +1,34 @@ +package net.ivpn.core.v2.serverlist + +/* + IVPN Android app + https://github.com/ivpn/android-app + + Created by Tamim Hossain. + Copyright (c) 2025 IVPN Limited. + + This file is part of the IVPN Android app. + + The IVPN Android app is free software: you can redistribute it and/or + modify it under the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any later version. + + The IVPN Android app is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + details. + + You should have received a copy of the GNU General Public License + along with the IVPN Android app. If not, see . +*/ + +import net.ivpn.core.rest.data.model.Server + +/** + * Listener for server expansion toggle events in the server list. + * Used to handle expanding/collapsing server items to show individual hosts. + */ +interface OnServerExpandListener { + fun onServerExpandToggle(server: Server) +} + diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt index 277ef6d8a..ef51d9d6f 100644 --- a/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt @@ -35,17 +35,20 @@ import net.ivpn.core.common.distance.DistanceProvider import net.ivpn.core.common.distance.OnDistanceChangedListener import net.ivpn.core.common.pinger.PingResultFormatter import net.ivpn.core.databinding.FastestServerItemBinding +import net.ivpn.core.databinding.HostItemBinding import net.ivpn.core.databinding.RandomServerItemBinding import net.ivpn.core.databinding.SearchItemBinding import net.ivpn.core.databinding.ServerItemBinding import net.ivpn.core.rest.data.model.Server import net.ivpn.core.v2.serverlist.AdapterListener import net.ivpn.core.v2.serverlist.FavouriteServerListener +import net.ivpn.core.v2.serverlist.OnServerExpandListener import net.ivpn.core.v2.serverlist.ServerBasedRecyclerViewAdapter import net.ivpn.core.v2.serverlist.dialog.Filters import net.ivpn.core.v2.serverlist.holders.* import net.ivpn.core.v2.serverlist.items.ConnectionOption import net.ivpn.core.v2.serverlist.items.FastestServerItem +import net.ivpn.core.v2.serverlist.items.HostItem import net.ivpn.core.v2.serverlist.items.RandomServerItem import net.ivpn.core.v2.serverlist.items.SearchServerItem import org.slf4j.LoggerFactory @@ -59,18 +62,22 @@ class AllServersRecyclerViewAdapter( private val isFastestServerAllowed: Boolean, private var filter: Filters?, private var isIPv6Enabled: Boolean -) : RecyclerView.Adapter(), ServerBasedRecyclerViewAdapter, FavouriteServerListener { +) : RecyclerView.Adapter(), ServerBasedRecyclerViewAdapter, FavouriteServerListener, OnServerExpandListener { @Inject lateinit var distanceProvider: DistanceProvider private var bindings = HashMap() + private var hostBindings = HashMap() private var searchBinding: SearchItemBinding? = null private var servers = arrayListOf() private var filteredServers = arrayListOf() private var displayServers = arrayListOf() private var forbiddenServer: Server? = null private var isFiltering = false + + // Track expanded servers by their city (unique identifier) + private var expandedServerCities = mutableSetOf() val distanceChangedListener = object : OnDistanceChangedListener { override fun onDistanceChanged() { @@ -89,6 +96,11 @@ class AllServersRecyclerViewAdapter( private var pings: Map? = null override fun getItemViewType(position: Int): Int { + val item = displayServers.getOrNull(position) + if (item is HostItem) { + return HOST_ITEM + } + if (isFiltering) { return when (position) { 0 -> SEARCH_ITEM @@ -125,6 +137,10 @@ class AllServersRecyclerViewAdapter( val binding = FastestServerItemBinding.inflate(layoutInflater, parent, false) FastestServerViewHolder(binding, navigator) } + HOST_ITEM -> { + val binding = HostItemBinding.inflate(layoutInflater, parent, false) + HostViewHolder(binding, navigator) + } else -> { val binding = ServerItemBinding.inflate(layoutInflater, parent, false) ServerViewHolder(binding, navigator) @@ -145,11 +161,19 @@ class AllServersRecyclerViewAdapter( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (holder is ServerViewHolder) { - val server: ConnectionOption = getServerFor(position) - if (server is Server) { - bindings[holder.binding] = server - setPing(holder.binding, server) - holder.bind(server, forbiddenServer, isIPv6Enabled, filter) + val item: ConnectionOption = getServerFor(position) + if (item is Server) { + bindings[holder.binding] = item + setPing(holder.binding, item) + val isExpanded = expandedServerCities.contains(item.city) + val showExpandButton = !isFiltering // Only show expand in non-search mode + holder.bind(item, forbiddenServer, isIPv6Enabled, filter, isExpanded, showExpandButton) + } + } else if (holder is HostViewHolder) { + val item: ConnectionOption = getServerFor(position) + if (item is HostItem) { + hostBindings[holder.binding] = item + holder.bind(item, forbiddenServer) } } else if (holder is SearchViewHolder) { searchBinding = holder.binding @@ -262,10 +286,33 @@ class AllServersRecyclerViewAdapter( } } sortServers(servers) - listToShow.addAll(servers) + + // Add servers and their hosts if expanded + for (server in servers) { + listToShow.add(server) + + // If this server is expanded, add its hosts + if (expandedServerCities.contains(server.city) && !isFiltering) { + server.hosts?.let { hosts -> + for (host in hosts) { + listToShow.add(HostItem(host, server)) + } + } + } + } return listToShow } + + override fun onServerExpandToggle(server: Server) { + val city = server.city + if (expandedServerCities.contains(city)) { + expandedServerCities.remove(city) + } else { + expandedServerCities.add(city) + } + applyFilter() + } override fun setForbiddenServer(server: Server?) { forbiddenServer = server @@ -374,5 +421,6 @@ class AllServersRecyclerViewAdapter( private const val SERVER_ITEM = 1 private const val SEARCH_ITEM = 2 private const val RANDOM_ITEM = 3 + private const val HOST_ITEM = 4 } } \ No newline at end of file diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/all/ServerListFragment.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/all/ServerListFragment.kt index f963d4874..372955044 100644 --- a/core/src/main/java/net/ivpn/core/v2/serverlist/all/ServerListFragment.kt +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/all/ServerListFragment.kt @@ -121,6 +121,7 @@ class ServerListFragment : Fragment(), super.onDestroy() if (this::adapter.isInitialized) { viewmodel.favouriteListeners.remove(adapter) + viewmodel.expandListeners.remove(adapter) } filterViewModel.listeners.remove(this) adapter.release() @@ -141,6 +142,7 @@ class ServerListFragment : Fragment(), binding.swipeRefreshLayout.setColorSchemeResources(R.color.colorPrimary, R.color.colorAccent) viewmodel.favouriteListeners.add(adapter) + viewmodel.expandListeners.add(adapter) } fun cancel() { diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt index 66a293029..17e32ded5 100644 --- a/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt @@ -106,7 +106,8 @@ class FavouriteServerListRecyclerViewAdapter( val server: Server = getServerFor(position) bindings[holder.binding] = server setPing(holder.binding, server) - holder.bind(server, forbiddenServer, isIPv6BadgeEnabled, filter) + // For favourite servers, don't show expand button (hosts are shown in main list) + holder.bind(server, forbiddenServer, isIPv6BadgeEnabled, filter, false, false) } } diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt new file mode 100644 index 000000000..8071b1bcf --- /dev/null +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/HostViewHolder.kt @@ -0,0 +1,58 @@ +package net.ivpn.core.v2.serverlist.holders + +/* + IVPN Android app + https://github.com/ivpn/android-app + + Created by Tamim Hossain. + Copyright (c) 2025 IVPN Limited. + + This file is part of the IVPN Android app. + + The IVPN Android app is free software: you can redistribute it and/or + modify it under the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any later version. + + The IVPN Android app is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + details. + + You should have received a copy of the GNU General Public License + along with the IVPN Android app. If not, see . +*/ + +import androidx.recyclerview.widget.RecyclerView +import net.ivpn.core.R +import net.ivpn.core.databinding.HostItemBinding +import net.ivpn.core.rest.data.model.Server +import net.ivpn.core.v2.serverlist.AdapterListener +import net.ivpn.core.v2.serverlist.items.HostItem + +class HostViewHolder( + val binding: HostItemBinding, + val navigator: AdapterListener +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(hostItem: HostItem, forbiddenServer: Server?) { + binding.hostItem = hostItem + binding.navigator = navigator + + // Set load indicator color based on load percentage + val load = hostItem.getLoad() + val loadIndicatorRes = when { + load < 50 -> R.drawable.ping_green_light + load < 80 -> R.drawable.ping_yellow_light + else -> R.drawable.ping_red_light + } + binding.loadIndicator.setImageResource(loadIndicatorRes) + + // Handle click to select this specific host + binding.hostLayout.setOnClickListener { + navigator.onHostSelected(hostItem.host, hostItem.parentServer, forbiddenServer) + } + + binding.executePendingBindings() + } +} + diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt index 8fac62655..55ce843c5 100644 --- a/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/holders/ServerViewHolder.kt @@ -35,7 +35,8 @@ class ServerViewHolder( val navigator: AdapterListener ) : RecyclerView.ViewHolder(binding.root) { - fun bind(server: Server, forbiddenServer: Server?, isIPv6Enabled: Boolean, filter: Filters?) { + fun bind(server: Server, forbiddenServer: Server?, isIPv6Enabled: Boolean, filter: Filters?, + isExpanded: Boolean = false, showExpandButton: Boolean = false) { binding.server = server binding.forbiddenServer = forbiddenServer binding.navigator = navigator @@ -52,6 +53,19 @@ class ServerViewHolder( } binding.ipv6Badge.isVisible = server.isIPv6Enabled && isIPv6Enabled binding.filter = filter + + // Handle expand button visibility and state + val hasMultipleHosts = server.hosts != null && server.hosts.size > 1 + binding.expandLayout.isVisible = showExpandButton && hasMultipleHosts + if (hasMultipleHosts) { + binding.expandIcon.setImageResource( + if (isExpanded) R.drawable.ic_expand_less else R.drawable.ic_expand_more + ) + binding.expandLayout.setOnClickListener { + navigator.onServerExpandToggle(server) + } + } + binding.executePendingBindings() } diff --git a/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt b/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt new file mode 100644 index 000000000..6d215e227 --- /dev/null +++ b/core/src/main/java/net/ivpn/core/v2/serverlist/items/HostItem.kt @@ -0,0 +1,77 @@ +package net.ivpn.core.v2.serverlist.items + +/* + IVPN Android app + https://github.com/ivpn/android-app + + Created by Tamim Hossain. + Copyright (c) 2025 IVPN Limited. + + This file is part of the IVPN Android app. + + The IVPN Android app is free software: you can redistribute it and/or + modify it under the terms of the GNU General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any later version. + + The IVPN Android app is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + details. + + You should have received a copy of the GNU General Public License + along with the IVPN Android app. If not, see . +*/ + +import net.ivpn.core.rest.data.model.Host +import net.ivpn.core.rest.data.model.Server + +/** + * Represents a host item in the server list, used for displaying individual + * hosts when a server is expanded. This allows users to select a specific + * host for consistent IP address connections. + */ +data class HostItem( + val host: Host, + val parentServer: Server +) : ConnectionOption { + + /** + * Returns the host name (e.g., "gb-lon-wg-001.relays.ivpn.net") + */ + fun getHostName(): String { + return host.hostname ?: "" + } + + /** + * Returns a shortened host name for display (e.g., "gb-lon-wg-001") + */ + fun getShortHostName(): String { + val hostname = host.hostname ?: return "" + return hostname.substringBefore(".relays") + } + + /** + * Returns the server load as a formatted percentage string + */ + fun getLoadPercentage(): String { + return "${host.load.toInt()}%" + } + + /** + * Returns the raw load value (0-100) + */ + fun getLoad(): Double { + return host.load + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HostItem) return false + return host.hostname == other.host.hostname + } + + override fun hashCode(): Int { + return host.hostname?.hashCode() ?: 0 + } +} + diff --git a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt index e9e058fa6..c4dec4b3b 100644 --- a/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt +++ b/core/src/main/java/net/ivpn/core/v2/viewmodel/ServerListViewModel.kt @@ -33,10 +33,12 @@ import net.ivpn.core.common.prefs.OnServerListUpdatedListener import net.ivpn.core.rest.data.model.ServerType import net.ivpn.core.common.prefs.ServersRepository import net.ivpn.core.common.prefs.Settings +import net.ivpn.core.rest.data.model.Host import net.ivpn.core.rest.data.model.Server import net.ivpn.core.v2.dialog.Dialogs import net.ivpn.core.v2.serverlist.AdapterListener import net.ivpn.core.v2.serverlist.FavouriteServerListener +import net.ivpn.core.v2.serverlist.OnServerExpandListener import javax.inject.Inject @ApplicationScope @@ -57,6 +59,7 @@ class ServerListViewModel @Inject constructor( val dataLoading = ObservableBoolean() val navigators = arrayListOf() val favouriteListeners = arrayListOf() + val expandListeners = arrayListOf() val adapterListener = object : AdapterListener { override fun onServerLongClick(server: Server) { @@ -99,6 +102,25 @@ class ServerListViewModel @Inject constructor( navigators[0].onServerSelected() } } + + override fun onHostSelected(host: Host, parentServer: Server, forbiddenServer: Server?) { + if (parentServer.canBeUsedAsMultiHopWith(forbiddenServer)) { + setCurrentServerAndHost(parentServer, host) + if (navigators.isNotEmpty()) { + navigators[0].onServerSelected() + } + } else { + if (navigators.isNotEmpty()) { + navigators[0].showDialog(Dialogs.INCOMPATIBLE_SERVERS) + } + } + } + + override fun onServerExpandToggle(server: Server) { + for (listener in expandListeners) { + listener.onServerExpandToggle(server) + } + } } private var listener: OnServerListUpdatedListener = object : OnServerListUpdatedListener { @@ -137,6 +159,12 @@ class ServerListViewModel @Inject constructor( } } + fun setCurrentServerAndHost(server: Server?, host: Host?) { + serverType?.let { + serversRepository.hostSelected(server, host, it) + } + } + fun start(serverType: ServerType?) { if (serverType == null) return diff --git a/core/src/main/java/net/ivpn/core/vpn/wireguard/ConfigManager.kt b/core/src/main/java/net/ivpn/core/vpn/wireguard/ConfigManager.kt index a1f5aad71..f85c7b969 100644 --- a/core/src/main/java/net/ivpn/core/vpn/wireguard/ConfigManager.kt +++ b/core/src/main/java/net/ivpn/core/vpn/wireguard/ConfigManager.kt @@ -150,7 +150,12 @@ class ConfigManager @Inject constructor( return null } - val host = if (v2rayController.isV2RayEnabled()) { + // Check if a specific host was selected for consistent IP address + val selectedHost = serversRepository.getCurrentHost(ServerType.ENTRY) + val host = if (selectedHost != null && server.hosts.any { it.hostname == selectedHost.hostname }) { + LOGGER.info("Using user-selected specific host: ${selectedHost.hostname}") + selectedHost + } else if (v2rayController.isV2RayEnabled()) { val candidates = server.hosts.filter { it.v2ray != null && it.v2ray.isNotEmpty() } val selected = candidates.randomOrNull() ?: server.hosts.random() if (candidates.isEmpty()) { @@ -190,7 +195,14 @@ class ConfigManager @Inject constructor( return null } - val entryHost = if (v2rayController.isV2RayEnabled()) { + // Check if specific hosts were selected + val selectedEntryHost = serversRepository.getCurrentHost(ServerType.ENTRY) + val selectedExitHost = serversRepository.getCurrentHost(ServerType.EXIT) + + val entryHost = if (selectedEntryHost != null && entryServer.hosts.any { it.hostname == selectedEntryHost.hostname }) { + LOGGER.info("Using user-selected specific entry host: ${selectedEntryHost.hostname}") + selectedEntryHost + } else if (v2rayController.isV2RayEnabled()) { val candidates = entryServer.hosts.filter { it.v2ray != null && it.v2ray.isNotEmpty() } val selected = candidates.randomOrNull() ?: entryServer.hosts.random() if (candidates.isEmpty()) { @@ -200,7 +212,13 @@ class ConfigManager @Inject constructor( } else { entryServer.hosts.random() } - val exitHost = exitServer.hosts.random() + + val exitHost = if (selectedExitHost != null && exitServer.hosts.any { it.hostname == selectedExitHost.hostname }) { + LOGGER.info("Using user-selected specific exit host: ${selectedExitHost.hostname}") + selectedExitHost + } else { + exitServer.hosts.random() + } LOGGER.info("Multi-hop: Entry server: ${entryHost.hostname} (${entryHost.host})") LOGGER.info("Multi-hop: Exit server: ${exitHost.hostname} (${exitHost.host})") diff --git a/core/src/main/res/drawable/ic_expand_less.xml b/core/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 000000000..42a6378dd --- /dev/null +++ b/core/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/src/main/res/drawable/ic_expand_more.xml b/core/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 000000000..52d82e957 --- /dev/null +++ b/core/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/src/main/res/layout/host_item.xml b/core/src/main/res/layout/host_item.xml new file mode 100644 index 000000000..df24c8c6b --- /dev/null +++ b/core/src/main/res/layout/host_item.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/res/layout/server_item.xml b/core/src/main/res/layout/server_item.xml index 8717dff53..c99a54589 100755 --- a/core/src/main/res/layout/server_item.xml +++ b/core/src/main/res/layout/server_item.xml @@ -105,6 +105,24 @@ + + + + + + Your favourite servers will be\n displayed here Save your time by creating your own list of servers + + Server load + Server host + Select a specific server to get the same IP address on every connection + Choose which servers can be used as the fastest. At least one server should be selected.