Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions core/src/main/java/net/ivpn/core/common/Mapper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 }
Expand Down
21 changes: 21 additions & 0 deletions core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions core/src/main/java/net/ivpn/core/common/prefs/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
along with the IVPN Android app. If not, see <https://www.gnu.org/licenses/>.
*/

import net.ivpn.core.rest.data.model.Host;
import net.ivpn.core.rest.data.model.Server;

public interface AdapterListener {
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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)
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,18 +63,25 @@ class AllServersRecyclerViewAdapter(
private val isFastestServerAllowed: Boolean,
private var filter: Filters?,
private var isIPv6Enabled: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ServerBasedRecyclerViewAdapter, FavouriteServerListener {
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ServerBasedRecyclerViewAdapter, FavouriteServerListener, OnServerExpandListener {

@Inject
lateinit var distanceProvider: DistanceProvider

@Inject
lateinit var settings: Settings

private var bindings = HashMap<ServerItemBinding, Server>()
private var hostBindings = HashMap<HostItemBinding, HostItem>()
private var searchBinding: SearchItemBinding? = null
private var servers = arrayListOf<Server>()
private var filteredServers = arrayListOf<Server>()
private var displayServers = arrayListOf<ConnectionOption>()
private var forbiddenServer: Server? = null
private var isFiltering = false

// Track expanded servers by their city (unique identifier)
private var expandedServerCities = mutableSetOf<String>()

val distanceChangedListener = object : OnDistanceChangedListener {
override fun onDistanceChanged() {
Expand All @@ -89,6 +100,11 @@ class AllServersRecyclerViewAdapter(
private var pings: Map<Server, PingResultFormatter?>? = 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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading