Skip to content

Commit d945c4c

Browse files
committed
feature: unified favorites across vpn protocols
1 parent 128fbb6 commit d945c4c

23 files changed

+905
-50
lines changed

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ 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
31+
import net.ivpn.core.rest.data.model.Host
3032
import net.ivpn.core.rest.data.model.Port
3133
import net.ivpn.core.rest.data.model.Server
3234
import net.ivpn.core.rest.data.session.SessionErrorResponse
@@ -35,6 +37,17 @@ import net.ivpn.core.vpn.model.V2RaySettings
3537
import java.util.*
3638

3739
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+
}
3851
fun from(json: String?): Server? {
3952
return if (json == null) null else Gson().fromJson(json, Server::class.java)
4053
}
@@ -132,4 +145,20 @@ object Mapper {
132145
null
133146
}
134147
}
148+
149+
fun favoriteIdentifierListFrom(json: String?): MutableList<FavoriteIdentifier>? {
150+
if (json == null) return null
151+
return try {
152+
val type = object : TypeToken<List<FavoriteIdentifier>>() {}.type
153+
Gson().fromJson(json, type)
154+
} catch (_: JsonSyntaxException) {
155+
null
156+
} catch (_: IllegalStateException) {
157+
null
158+
}
159+
}
160+
161+
fun stringFromFavoriteIdentifiers(identifiers: List<FavoriteIdentifier>?): String {
162+
return Gson().toJson(identifiers)
163+
}
135164
}

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: 187 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ 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
7+
import net.ivpn.core.rest.data.model.Host
68
import net.ivpn.core.rest.data.model.Server
79
import net.ivpn.core.rest.data.model.ServerLocation
810
import net.ivpn.core.rest.data.model.ServerLocation.Companion.from
@@ -45,9 +47,12 @@ class ServersPreference @Inject constructor(
4547
companion object {
4648
private const val CURRENT_ENTER_SERVER = "CURRENT_ENTER_SERVER"
4749
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"
4852
private const val SERVERS_LIST = "SERVERS_LIST"
4953
private const val LOCATION_LIST = "LOCATION_LIST"
5054
private const val FAVOURITES_SERVERS_LIST = "FAVOURITES_SERVERS_LIST"
55+
private const val UNIFIED_FAVOURITES_LIST = "UNIFIED_FAVOURITES_LIST"
5156
private const val EXCLUDED_FASTEST_SERVERS = "EXCLUDED_FASTEST_SERVERS"
5257
private const val SETTINGS_FASTEST_SERVER = "SETTINGS_FASTEST_SERVER"
5358
private const val SETTINGS_RANDOM_ENTER_SERVER = "SETTINGS_RANDOM_ENTER_SERVER"
@@ -91,28 +96,75 @@ class ServersPreference @Inject constructor(
9196
return Mapper.serverListFrom(sharedPreferences.getString(SERVERS_LIST, null))
9297
}
9398

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

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

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

118170
val excludedServersList: MutableList<Server>
@@ -200,40 +252,138 @@ class ServersPreference @Inject constructor(
200252
return Mapper.from(sharedPreferences.getString(serverKey, null))
201253
}
202254

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+
283+
/**
284+
* Adds a server to the unified favorites list.
285+
* The server is stored as a protocol-agnostic identifier:
286+
* - For locations: normalized gateway (with .wg. replaced by .gw.)
287+
* - For hosts: dns_name
288+
*/
203289
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) {
290+
if (server == null) return
291+
292+
val identifier = FavoriteIdentifier.fromServer(server)
293+
val identifiers = unifiedFavouritesList
294+
295+
// Check if already in favorites
296+
if (identifiers.any { it == identifier }) {
207297
return
208298
}
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()
299+
300+
identifiers.add(identifier)
301+
saveUnifiedFavourites(identifiers)
302+
}
303+
304+
/**
305+
* Removes a server from the unified favorites list.
306+
*/
307+
fun removeFavouriteServer(server: Server) {
308+
val identifier = FavoriteIdentifier.fromServer(server)
309+
val identifiers = unifiedFavouritesList
310+
311+
identifiers.removeAll { it == identifier }
312+
saveUnifiedFavourites(identifiers)
313+
}
314+
315+
/**
316+
* Adds a specific host to favorites by dns_name.
317+
*/
318+
fun addFavouriteHost(dnsName: String) {
319+
val identifier = FavoriteIdentifier.forHost(dnsName)
320+
val identifiers = unifiedFavouritesList
321+
322+
if (identifiers.any { it == identifier }) {
323+
return
215324
}
216-
if (!wireguardServers.contains(wireguardServer)) {
217-
wireguardServers.add(wireguardServer)
218-
preference.wireguardServersSharedPreferences.edit()
219-
.putString(FAVOURITES_SERVERS_LIST, Mapper.stringFrom(wireguardServers)).apply()
325+
326+
identifiers.add(identifier)
327+
saveUnifiedFavourites(identifiers)
328+
}
329+
330+
/**
331+
* Removes a specific host from favorites by dns_name.
332+
*/
333+
fun removeFavouriteHost(dnsName: String) {
334+
val identifier = FavoriteIdentifier.forHost(dnsName)
335+
val identifiers = unifiedFavouritesList
336+
337+
identifiers.removeAll { it == identifier }
338+
saveUnifiedFavourites(identifiers)
339+
}
340+
341+
/**
342+
* Checks if a server is in the favorites list.
343+
*/
344+
fun isFavourite(server: Server): Boolean {
345+
val identifier = FavoriteIdentifier.fromServer(server)
346+
return unifiedFavouritesList.any { it == identifier }
347+
}
348+
349+
/**
350+
* Saves the unified favorites list.
351+
*/
352+
private fun saveUnifiedFavourites(identifiers: List<FavoriteIdentifier>) {
353+
preference.stickySharedPreferences.edit {
354+
putString(UNIFIED_FAVOURITES_LIST, Mapper.stringFromFavoriteIdentifiers(identifiers))
220355
}
221356
}
222357

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) {
358+
/**
359+
* Migrates old per-protocol favorites to the new unified format.
360+
* This should be called once during app upgrade.
361+
*/
362+
fun migrateOldFavouritesToUnified() {
363+
// Check if already migrated
364+
if (preference.stickySharedPreferences.contains(UNIFIED_FAVOURITES_LIST)) {
227365
return
228366
}
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()
367+
368+
val identifiers = mutableSetOf<FavoriteIdentifier>()
369+
370+
val oldOpenvpnFavourites = Mapper.serverListFrom(
371+
preference.serversSharedPreferences.getString(FAVOURITES_SERVERS_LIST, null)
372+
)
373+
oldOpenvpnFavourites?.forEach { server ->
374+
identifiers.add(FavoriteIdentifier.fromServer(server))
375+
}
376+
377+
val oldWireguardFavourites = Mapper.serverListFrom(
378+
preference.wireguardServersSharedPreferences.getString(FAVOURITES_SERVERS_LIST, null)
379+
)
380+
oldWireguardFavourites?.forEach { server ->
381+
identifiers.add(FavoriteIdentifier.fromServer(server))
382+
}
383+
384+
if (identifiers.isNotEmpty()) {
385+
saveUnifiedFavourites(identifiers.toList())
386+
}
237387
}
238388

239389
fun addToExcludedServersList(server: Server?) {

0 commit comments

Comments
 (0)