Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.univpn.app.data

import androidx.room.Room
import androidx.room.withTransaction
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.univpn.app.data.db.AppDatabase
import com.univpn.app.data.model.GeneratedProfileMeta
import com.univpn.app.data.model.TunnelType
import com.univpn.app.data.model.VpnProfile
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/** The write pattern FreshnessChecker uses for a refreshed config (#11). */
@RunWith(AndroidJUnit4::class)
class ProfileRefreshDaoTest {

private lateinit var db: AppDatabase

private val profile = VpnProfile("p1", "Mullvad — Tirana", TunnelType.WIREGUARD, "old config")
private val meta = GeneratedProfileMeta(
profileId = "p1", accountId = "a1", providerId = "mullvad", serverId = "al-tia-wg-001|pk",
serverName = "al-tia-wg-001", serverCountry = "Albania", serverCity = "Tirana",
generatedAt = 1L, publicKey = "pub"
)

@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java)
.allowMainThreadQueries()
.build()
}

@After
fun tearDown() { db.close() }

private suspend fun refresh(newConfig: String): Boolean = db.withTransaction {
if (db.vpnProfileDao().update(profile.copy(configContent = newConfig)) == 0) false
else {
db.generatedProfileMetaDao().upsert(meta.copy(generatedAt = 2L))
true
}
}

@Test
fun refresh_updatesExistingProfileAndMeta() = runBlocking {
db.vpnProfileDao().insert(profile)
db.generatedProfileMetaDao().upsert(meta)

assertEquals(true, refresh("new config"))
assertEquals("new config", db.vpnProfileDao().getById("p1")?.configContent)
assertEquals(2L, db.generatedProfileMetaDao().getByProfileId("p1")?.generatedAt)
}

@Test
fun refresh_doesNotRecreateDeletedProfile() = runBlocking {
db.vpnProfileDao().insert(profile)
db.generatedProfileMetaDao().upsert(meta)
db.vpnProfileDao().delete(profile) // user deletes it while the refresh request is in flight

assertEquals(false, refresh("new config"))
assertNull(db.vpnProfileDao().getById("p1"))
assertNull(db.generatedProfileMetaDao().getByProfileId("p1"))
}

