Skip to content

Commit 6feb2c3

Browse files
committed
feat: unify favorite locations and hosts across all VPN protocols
feat: unify favorite locations and hosts across all VPN protocols
1 parent 128fbb6 commit 6feb2c3

File tree

7 files changed

+415
-38
lines changed

7 files changed

+415
-38
lines changed

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.google.gson.JsonSyntaxException
2727
import com.google.gson.reflect.TypeToken
2828
import net.ivpn.core.rest.data.ServersListResponse
2929
import net.ivpn.core.rest.data.model.AntiTracker
30+
import net.ivpn.core.rest.data.model.FavoriteIdentifier
3031
import net.ivpn.core.rest.data.model.Port
3132
import net.ivpn.core.rest.data.model.Server
3233
import net.ivpn.core.rest.data.session.SessionErrorResponse
@@ -132,4 +133,20 @@ object Mapper {
132133
null
133134
}
134135
}
136+
137+
fun favoriteIdentifierListFrom(json: String?): MutableList<FavoriteIdentifier>? {
138+
if (json == null) return null
139+
return try {
140+
val type = object : TypeToken<List<FavoriteIdentifier>>() {}.type
141+
Gson().fromJson(json, type)
142+
} catch (_: JsonSyntaxException) {
143+
null
144+
} catch (_: IllegalStateException) {
145+
null
146+
}
147+
}
148+
149+
fun stringFromFavoriteIdentifiers(identifiers: List<FavoriteIdentifier>?): String {
150+
return Gson().toJson(identifiers)
151+
}
135152
}

