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
15 changes: 4 additions & 11 deletions app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -157,18 +157,11 @@ fun VpnScreen(
}
}

// Auto-open portal once per tunnel session (connectedSince-keyed, not a plain boolean,
// so re-foregrounding the app on the same session does not re-fire the browser).
// ponytail: open-once-per-tunnel-session via connectedSince identity; a new user-initiated
// connect mints a new connectedSince and re-opens, a blip/re-foreground on the same session
// does not. autoOpen is keyed so a delayed permissions fetch that flips it true re-evaluates.
var lastOpenedSince by rememberSaveable { mutableStateOf(0L) }
// Auto-open portal once per tunnel session; the ViewModel keys it on
// connectedSince. autoOpen/portalUrl are keys so a delayed permissions
// fetch that enables it re-evaluates for the current session.
LaunchedEffect(tunnelState, portalUrl, autoOpen) {
val st = tunnelState
if (st is TunnelState.Connected && autoOpen && !portalUrl.isNullOrBlank() && st.connectedSince != lastOpenedSince) {
lastOpenedSince = st.connectedSince
viewModel.openPortal(context)
}
viewModel.autoOpenPortalIfNeeded(context, tunnelState)
}

// Tick every second to update connection duration
Expand Down
69 changes: 66 additions & 3 deletions app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ import com.gatecontrol.android.network.MachineBindingMonitor
import com.gatecontrol.android.network.PermissionFlags
import com.gatecontrol.android.network.TrafficStats
import com.gatecontrol.android.network.VpnService
import com.gatecontrol.android.network.getPortalLink
import com.gatecontrol.android.common.ClientPolicy
import com.gatecontrol.android.service.ClientPolicyManager
import com.gatecontrol.android.service.TunnelConnector
import com.gatecontrol.android.tunnel.TunnelManager
import com.gatecontrol.android.tunnel.TunnelState
import com.gatecontrol.android.tunnel.TunnelStats
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
Expand All @@ -27,6 +31,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject

Expand Down Expand Up @@ -227,11 +232,69 @@ class VpnViewModel @Inject constructor(
}
}

/** In-flight portal-link fetch; a second tap while it runs is ignored. */
private var portalJob: Job? = null

/** connectedSince of the tunnel session the portal was auto-opened for (0 = none). */
private var autoOpenedSince = 0L

/** Opens the portal on a tap. See [openPortalWithLogin]. */
fun openPortal(context: android.content.Context) {
val url = portalUrl.value ?: return
if (!url.startsWith("https://")) return
openPortalWithLogin(context)
}

/**
* Auto-open once per tunnel session (keyed on connectedSince, not a plain
* boolean): re-foregrounding or recomposing on the same session does not
* re-open the browser, a new connect mints a new connectedSince and does.
* Does nothing until the server enabled auto-open and sent a portal URL,
* so a delayed permissions fetch can still trigger it for this session.
*/
fun autoOpenPortalIfNeeded(context: android.content.Context, state: TunnelState) {
if (state !is TunnelState.Connected) return
if (!autoOpenPortal.value || portalUrl.value.isNullOrBlank()) return
if (state.connectedSince == autoOpenedSince) return
autoOpenedSince = state.connectedSince
openPortalWithLogin(context)
}

/**
* Fetches a fresh one-time login link right before opening (never cached:
* the ticket is single-use and short-lived) and falls back to the plain
* portal URL when the server cannot provide one. The fetch runs in
* [viewModelScope] so the tap stays responsive; the browser is started on
* the main thread. The link is never logged.
*/
private fun openPortalWithLogin(context: android.content.Context) {
val fallback = portalUrl.value ?: return
if (!fallback.startsWith("https://")) return
if (portalJob?.isActive == true) return
val appContext = context.applicationContext ?: context
portalJob = viewModelScope.launch {
val link = fetchPortalLink(fallback)
withContext(Dispatchers.Main) {
portalOpener(appContext, link ?: fallback)
}
}
}

private suspend fun fetchPortalLink(portalUrl: String): String? {
val serverUrl = setupRepository.getServerUrl()
if (serverUrl.isEmpty()) return null
return try {
apiClientProvider.getClient(serverUrl).getPortalLink(portalUrl)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.d("Portal link unavailable (${e.javaClass.simpleName})")
null
}
}