@Test
fun update_keepsMetadataRow() = runBlocking {
// INSERT OR REPLACE deletes the parent row first, which cascades to the metadata.
// UPDATE must leave it in place.
db.vpnProfileDao().insert(profile)
db.generatedProfileMetaDao().upsert(meta)
db.vpnProfileDao().update(profile.copy(configContent = "edited"))
assertNotNull(db.generatedProfileMetaDao().getByProfileId("p1"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,11 @@ class ServiceRecoveryTest {
}

@Test
fun serviceDead_unmappedUserApp_implicitNoVpn_tunnelDropped() = runBlocking {
// User app with no DB entry and not a system app → route == null, not system.
// onAppForeground falls through to targetProfile = null → tunnel stopped.
fun serviceDead_unmappedUserApp_keepsCurrentTunnel() = runBlocking {
// An app with no route ("Default (keep current tunnel)", or installed after the
// seeder last ran) keeps whatever tunnel is up instead of dropping it (#5).
val route = db.appRouteDao().getByPackage("com.some.newapp")
assertNull(route)
// No route + not system app → targetProfile = null → tunnel will be dropped.
// This is the implicit NoVPN behaviour for unmapped user apps.
assertEquals(RouteDecision.Keep, decideRoute(route))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ interface GeneratedProfileMetaDao {
@Query("SELECT * FROM generated_profile_meta WHERE profileId = :profileId")
suspend fun getByProfileId(profileId: String): GeneratedProfileMeta?

@Query("SELECT * FROM generated_profile_meta WHERE providerId = :providerId")
suspend fun getByProvider(providerId: String): List<GeneratedProfileMeta>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(meta: GeneratedProfileMeta)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ interface VpnProfileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(profile: VpnProfile)

/** Returns the number of rows updated: 0 if the profile no longer exists. */
@Update
suspend fun update(profile: VpnProfile): Int

@Delete
suspend fun delete(profile: VpnProfile)
}
60 changes: 42 additions & 18 deletions univpn/src/main/kotlin/com/univpn/app/provider/FreshnessChecker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,37 @@ package com.univpn.app.provider

import android.content.Context
import android.util.Log
import androidx.room.withTransaction
import com.univpn.app.data.db.AppDatabase
import com.univpn.app.data.model.GeneratedProfileMeta
import com.univpn.app.data.model.VpnProfile
import com.univpn.app.data.model.TunnelType
import java.util.UUID
import com.univpn.app.tunnel.WgConfig

private const val REFRESH_AGE_MS = 7L * 24 * 60 * 60 * 1000 // 7 days
private const val TAG = "FreshnessChecker"

class FreshnessChecker(private val context: Context) {

suspend fun checkAndRefreshIfStale(profile: VpnProfile) {
/** Returns true if [profile]'s config was replaced, so a running tunnel should be restarted. */
suspend fun checkAndRefreshIfStale(profile: VpnProfile): Boolean {
val db = AppDatabase.get(context)
val meta = db.generatedProfileMetaDao().getByProfileId(profile.id) ?: return
val meta = db.generatedProfileMetaDao().getByProfileId(profile.id) ?: return false
val age = System.currentTimeMillis() - meta.generatedAt
if (age < REFRESH_AGE_MS) return
if (age < REFRESH_AGE_MS) return false

val connector = ProviderRegistry.get(meta.providerId) ?: return
val connector = ProviderRegistry.get(meta.providerId) ?: return false
val credStore = CredentialStore(context)
val credentials = credStore.load(meta.accountId) ?: run {
Log.w(TAG, "No credentials for ${meta.providerId} account ${meta.accountId}")
return
return false
}

// Keep the endpoint the profile already uses (the relay's IP for Mullvad). The server id
// only holds the relay-list hostname, which doesn't resolve on its own.
val server = VpnServer(
id = meta.serverId,
country = meta.serverCountry,
city = meta.serverCity,
hostname = meta.serverId.substringBefore("|"),
hostname = WgConfig.endpointHost(profile.configContent) ?: meta.serverId.substringBefore("|"),
serverName = meta.serverName,
)

Expand All @@ -42,16 +44,38 @@ class FreshnessChecker(private val context: Context) {
} else Pair(meta.publicKey.ifEmpty { null }, null)

val result = connector.generateConfig(server, credentials, wgPub, wgPriv)
if (result is ConnectorResult.Success) {
val (newConfig, newPubKey) = result.value
val updatedProfile = profile.copy(configContent = newConfig)
db.vpnProfileDao().insert(updatedProfile) // REPLACE strategy
db.generatedProfileMetaDao().upsert(
meta.copy(generatedAt = System.currentTimeMillis(), publicKey = newPubKey)
)
Log.i(TAG, "Silently refreshed config for ${profile.name}")
} else {
if (result !is ConnectorResult.Success) {
Log.w(TAG, "Config refresh failed for ${profile.name}: $result")
return false
}
val (newConfig, newPubKey) = result.value
// UPDATE rather than INSERT OR REPLACE: a profile deleted while the request was in
// flight stays deleted, and the metadata isn't cascade-deleted and re-inserted.
val updated = db.withTransaction {
if (db.vpnProfileDao().update(profile.copy(configContent = newConfig)) == 0) {
false
} else {
db.generatedProfileMetaDao().upsert(
meta.copy(generatedAt = System.currentTimeMillis(), publicKey = newPubKey)
)
true
}
}
if (updated) Log.i(TAG, "Silently refreshed config for ${profile.name}")
else Log.i(TAG, "${profile.name} was deleted during refresh; not recreating it")
return updated
}

/** Fixes Mullvad configs whose Endpoint was written as a bare, unresolvable relay hostname. */
suspend fun repairMullvadEndpoints() {
val db = AppDatabase.get(context)
for (meta in db.generatedProfileMetaDao().getByProvider("mullvad")) {
val profile = db.vpnProfileDao().getById(meta.profileId) ?: continue
val repaired = MullvadEndpoint.repair(profile.configContent)
if (repaired != profile.configContent) {
db.vpnProfileDao().update(profile.copy(configContent = repaired))
Log.i(TAG, "Repaired Mullvad endpoint for ${profile.name}")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ class MullvadConnector : VpnProviderConnector {
appendLine()
appendLine("[Peer]")
appendLine("PublicKey = $serverPubKey")
appendLine("Endpoint = ${server.hostname}:51820")
appendLine("Endpoint = ${MullvadEndpoint.resolvable(server.hostname)}:51820")
appendLine("AllowedIPs = 0.0.0.0/0, ::/0")
}
Log.i(TAG, "Mullvad config generated for ${server.city}")
Expand Down
21 changes: 21 additions & 0 deletions univpn/src/main/kotlin/com/univpn/app/provider/MullvadEndpoint.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.univpn.app.provider

/**
* Mullvad relay-list hostnames such as "se-got-wg-001" don't resolve in DNS on their own;
* only "se-got-wg-001.relays.mullvad.net" does. Config refreshes before 0.9.2 wrote the bare
* name into Endpoint, so these helpers both build and repair endpoints.
*/
object MullvadEndpoint {
private const val RELAY_DOMAIN = ".relays.mullvad.net"

// Endpoint = <bare host>:<port>. IPv4 and FQDNs contain '.', IPv6 is bracketed, so neither matches.
private val BARE_ENDPOINT = Regex("""(?im)^(\s*Endpoint\s*=\s*)([A-Za-z0-9-]+)(:\d+)\s*$""")

/** Returns [host] unchanged if it's an IP or FQDN, otherwise the relay's resolvable name. */
fun resolvable(host: String): String =
if ('.' in host || ':' in host) host else host + RELAY_DOMAIN

/** Rewrites a bare relay hostname in the Endpoint line; returns [config] unchanged otherwise. */
fun repair(config: String): String =
BARE_ENDPOINT.replace(config) { m -> m.groupValues[1] + resolvable(m.groupValues[2]) + m.groupValues[3] }
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ class DebugOverlayService : Service() {
}
}

val failed = VpnSwitcherService.failedProfileFlow.value
val profileLine = when {
failed != null -> "⚠ $failed — NOT CONNECTED"
DebugState.profileType.isNotEmpty() -> "● ${DebugState.profileName} (${DebugState.profileType})"
DebugState.profileName != "None" -> "● ${DebugState.profileName}"
else -> "○ No VPN"
Expand Down
20 changes: 20 additions & 0 deletions univpn/src/main/kotlin/com/univpn/app/service/RouteDecision.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.univpn.app.service

import com.univpn.app.data.model.AppRoute

/** What a foreground app asks of the tunnel. */
sealed class RouteDecision {
/** Leave the tunnel as it is (Passthrough, or no route = "Default (keep current tunnel)"). */
object Keep : RouteDecision()

/** Run this profile; null means No VPN. */
data class Use(val profileId: String?) : RouteDecision()
}

fun decideRoute(route: AppRoute?): RouteDecision = when {
route == null || route.isPassthrough -> RouteDecision.Keep
else -> RouteDecision.Use(route.profileId)
}

/** Delay before retrying a failed tunnel start: 5 s, 10 s, 20 s, 40 s, then every 60 s. */
fun retryDelayMs(attempt: Int): Long = minOf(60_000L, 5_000L shl minOf(attempt, 4))
Loading