Skip to content
Draft
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
29 changes: 29 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,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
Expand All @@ -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)
}
Expand Down Expand Up @@ -132,4 +145,20 @@ object Mapper {
null
}
}

fun favoriteIdentifierListFrom(json: String?): MutableList<FavoriteIdentifier>? {
if (json == null) return null
return try {
val type = object : TypeToken<List<FavoriteIdentifier>>() {}.type
Gson().fromJson(json, type)
} catch (_: JsonSyntaxException) {
null
} catch (_: IllegalStateException) {
null
}
}

fun stringFromFavoriteIdentifiers(identifiers: List<FavoriteIdentifier>?): String {
return Gson().toJson(identifiers)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class MigrationController @Inject constructor(
return when (version) {
2 -> UF1T2(userPreference, protocolController)
3 -> UF2T3(repository, serversPreference)
4 -> UF3T4(serversPreference)
else -> null
}
}
Expand Down
48 changes: 48 additions & 0 deletions core/src/main/java/net/ivpn/core/common/migration/UF3T4.kt
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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

Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
224 changes: 187 additions & 37 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,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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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<Server>
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<Server>()
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<FavoriteIdentifier>
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<Server>
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<Server>()
for (server in currentServers) {
for (identifier in identifiers) {
if (identifier.matches(server)) {
favourites.add(server)
break
}
}
}
return favourites
}

val wireguardFavouritesServersList: MutableList<Server>
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<Server>()
for (server in currentServers) {
for (identifier in identifiers) {
if (identifier.matches(server)) {
favourites.add(server)
break
}
}
}
return favourites
}

val excludedServersList: MutableList<Server>
Expand Down Expand Up @@ -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<FavoriteIdentifier>) {
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<FavoriteIdentifier>()

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?) {
Expand Down
Loading