/** Starts the browser for [url]; replaceable in tests. */
internal var portalOpener: (android.content.Context, String) -> Unit = { ctx, url ->
runCatching {
context.startActivity(
ctx.startActivity(
android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
)
Expand Down
154 changes: 154 additions & 0 deletions app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -269,4 +269,158 @@ class VpnViewModelTest {
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 0) { tunnelManager.disconnect() }
}

// --- Portal auto-login ---

private val portal = "https://portal.example.com"
private val opened = mutableListOf<String>()
private val context = mockk<android.content.Context>(relaxed = true)

private fun enablePortal(autoOpen: Boolean = true) {
coEvery { apiClient.getPermissions() } returns PermissionsResponse(
ok = true,
permissions = PermissionFlags(services = false, traffic = false, dns = false, rdp = false),
scopes = emptyList(),
portalUrl = portal,
autoOpenPortal = autoOpen,
)
viewModel.loadPermissions()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.portalOpener = { _, url -> opened += url }
}

private fun portalLink(url: String?) = retrofit2.Response.success(
com.gatecontrol.android.network.PortalLinkResponse(ok = true, url = url, expiresIn = 60),
)

@Test
fun `openPortal opens the one-time login link`() = runTest {
enablePortal()
coEvery { apiClient.requestPortalLink() } returns portalLink("$portal/auto?t=ticket1")

viewModel.openPortal(context)
testDispatcher.scheduler.advanceUntilIdle()

assertEquals(listOf("$portal/auto?t=ticket1"), opened)
}

@Test
fun `openPortal fetches a fresh link on every tap`() = runTest {
enablePortal()
coEvery { apiClient.requestPortalLink() } returnsMany listOf(
portalLink("$portal/auto?t=a"),
portalLink("$portal/auto?t=b"),
)

viewModel.openPortal(context)
testDispatcher.scheduler.advanceUntilIdle()
viewModel.openPortal(context)
testDispatcher.scheduler.advanceUntilIdle()

assertEquals(listOf("$portal/auto?t=a", "$portal/auto?t=b"), opened)
coVerify(exactly = 2) { apiClient.requestPortalLink() }
}

@Test
fun `openPortal falls back to the portal URL on 404`() = runTest {
enablePortal()
coEvery { apiClient.requestPortalLink() } returns retrofit2.Response.error(
404, "{}".toResponseBody(null),
)

viewModel.openPortal(context)
testDispatcher.scheduler.advanceUntilIdle()

assertEquals(listOf(portal), opened)
}

@Test
fun `openPortal falls back to the portal URL after the 5 s timeout`() = runTest {
enablePortal()
coEvery { apiClient.requestPortalLink() } coAnswers {
kotlinx.coroutines.delay(30_000)
portalLink("$portal/auto?t=late")
}

viewModel.openPortal(context)
testDispatcher.scheduler.advanceTimeBy(4_900)
testDispatcher.scheduler.runCurrent()
assertTrue(opened.isEmpty())
testDispatcher.scheduler.advanceTimeBy(200)
testDispatcher.scheduler.runCurrent()

assertEquals(listOf(portal), opened)
}

@Test
fun `openPortal falls back to the portal URL on host mismatch`() = runTest {
enablePortal()
coEvery { apiClient.requestPortalLink() } returns portalLink("https://evil.example.com/auto?t=x")

viewModel.openPortal(context)
testDispatcher.scheduler.advanceUntilIdle()

assertEquals(listOf(portal), opened)
}

@Test
fun `openPortal falls back to the portal URL on network error`() = runTest {
enablePortal()
coEvery { apiClient.requestPortalLink() } throws java.io.IOException("offline")

viewModel.openPortal(context)
testDispatcher.scheduler.advanceUntilIdle()

assertEquals(listOf(portal), opened)
}

@Test
fun `openPortal ignores a second tap while the link is loading`() = runTest {
enablePortal()
coEvery { apiClient.requestPortalLink() } coAnswers {
kotlinx.coroutines.delay(1_000)
portalLink("$portal/auto?t=x")
}

viewModel.openPortal(context)
viewModel.openPortal(context)
testDispatcher.scheduler.advanceUntilIdle()

assertEquals(1, opened.size)
coVerify(exactly = 1) { apiClient.requestPortalLink() }
}

@Test
fun `auto-open calls the endpoint once per tunnel session`() = runTest {
enablePortal()
coEvery { apiClient.requestPortalLink() } returns portalLink("$portal/auto?t=x")
val session = TunnelState.Connected(connectedSince = 1_000L)

viewModel.autoOpenPortalIfNeeded(context, session)
testDispatcher.scheduler.advanceUntilIdle()
// Recomposition / re-foreground on the same session
viewModel.autoOpenPortalIfNeeded(context, session)
viewModel.autoOpenPortalIfNeeded(context, TunnelState.Connected(connectedSince = 1_000L))
testDispatcher.scheduler.advanceUntilIdle()

coVerify(exactly = 1) { apiClient.requestPortalLink() }
assertEquals(1, opened.size)

// A new connect is a new session
viewModel.autoOpenPortalIfNeeded(context, TunnelState.Connected(connectedSince = 2_000L))
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 2) { apiClient.requestPortalLink() }
}

@Test
fun `auto-open does nothing when disabled or not connected`() = runTest {
enablePortal(autoOpen = false)

viewModel.autoOpenPortalIfNeeded(context, TunnelState.Connected(connectedSince = 1_000L))
viewModel.autoOpenPortalIfNeeded(context, TunnelState.Disconnected)
testDispatcher.scheduler.advanceUntilIdle()

coVerify(exactly = 0) { apiClient.requestPortalLink() }
assertTrue(opened.isEmpty())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ interface ApiClient {
@Body body: RequestBody,
): SupportBundleUploadResponse

/**
* Mints a one-time automatic login link for the portal. Use
* [getPortalLink], which validates the answer and never throws.
*/
@POST("api/v1/client/portal-link")
suspend fun requestPortalLink(): Response<PortalLinkResponse>

@GET("api/v1/client/peer-info")
suspend fun getPeerInfo(@Query("peerId") peerId: Int): PeerInfoResponse

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,16 @@ data class PiholeBlockingRequest(
val enabled: Boolean,
val timer: Int? = null
)

/**
* One-time portal login link (`POST /client/portal-link`). [url] carries a
* single-use ticket: never log, store or report it.
*/
data class PortalLinkResponse(
val ok: Boolean = false,
@SerializedName("url") val url: String? = null,
@SerializedName("expiresIn") val expiresIn: Int? = null,
) {
// The ticket must not end up in a log line through an accidental toString().
override fun toString(): String = "PortalLinkResponse(ok=$ok, url=${if (url == null) "null" else "[REDACTED]"}, expiresIn=$expiresIn)"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.gatecontrol.android.network

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import timber.log.Timber

/** Upper bound for the portal-link request before falling back to the plain portal URL. */
const val PORTAL_LINK_TIMEOUT_MS = 5_000L

/**
* Fetches a fresh one-time automatic login link for the portal
* (`POST /api/v1/client/portal-link`).
*
* Returns the link, or null when the caller has to fall back to the plain
* [portalUrl]: non-2xx status, network error, no answer within [timeoutMs],
* missing `url`, or a `url` whose scheme or host differs from [portalUrl].
*
* The link carries a secret ticket: it is never logged, stored or cached —
* every call mints a new one. Log lines only name the failure class.
*/
suspend fun ApiClient.getPortalLink(
portalUrl: String,
timeoutMs: Long = PORTAL_LINK_TIMEOUT_MS,
): String? {
val response = try {
withTimeoutOrNull(timeoutMs) { requestPortalLink() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Class name only: exception messages may echo request/response details.
Timber.d("Portal link unavailable (${e.javaClass.simpleName}) — using portal URL")
return null
}
if (response == null) {
Timber.d("Portal link timed out after $timeoutMs ms — using portal URL")
return null
}
if (!response.isSuccessful) {
Timber.d("Portal link returned HTTP ${response.code()} — using portal URL")
response.errorBody()?.close()
return null
}
val link = response.body()?.url?.takeIf { it.isNotBlank() }
if (link == null) {
Timber.d("Portal link response has no url — using portal URL")
return null
}
if (!isSameOrigin(link, portalUrl)) {
Timber.w("Portal link points to a different scheme/host than the portal URL — ignored")
return null
}
return link
}

/** True when [link] has the same scheme and host as [portalUrl] (case-insensitive). */
internal fun isSameOrigin(link: String, portalUrl: String): Boolean {
val a = link.toHttpUrlOrNull() ?: return false
val b = portalUrl.toHttpUrlOrNull() ?: return false
// HttpUrl normalises scheme and host to lower case.
return a.scheme == b.scheme && a.host == b.host
}
Loading