Skip to content

Commit d05f414

Browse files
committed
Merge feature/server-load-and-manual-selection to prevent conflicts on PR merge
2 parents 6feb2c3 + e65e815 commit d05f414

File tree

18 files changed

+491
-13
lines changed

18 files changed

+491
-13
lines changed

core/src/main/java/net/ivpn/core/common/Mapper.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.google.gson.reflect.TypeToken
2828
import net.ivpn.core.rest.data.ServersListResponse
2929
import net.ivpn.core.rest.data.model.AntiTracker
3030
import net.ivpn.core.rest.data.model.FavoriteIdentifier
31+
import net.ivpn.core.rest.data.model.Host
3132
import net.ivpn.core.rest.data.model.Port
3233
import net.ivpn.core.rest.data.model.Server
3334
import net.ivpn.core.rest.data.session.SessionErrorResponse
@@ -36,6 +37,17 @@ import net.ivpn.core.vpn.model.V2RaySettings
3637
import java.util.*
3738

3839
object Mapper {
40+
fun hostFrom(json: String?): Host? {
41+
return if (json == null || json.isEmpty()) null else try {
42+
Gson().fromJson(json, Host::class.java)
43+
} catch (_: JsonSyntaxException) {
44+
null
45+
}
46+
}
47+
48+
fun stringFromHost(host: Host?): String {
49+
return Gson().toJson(host)
50+
}
3951
fun from(json: String?): Server? {
4052
return if (json == null) null else Gson().fromJson(json, Server::class.java)
4153
}

core/src/main/java/net/ivpn/core/common/prefs/ServersPreference.kt

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.SharedPreferences
44
import net.ivpn.core.common.Mapper
55
import net.ivpn.core.common.dagger.ApplicationScope
66
import net.ivpn.core.rest.data.model.FavoriteIdentifier
7+
import net.ivpn.core.rest.data.model.Host
78
import net.ivpn.core.rest.data.model.Server
89
import net.ivpn.core.rest.data.model.ServerLocation
910
import net.ivpn.core.rest.data.model.ServerLocation.Companion.from
@@ -46,6 +47,8 @@ class ServersPreference @Inject constructor(
4647
companion object {
4748
private const val CURRENT_ENTER_SERVER = "CURRENT_ENTER_SERVER"
4849
private const val CURRENT_EXIT_SERVER = "CURRENT_EXIT_SERVER"
50+
private const val CURRENT_ENTER_HOST = "CURRENT_ENTER_HOST"
51+
private const val CURRENT_EXIT_HOST = "CURRENT_EXIT_HOST"
4952
private const val SERVERS_LIST = "SERVERS_LIST"
5053
private const val LOCATION_LIST = "LOCATION_LIST"
5154
private const val FAVOURITES_SERVERS_LIST = "FAVOURITES_SERVERS_LIST"
@@ -249,12 +252,39 @@ class ServersPreference @Inject constructor(
249252
return Mapper.from(sharedPreferences.getString(serverKey, null))
250253
}
251254

255+
fun setCurrentHost(serverType: ServerType?, host: Host?) {
256+
if (serverType == null) return
257+
val hostKey =
258+
if (serverType == ServerType.ENTRY) CURRENT_ENTER_HOST else CURRENT_EXIT_HOST
259+
preference.serversSharedPreferences.edit {
260+
putString(hostKey, Mapper.stringFromHost(host))
261+
}
262+
preference.wireguardServersSharedPreferences.edit {
263+
putString(hostKey, Mapper.stringFromHost(host))
264+
}
265+
}
266+
267+
fun getCurrentHost(serverType: ServerType?): Host? {
268+
if (serverType == null) return null
269+
val sharedPreferences = properSharedPreference
270+
val hostKey =
271+
if (serverType == ServerType.ENTRY) CURRENT_ENTER_HOST else CURRENT_EXIT_HOST
272+
return Mapper.hostFrom(sharedPreferences.getString(hostKey, null))
273+
}
274+
275+
fun clearCurrentHost(serverType: ServerType?) {
276+
if (serverType == null) return
277+
val hostKey =
278+
if (serverType == ServerType.ENTRY) CURRENT_ENTER_HOST else CURRENT_EXIT_HOST
279+
preference.serversSharedPreferences.edit { remove(hostKey) }
280+
preference.wireguardServersSharedPreferences.edit { remove(hostKey) }
281+
}
282+
252283
/**
253284
* Adds a server to the unified favorites list.
254285
* The server is stored as a protocol-agnostic identifier:
255286
* - For locations: normalized gateway (with .wg. replaced by .gw.)
256287
* - For hosts: dns_name
257-
* This matches the iOS implementation.
258288
*/
259289
fun addFavouriteServer(server: Server?) {
260290
if (server == null) return

core/src/main/java/net/ivpn/core/common/prefs/ServersRepository.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import net.ivpn.core.rest.RequestListener
3030
import net.ivpn.core.rest.data.ServersListResponse
3131
import net.ivpn.core.rest.data.model.AntiTracker
3232
import net.ivpn.core.rest.data.model.Config
33+
import net.ivpn.core.rest.data.model.Host
3334
import net.ivpn.core.rest.data.model.Server
3435
import net.ivpn.core.rest.data.model.ServerLocation
3536
import net.ivpn.core.rest.data.model.ServerType
@@ -276,11 +277,31 @@ class ServersRepository @Inject constructor(
276277
serversPreference.putSettingFastestServer(false)
277278
serversPreference.putSettingRandomServer(false, type)
278279
setCurrentServer(type, server)
280+
// Clear host selection when a different server is selected
281+
serversPreference.clearCurrentHost(type)
279282
for (listener in onServerChangedListeners) {
280283
listener.onServerChanged()
281284
}
282285
}
283286

287+
fun hostSelected(server: Server?, host: Host?, type: ServerType) {
288+
serversPreference.putSettingFastestServer(false)
289+
serversPreference.putSettingRandomServer(false, type)
290+
setCurrentServer(type, server)
291+
serversPreference.setCurrentHost(type, host)
292+
for (listener in onServerChangedListeners) {
293+
listener.onServerChanged()
294+
}
295+
}
296+
297+
fun getCurrentHost(serverType: ServerType): Host? {
298+
return serversPreference.getCurrentHost(serverType)
299+
}
300+
301+
fun clearCurrentHost(serverType: ServerType) {
302+
serversPreference.clearCurrentHost(serverType)
303+
}
304+
284305
private fun tryUpdateServerListOffline() {
285306
LOGGER.info("Trying update server list offline from cache...")
286307
if (getCachedServers() != null) {

core/src/main/java/net/ivpn/core/v2/serverlist/AdapterListener.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
along with the IVPN Android app. If not, see <https://www.gnu.org/licenses/>.
2323
*/
2424

25+
import net.ivpn.core.rest.data.model.Host;
2526
import net.ivpn.core.rest.data.model.Server;
2627

2728
public interface AdapterListener {
@@ -37,4 +38,8 @@ public interface AdapterListener {
3738
void onRandomServerSelected();
3839

3940
void changeFavouriteStateFor(Server server, boolean isFavourite);
41+
42+
void onHostSelected(Host host, Server parentServer, Server forbiddenServer);
43+
44+
void onServerExpandToggle(Server server);
4045
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package net.ivpn.core.v2.serverlist
2+
3+
/*
4+
IVPN Android app
5+
https://github.com/ivpn/android-app
6+
7+
Created by Tamim Hossain.
8+
Copyright (c) 2025 IVPN Limited.
9+
10+
This file is part of the IVPN Android app.
11+
12+
The IVPN Android app is free software: you can redistribute it and/or
13+
modify it under the terms of the GNU General Public License as published by the Free
14+
Software Foundation, either version 3 of the License, or (at your option) any later version.
15+
16+
The IVPN Android app is distributed in the hope that it will be useful,
17+
but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
18+
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
19+
details.
20+
21+
You should have received a copy of the GNU General Public License
22+
along with the IVPN Android app. If not, see <https://www.gnu.org/licenses/>.
23+
*/
24+
25+
import net.ivpn.core.rest.data.model.Server
26+
27+
/**
28+
* Listener for server expansion toggle events in the server list.
29+
* Used to handle expanding/collapsing server items to show individual hosts.
30+
*/
31+
interface OnServerExpandListener {
32+
fun onServerExpandToggle(server: Server)
33+
}
34+

core/src/main/java/net/ivpn/core/v2/serverlist/all/AllServersRecyclerViewAdapter.kt

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,20 @@ import net.ivpn.core.common.distance.DistanceProvider
3535
import net.ivpn.core.common.distance.OnDistanceChangedListener
3636
import net.ivpn.core.common.pinger.PingResultFormatter
3737
import net.ivpn.core.databinding.FastestServerItemBinding
38+
import net.ivpn.core.databinding.HostItemBinding
3839
import net.ivpn.core.databinding.RandomServerItemBinding
3940
import net.ivpn.core.databinding.SearchItemBinding
4041
import net.ivpn.core.databinding.ServerItemBinding
4142
import net.ivpn.core.rest.data.model.Server
4243
import net.ivpn.core.v2.serverlist.AdapterListener
4344
import net.ivpn.core.v2.serverlist.FavouriteServerListener
45+
import net.ivpn.core.v2.serverlist.OnServerExpandListener
4446
import net.ivpn.core.v2.serverlist.ServerBasedRecyclerViewAdapter
4547
import net.ivpn.core.v2.serverlist.dialog.Filters
4648
import net.ivpn.core.v2.serverlist.holders.*
4749
import net.ivpn.core.v2.serverlist.items.ConnectionOption
4850
import net.ivpn.core.v2.serverlist.items.FastestServerItem
51+
import net.ivpn.core.v2.serverlist.items.HostItem
4952
import net.ivpn.core.v2.serverlist.items.RandomServerItem
5053
import net.ivpn.core.v2.serverlist.items.SearchServerItem
5154
import org.slf4j.LoggerFactory
@@ -59,18 +62,22 @@ class AllServersRecyclerViewAdapter(
5962
private val isFastestServerAllowed: Boolean,
6063
private var filter: Filters?,
6164
private var isIPv6Enabled: Boolean
62-
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ServerBasedRecyclerViewAdapter, FavouriteServerListener {
65+
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ServerBasedRecyclerViewAdapter, FavouriteServerListener, OnServerExpandListener {
6366

6467
@Inject
6568
lateinit var distanceProvider: DistanceProvider
6669

6770
private var bindings = HashMap<ServerItemBinding, Server>()
71+
private var hostBindings = HashMap<HostItemBinding, HostItem>()
6872
private var searchBinding: SearchItemBinding? = null
6973
private var servers = arrayListOf<Server>()
7074
private var filteredServers = arrayListOf<Server>()
7175
private var displayServers = arrayListOf<ConnectionOption>()
7276
private var forbiddenServer: Server? = null
7377
private var isFiltering = false
78+
79+
// Track expanded servers by their city (unique identifier)
80+
private var expandedServerCities = mutableSetOf<String>()
7481

7582
val distanceChangedListener = object : OnDistanceChangedListener {
7683
override fun onDistanceChanged() {
@@ -89,6 +96,11 @@ class AllServersRecyclerViewAdapter(
8996
private var pings: Map<Server, PingResultFormatter?>? = null
9097

9198
override fun getItemViewType(position: Int): Int {
99+
val item = displayServers.getOrNull(position)
100+
if (item is HostItem) {
101+
return HOST_ITEM
102+
}
103+
92104
if (isFiltering) {
93105
return when (position) {
94106
0 -> SEARCH_ITEM
@@ -125,6 +137,10 @@ class AllServersRecyclerViewAdapter(
125137
val binding = FastestServerItemBinding.inflate(layoutInflater, parent, false)
126138
FastestServerViewHolder(binding, navigator)
127139
}
140+
HOST_ITEM -> {
141+
val binding = HostItemBinding.inflate(layoutInflater, parent, false)
142+
HostViewHolder(binding, navigator)
143+
}
128144
else -> {
129145
val binding = ServerItemBinding.inflate(layoutInflater, parent, false)
130146
ServerViewHolder(binding, navigator)
@@ -145,11 +161,19 @@ class AllServersRecyclerViewAdapter(
145161

146162
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
147163
if (holder is ServerViewHolder) {
148-
val server: ConnectionOption = getServerFor(position)
149-
if (server is Server) {
150-
bindings[holder.binding] = server
151-
setPing(holder.binding, server)
152-
holder.bind(server, forbiddenServer, isIPv6Enabled, filter)
164+
val item: ConnectionOption = getServerFor(position)
165+
if (item is Server) {
166+
bindings[holder.binding] = item
167+
setPing(holder.binding, item)
168+
val isExpanded = expandedServerCities.contains(item.city)
169+
val showExpandButton = !isFiltering // Only show expand in non-search mode
170+
holder.bind(item, forbiddenServer, isIPv6Enabled, filter, isExpanded, showExpandButton)
171+
}
172+
} else if (holder is HostViewHolder) {
173+
val item: ConnectionOption = getServerFor(position)
174+
if (item is HostItem) {
175+
hostBindings[holder.binding] = item
176+
holder.bind(item, forbiddenServer)
153177
}
154178
} else if (holder is SearchViewHolder) {
155179
searchBinding = holder.binding
@@ -262,10 +286,33 @@ class AllServersRecyclerViewAdapter(
262286
}
263287
}
264288
sortServers(servers)
265-
listToShow.addAll(servers)
289+
290+
// Add servers and their hosts if expanded
291+
for (server in servers) {
292+
listToShow.add(server)
293+
294+
// If this server is expanded, add its hosts
295+
if (expandedServerCities.contains(server.city) && !isFiltering) {
296+
server.hosts?.let { hosts ->
297+
for (host in hosts) {
298+
listToShow.add(HostItem(host, server))
299+
}
300+
}
301+
}
302+
}
266303

267304
return listToShow
268305
}
306+
307+
override fun onServerExpandToggle(server: Server) {
308+
val city = server.city
309+
if (expandedServerCities.contains(city)) {
310+
expandedServerCities.remove(city)
311+
} else {
312+
expandedServerCities.add(city)
313+
}
314+
applyFilter()
315+
}
269316

270317
override fun setForbiddenServer(server: Server?) {
271318
forbiddenServer = server
@@ -374,5 +421,6 @@ class AllServersRecyclerViewAdapter(
374421
private const val SERVER_ITEM = 1
375422
private const val SEARCH_ITEM = 2
376423
private const val RANDOM_ITEM = 3
424+
private const val HOST_ITEM = 4
377425
}
378426
}

core/src/main/java/net/ivpn/core/v2/serverlist/all/ServerListFragment.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ class ServerListFragment : Fragment(),
121121
super.onDestroy()
122122
if (this::adapter.isInitialized) {
123123
viewmodel.favouriteListeners.remove(adapter)
124+
viewmodel.expandListeners.remove(adapter)
124125
}
125126
filterViewModel.listeners.remove(this)
126127
adapter.release()
@@ -141,6 +142,7 @@ class ServerListFragment : Fragment(),
141142
binding.swipeRefreshLayout.setColorSchemeResources(R.color.colorPrimary,
142143
R.color.colorAccent)
143144
viewmodel.favouriteListeners.add(adapter)
145+
viewmodel.expandListeners.add(adapter)
144146
}
145147

146148
fun cancel() {

core/src/main/java/net/ivpn/core/v2/serverlist/favourite/FavouriteServerListRecyclerViewAdapter.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ class FavouriteServerListRecyclerViewAdapter(
106106
val server: Server = getServerFor(position)
107107
bindings[holder.binding] = server
108108
setPing(holder.binding, server)
109-
holder.bind(server, forbiddenServer, isIPv6BadgeEnabled, filter)
109+
// For favourite servers, don't show expand button (hosts are shown in main list)
110+
holder.bind(server, forbiddenServer, isIPv6BadgeEnabled, filter, false, false)
110111
}
111112
}
112113

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package net.ivpn.core.v2.serverlist.holders
2+
3+
/*
4+
IVPN Android app
5+
https://github.com/ivpn/android-app
6+
7+
Created by Tamim Hossain.
8+
Copyright (c) 2025 IVPN Limited.
9+
10+
This file is part of the IVPN Android app.
11+
12+
The IVPN Android app is free software: you can redistribute it and/or
13+
modify it under the terms of the GNU General Public License as published by the Free
14+
Software Foundation, either version 3 of the License, or (at your option) any later version.
15+
16+
The IVPN Android app is distributed in the hope that it will be useful,
17+
but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
18+
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
19+
details.
20+
21+
You should have received a copy of the GNU General Public License
22+
along with the IVPN Android app. If not, see <https://www.gnu.org/licenses/>.
23+
*/
24+
25+
import androidx.recyclerview.widget.RecyclerView
26+
import net.ivpn.core.R
27+
import net.ivpn.core.databinding.HostItemBinding
28+
import net.ivpn.core.rest.data.model.Server
29+
import net.ivpn.core.v2.serverlist.AdapterListener
30+
import net.ivpn.core.v2.serverlist.items.HostItem
31+
32+
class HostViewHolder(
33+
val binding: HostItemBinding,
34+
val navigator: AdapterListener
35+
) : RecyclerView.ViewHolder(binding.root) {
36+
37+
fun bind(hostItem: HostItem, forbiddenServer: Server?) {
38+
binding.hostItem = hostItem
39+
binding.navigator = navigator
40+
41+
// Set load indicator color based on load percentage
42+
val load = hostItem.getLoad()
43+
val loadIndicatorRes = when {
44+
load < 50 -> R.drawable.ping_green_light
45+
load < 80 -> R.drawable.ping_yellow_light
46+
else -> R.drawable.ping_red_light
47+
}
48+
binding.loadIndicator.setImageResource(loadIndicatorRes)
49+
50+
// Handle click to select this specific host
51+
binding.hostLayout.setOnClickListener {
52+
navigator.onHostSelected(hostItem.host, hostItem.parentServer, forbiddenServer)
53+
}
54+
55+
binding.executePendingBindings()
56+
}
57+
}
58+

0 commit comments

Comments
 (0)