core/src/main/java/net/ivpn/core/common/migration/MigrationController.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ class MigrationController @Inject constructor(
7272
return when (version) {
7373
2 -> UF1T2(userPreference, protocolController)
7474
3 -> UF2T3(repository, serversPreference)
75+
4 -> UF3T4(serversPreference)
7576
else -> null
7677
}
7778
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package net.ivpn.core.common.migration
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.common.prefs.ServersPreference
26+
import org.slf4j.LoggerFactory
27+
28+
/**
29+
* Migration from version 3 to 4.
30+
* Migrates per-protocol favorites (OpenVPN and WireGuard) to unified favorites storage.
31+
* The unified favorites use protocol-agnostic identifiers (gateway prefix for locations,
32+
* dns_name for hosts) which allows favorites to be shared across all VPN protocols.
33+
*/
34+
class UF3T4(
35+
private val serversPreference: ServersPreference
36+
) : Update {
37+
38+
companion object {
39+
private val LOGGER = LoggerFactory.getLogger(UF3T4::class.java)
40+
}
41+
42+
override fun update() {
43+
LOGGER.info("Migrating favorites to unified storage")
44+
serversPreference.migrateOldFavouritesToUnified()
45+
LOGGER.info("Favorites migration completed")
46+
}
47+
}
48+

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import javax.inject.Inject
3535
class Preference @Inject constructor() {
3636

3737
companion object {
38-
const val LAST_LOGIC_VERSION = 3
38+
const val LAST_LOGIC_VERSION = 4
3939
private const val CURRENT_LOGIC_VERSION = "CURRENT_LOGIC_VERSION"
4040
private const val COMMON_PREF = "COMMON_PREF"
4141
private const val TRUSTED_WIFI_PREF = "TRUSTED_WIFI_PREF"

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

Lines changed: 157 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package net.ivpn.core.common.prefs
33
import android.content.SharedPreferences
44
import net.ivpn.core.common.Mapper
55
import net.ivpn.core.common.dagger.ApplicationScope
6+
import net.ivpn.core.rest.data.model.FavoriteIdentifier
67
import net.ivpn.core.rest.data.model.Server
78
import net.ivpn.core.rest.data.model.ServerLocation
89
import net.ivpn.core.rest.data.model.ServerLocation.Companion.from
@@ -48,6 +49,7 @@ class ServersPreference @Inject constructor(
4849
private const val SERVERS_LIST = "SERVERS_LIST"
4950
private const val LOCATION_LIST = "LOCATION_LIST"
5051
private const val FAVOURITES_SERVERS_LIST = "FAVOURITES_SERVERS_LIST"
52+
private const val UNIFIED_FAVOURITES_LIST = "UNIFIED_FAVOURITES_LIST"
5153
private const val EXCLUDED_FASTEST_SERVERS = "EXCLUDED_FASTEST_SERVERS"
5254
private const val SETTINGS_FASTEST_SERVER = "SETTINGS_FASTEST_SERVER"
5355
private const val SETTINGS_RANDOM_ENTER_SERVER = "SETTINGS_RANDOM_ENTER_SERVER"
@@ -91,28 +93,75 @@ class ServersPreference @Inject constructor(
9193
return Mapper.serverListFrom(sharedPreferences.getString(SERVERS_LIST, null))
9294
}
9395

96+
/**
97+
* Returns the list of favorite servers for the current protocol.
98+
* This uses the unified favorites system where favorites are stored as
99+
* protocol-agnostic identifiers (gateway prefix for locations, dns_name for hosts).
100+
* When retrieving, it matches these identifiers against the current protocol's servers.
101+
*/
94102
val favouritesServersList: MutableList<Server>
95103
get() {
96-
val sharedPreferences = properSharedPreference
97-
val servers =
98-
Mapper.serverListFrom(sharedPreferences.getString(FAVOURITES_SERVERS_LIST, null))
99-
return servers ?: ArrayList()
104+
val identifiers = unifiedFavouritesList
105+
val currentServers = serversList ?: return ArrayList()
106+
107+
val favourites = ArrayList<Server>()
108+
for (server in currentServers) {
109+
for (identifier in identifiers) {
110+
if (identifier.matches(server)) {
111+
favourites.add(server)
112+
break
113+
}
114+
}
115+
}
116+
return favourites
117+
}
118+
119+
/**
120+
* Returns the unified list of favorite identifiers.
121+
* These identifiers are protocol-agnostic and work across OpenVPN and WireGuard.
122+
*/
123+
val unifiedFavouritesList: MutableList<FavoriteIdentifier>
124+
get() {
125+
// First try to get from unified storage
126+
val sharedPreferences = preference.stickySharedPreferences
127+
val identifiers = Mapper.favoriteIdentifierListFrom(
128+
sharedPreferences.getString(UNIFIED_FAVOURITES_LIST, null)
129+
)
130+
return identifiers ?: ArrayList()
100131
}
101132

102133
val openvpnFavouritesServersList: MutableList<Server>
103134
get() {
104-
val sharedPreferences = preference.serversSharedPreferences
105-
val servers =
106-
Mapper.serverListFrom(sharedPreferences.getString(FAVOURITES_SERVERS_LIST, null))
107-
return servers ?: ArrayList()
135+
val identifiers = unifiedFavouritesList
136+
val currentServers = openvpnServersList ?: return ArrayList()
137+
138+
val favourites = ArrayList<Server>()
139+
for (server in currentServers) {
140+
for (identifier in identifiers) {
141+
if (identifier.matches(server)) {
142+
favourites.add(server)
143+
break
144+
}
145+
}
146+
}
147+
return favourites
108148
}
109149

110150
val wireguardFavouritesServersList: MutableList<Server>
111151
get() {
112-
val sharedPreferences = preference.wireguardServersSharedPreferences
113-
val servers =
114-
Mapper.serverListFrom(sharedPreferences.getString(FAVOURITES_SERVERS_LIST, null))
115-
return servers ?: ArrayList()
152+
val identifiers = unifiedFavouritesList
153+
val currentServers = wireguardServersList ?: return ArrayList()
154+
155+
val favourites = ArrayList<Server>()
156+
for (server in currentServers) {
157+
for (identifier in identifiers) {
158+
if (identifier.matches(server)) {
159+
favourites.add(server)
160+
break
161+
}
162+
}
163+
}
164+
return favourites
116165
}
117166

118167
val excludedServersList: MutableList<Server>
@@ -200,40 +249,111 @@ class ServersPreference @Inject constructor(
200249
return Mapper.from(sharedPreferences.getString(serverKey, null))
201250
}
202251

252+
/**
253+
* Adds a server to the unified favorites list.
254+
* The server is stored as a protocol-agnostic identifier:
255+
* - For locations: normalized gateway (with .wg. replaced by .gw.)
256+
* - For hosts: dns_name
257+
* This matches the iOS implementation.
258+
*/
203259
fun addFavouriteServer(server: Server?) {
204-
val openvpnServer = openvpnServersList?.first { it == server }
205-
val wireguardServer = wireguardServersList?.first { it == server }
206-
if (server == null || openvpnServer == null || wireguardServer == null) {
260+
if (server == null) return
261+
262+
val identifier = FavoriteIdentifier.fromServer(server)
263+
val identifiers = unifiedFavouritesList
264+
265+
// Check if already in favorites
266+
if (identifiers.any { it == identifier }) {
207267
return
208268
}
209-
val openvpnServers = openvpnFavouritesServersList
210-
val wireguardServers = wireguardFavouritesServersList
211-
if (!openvpnServers.contains(openvpnServer)) {
212-
openvpnServers.add(openvpnServer)
213-
preference.serversSharedPreferences.edit()
214-
.putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(openvpnServers)).apply()
269+
270+
identifiers.add(identifier)
271+
saveUnifiedFavourites(identifiers)
272+
}
273+
274+
/**
275+
* Removes a server from the unified favorites list.
276+
*/
277+
fun removeFavouriteServer(server: Server) {
278+
val identifier = FavoriteIdentifier.fromServer(server)
279+
val identifiers = unifiedFavouritesList
280+
281+
identifiers.removeAll { it == identifier }
282+
saveUnifiedFavourites(identifiers)
283+
}
284+
285+
/**
286+
* Adds a specific host to favorites by dns_name.
287+
*/
288+
fun addFavouriteHost(dnsName: String) {
289+
val identifier = FavoriteIdentifier.forHost(dnsName)
290+
val identifiers = unifiedFavouritesList
291+
292+
if (identifiers.any { it == identifier }) {
293+
return
215294
}
216-
if (!wireguardServers.contains(wireguardServer)) {
217-
wireguardServers.add(wireguardServer)
218-
preference.wireguardServersSharedPreferences.edit()
219-
.putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(wireguardServers)).apply()
295+
296+
identifiers.add(identifier)
297+
saveUnifiedFavourites(identifiers)
298+
}
299+
300+
/**
301+
* Removes a specific host from favorites by dns_name.
302+
*/
303+
fun removeFavouriteHost(dnsName: String) {
304+
val identifier = FavoriteIdentifier.forHost(dnsName)
305+
val identifiers = unifiedFavouritesList
306+
307+
identifiers.removeAll { it == identifier }
308+
saveUnifiedFavourites(identifiers)
309+
}
310+
311+
/**
312+
* Checks if a server is in the favorites list.
313+
*/
314+
fun isFavourite(server: Server): Boolean {
315+
val identifier = FavoriteIdentifier.fromServer(server)
316+
return unifiedFavouritesList.any { it == identifier }
317+
}
318+
319+
/**
320+
* Saves the unified favorites list.
321+
*/
322+
private fun saveUnifiedFavourites(identifiers: List<FavoriteIdentifier>) {
323+
preference.stickySharedPreferences.edit {
324+
putString(UNIFIED_FAVOURITES_LIST, Mapper.stringFromFavoriteIdentifiers(identifiers))
220325
}
221326
}
222327

223-
fun removeFavouriteServer(server: Server) {
224-
val openvpnServer = openvpnServersList?.first { it == server }
225-
val wireguardServer = wireguardServersList?.first { it == server }
226-
if (openvpnServer == null || wireguardServer == null) {
328+
/**
329+
* Migrates old per-protocol favorites to the new unified format.
330+
* This should be called once during app upgrade.
331+
*/
332+
fun migrateOldFavouritesToUnified() {
333+
// Check if already migrated
334+
if (preference.stickySharedPreferences.contains(UNIFIED_FAVOURITES_LIST)) {
227335
return
228336
}
229-
val openvpnServers = openvpnFavouritesServersList
230-
val wireguardServers = wireguardFavouritesServersList
231-
openvpnServers.remove(openvpnServer)
232-
wireguardServers.remove(wireguardServer)
233-
preference.serversSharedPreferences.edit()
234-
.putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(openvpnServers)).apply()
235-
preference.wireguardServersSharedPreferences.edit()
236-
.putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(wireguardServers)).apply()
337+
338+
val identifiers = mutableSetOf<FavoriteIdentifier>()
339+
340+
val oldOpenvpnFavourites = Mapper.serverListFrom(
341+
preference.serversSharedPreferences.getString(FAVOURITES_SERVERS_LIST, null)
342+
)
343+
oldOpenvpnFavourites?.forEach { server ->
344+
identifiers.add(FavoriteIdentifier.fromServer(server))
345+
}
346+
347+
val oldWireguardFavourites = Mapper.serverListFrom(
348+
preference.wireguardServersSharedPreferences.getString(FAVOURITES_SERVERS_LIST, null)
349+
)
350+
oldWireguardFavourites?.forEach { server ->
351+
identifiers.add(FavoriteIdentifier.fromServer(server))
352+
}
353+
354+
if (identifiers.isNotEmpty()) {
355+
saveUnifiedFavourites(identifiers.toList())
356+
}
237357
}
238358

239359
fun addToExcludedServersList(server: Server?) {

0 commit comments

Comments
 (0)