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: 12 additions & 3 deletions ADAPTER_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,18 @@ retrying a connection, and completing sign-in.
- Cancellation releases challenge bookkeeping and rejects late results before
they can persist credentials. The existing authenticated-session transition
still owns the final credential commit.
- Only a classified DNS failure before an HTTP exchange may retry
automatically. An ambiguous response after the one-time exchange requires a
new sign-in attempt.
- Only a classified failure while the connection is being established, before
an HTTP exchange starts, may retry automatically: unresolved DNS, a refused
or timed-out connection, an unreachable network, a TLS timeout, or a socket
failure during DNS lookup or connect. Certificate, TLS handshake, protocol,
and unclassified failures end the attempt.
- A failure after the one-time exchange starts, or an invalid one-time approval
response, is ambiguous. It is never retried and requires a new sign-in
attempt.
- Each poll uses its own HTTP client with OkHttp connection-failure retry
disabled and a connection pool that keeps no idle connection, so a poll never
reuses a connection from an earlier poll or attempt. Cancelling the attempt
cancels the poll's calls, including one that has not been sent yet.
- Retry copy describes the last observed failure. It cannot report the current
health of DNS, the server, or a VPN.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package dev.obiente.nextcloudnative

import androidx.test.ext.junit.runners.AndroidJUnit4
import dev.obiente.nextcloudnative.app.JvmNetworkFailureDiagnostic
import dev.obiente.nextcloudnative.app.JvmNetworkRequestAttempt
import dev.obiente.nextcloudnative.app.LoginChallenge
import dev.obiente.nextcloudnative.app.LoginPollHttpResponse
import dev.obiente.nextcloudnative.app.LoginPollResult
import dev.obiente.nextcloudnative.app.executeLoginPollHttp
import dev.obiente.nextcloudnative.app.toJvmNetworkFailureDiagnostic
import dev.obiente.nextcloudnative.app.trackJvmNetworkFailures
import dev.obiente.nextcloudnative.app.withLoginPollHttpClient
import java.io.IOException
import java.net.ServerSocket
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import mockwebserver3.Dispatcher
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import mockwebserver3.RecordedRequest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.tls.HandshakeCertificates
import okhttp3.tls.HeldCertificate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* Runs Login Flow v2 poll isolation and cancellation on the Android runtime and its TLS stack.
*
* All servers are local synthetic fixtures; no request leaves the device.
*/
@RunWith(AndroidJUnit4::class)
class AndroidLoginPollTransportInstrumentedTest {
private val certificate = HeldCertificate.Builder()
.commonName("localhost")
.addSubjectAlternativeName("localhost")
.build()

@Test
fun everyPollUsesANewTlsConnectionUntilApproval() = runBlocking {
val connections = CopyOnWriteArrayList<Int>()
withServer(
dispatcher = { request, server ->
connections += request.connectionIndex
if (connections.size < 3) pending() else server.approved()
},
) { server, base ->
val challenge = server.challenge()
val results = List(3) {
withLoginPollHttpClient(base) { client -> poll(client, challenge) }
}

assertEquals(LoginPollResult.Pending, results[0])
assertEquals(LoginPollResult.Pending, results[1])
assertTrue(results[2] is LoginPollResult.Approved)
assertEquals(3, connections.distinct().size)
}
}

@Test
fun cancellingTheAttemptCancelsAPollHeldByTheServer() = runBlocking {
val arrived = CountDownLatch(1)
val release = CountDownLatch(1)
withServer(
dispatcher = { _, _ ->
arrived.countDown()
release.await(30, TimeUnit.SECONDS)
pending()
},
onClose = release::countDown,
) { server, base ->
val attempt = AtomicReference<JvmNetworkRequestAttempt?>(null)
var completed = false
val job = launch(Dispatchers.IO) {
withLoginPollHttpClient(base) { client ->
poll(client, server.challenge(), onAttempt = attempt::set)
}
completed = true
}
assertTrue(arrived.await(10, TimeUnit.SECONDS))

job.cancel()
assertTrue(requireNotNull(attempt.get()).cancelled)
withTimeout(5_000) { job.join() }

assertTrue(job.isCancelled)
assertFalse(completed)
}
}

@Test
fun refusedConnectionBeforeTheExchangeIsRetryable() = runBlocking {
val closedPort = ServerSocket(0).use { it.localPort }
val pollEndpoint = "https://localhost:$closedPort/login/v2/poll"
val challenge = LoginChallenge(
enteredServerUrl = "https://localhost:$closedPort",
pollEndpoint = pollEndpoint,
pollFallbackEndpoint = null,
token = "synthetic-one-time-token",
loginUrl = "https://localhost:$closedPort/login/v2/flow/synthetic",
)

val result = withLoginPollHttpClient(trustingClient()) { client -> poll(client, challenge) }

assertTrue(result is LoginPollResult.RetryablePreExchangeFailure)
assertEquals("NETWORK_CONNECT_FAILED", (result as LoginPollResult.RetryablePreExchangeFailure).code)
}

private suspend fun withServer(
dispatcher: (RecordedRequest, MockWebServer) -> MockResponse,
onClose: () -> Unit = {},
test: suspend (MockWebServer, OkHttpClient) -> Unit,
) {
MockWebServer().use { server ->
server.useHttps(
HandshakeCertificates.Builder().heldCertificate(certificate).build().sslSocketFactory(),
)
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse = dispatcher(request, server)
}
server.start()
try {
test(server, trustingClient())
} finally {
onClose()
}
}
}

private fun trustingClient(): OkHttpClient {
val trusted = HandshakeCertificates.Builder()
.addTrustedCertificate(certificate.certificate)
.build()
return OkHttpClient.Builder()
.sslSocketFactory(trusted.sslSocketFactory(), trusted.trustManager)
.trackJvmNetworkFailures()
.build()
}

private suspend fun poll(
client: OkHttpClient,
challenge: LoginChallenge,
onAttempt: (JvmNetworkRequestAttempt) -> Unit = {},
): LoginPollResult {
var networkFailure: JvmNetworkFailureDiagnostic? = null
return executeLoginPollHttp(
challenge = challenge,
fallbackAlreadySelected = false,
poll = { endpoint ->
networkFailure = null
val attempt = JvmNetworkRequestAttempt().also(onAttempt)
val request = Request.Builder()
.url(endpoint)
.post("token=synthetic-one-time-token".toRequestBody(FORM))
.tag(JvmNetworkRequestAttempt::class.java, attempt)
.build()
try {
client.newCall(request).execute().use { response ->
LoginPollHttpResponse(response.code, response.body.string())
}
} catch (failure: IOException) {
networkFailure = failure.toJvmNetworkFailureDiagnostic(
attempt = attempt,
readOnlyRequest = false,
replayableRequest = false,
)
throw failure
}
},
networkFailure = { networkFailure },
).interpretation.result
}

private fun MockWebServer.challenge(): LoginChallenge {
val origin = url("/").toString().trimEnd('/')
return LoginChallenge(
enteredServerUrl = origin,
pollEndpoint = "$origin/login/v2/poll",
pollFallbackEndpoint = null,
token = "synthetic-one-time-token",
loginUrl = "$origin/login/v2/flow/synthetic",
)
}

private fun pending() = MockResponse.Builder().code(404).body("[]").build()

private fun MockWebServer.approved() = MockResponse.Builder()
.code(200)
.body(
"""{"server":"${url("/").toString().trimEnd('/')}","loginName":"person",""" +
""""appPassword":"synthetic-app-password"}""",
)
.build()

private companion object {
val FORM = "application/x-www-form-urlencoded".toMediaType()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import dev.obiente.nextcloudnative.app.LoginTransportSecurity
import dev.obiente.nextcloudnative.app.LOGIN_FLOW_RESPONSE_MAX_BYTES
import dev.obiente.nextcloudnative.app.LoginPollHttpResponse
import dev.obiente.nextcloudnative.app.executeLoginPollHttp
import dev.obiente.nextcloudnative.app.withLoginPollHttpClient
import dev.obiente.nextcloudnative.app.interpretLoginChallengeHttpResponse
import dev.obiente.nextcloudnative.app.loginPollEndpointFallbackDiagnostic
import dev.obiente.nextcloudnative.app.normalizeServerUrl
Expand Down Expand Up @@ -347,7 +348,6 @@ internal class AndroidNextcloudServices(
.useAndroidNextcloudCertificateTrust(appContext)
.trackJvmNetworkFailures()
.build()
private val loginPollHttpClient = httpClient.newBuilder().retryOnConnectionFailure(false).build()
private val loginPollFallbackTokens = ConcurrentHashMap.newKeySet<String>()
private val loginPollPendingTokens = ConcurrentHashMap.newKeySet<String>()
private val noRedirectHttpClient = httpClient.newBuilder()
Expand Down Expand Up @@ -1202,7 +1202,7 @@ internal class AndroidNextcloudServices(
override suspend fun pollLogin(challenge: LoginChallenge): LoginPollResult = withContext(Dispatchers.IO) {
val formBody = "token=" + URLEncoder.encode(challenge.token, StandardCharsets.UTF_8.name())
var networkFailure: JvmNetworkFailureDiagnostic? = null
val execution = executeLoginPollHttp(
val execution = withLoginPollHttpClient(httpClient) { loginPollHttpClient -> executeLoginPollHttp(
challenge = challenge,
fallbackAlreadySelected = challenge.token in loginPollFallbackTokens,
poll = { endpoint ->
Expand All @@ -1219,7 +1219,7 @@ internal class AndroidNextcloudServices(
).let { LoginPollHttpResponse(it.status, it.text) }
},
networkFailure = { networkFailure },
)
) }
execution.selectedFallbackReason?.let { reason ->
loginPollFallbackTokens += challenge.token
runCatching {
Expand Down Expand Up @@ -3730,9 +3730,6 @@ internal class AndroidNextcloudServices(
LoginTransportSecurity.Tls
}

private val LoginTransportSecurity.diagnosticValue: String
get() = if (this == LoginTransportSecurity.PlainHttp) "plaintext" else "tls"

private fun JSONArray.toAppEntries(): List<NextcloudAppEntry> = buildList {
for (index in 0 until length()) {
val item = optJSONObject(index) ?: continue
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/488-login-poll-fresh-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: 488
pull: none
platforms: android, desktop
user-facing: yes

Keep browser sign-in working after returning to the app during approval: each check uses a new connection, and connection failures before a request is sent are retried. Cancelling sign-in stops a check in flight.
39 changes: 30 additions & 9 deletions docs/login-diagnostics.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sign-in recovery and login diagnostics

**Last reviewed: 2026-09-28.** This describes repository implementation, which may
**Last reviewed: 2026-10-08.** This describes repository implementation, which may
have changed. Check the [current source](https://github.com/obiente/native/blob/main/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/LoginAttemptState.kt)
and the release notes for the installed build before assuming a released version
includes these controls.
Expand All @@ -13,8 +13,9 @@ setup.

While the app waits for browser approval or retries the connection,
**Cancel sign-in** stops the attempt and lets you edit the server address
again. On Android, the system Back action does the same. Cancelling does not
revoke credentials the server has already issued.
again. On Android, the system Back action does the same. Cancelling also stops
an approval check that is still waiting for the server. It does not revoke
credentials the server has already issued.

The form scrolls when a short window, landscape layout or larger text makes it
taller than the screen. In landscape it keeps a bounded width. If the server
Expand All @@ -24,20 +25,32 @@ the URL parser's error text.
## Connection problems

A name-resolution message means a DNS lookup failed before any request reached
the server. It does not prove the server is down, or that the failure is still
happening. Check the address and the device's network or VPN connection.
the server. A "could not be reached" message means the app could not open a
connection to the server, so no sign-in request was sent. Neither proves the
server is down, or that the failure is still happening. Check the address and
the device's network or VPN connection.

The app retries this kind of failure after a bounded delay. When the server
The app retries both kinds of failure after a bounded delay. When the server
answers again, the browser-approval status returns. You can also cancel, change
the address or connection, and start a fresh attempt.

Each approval check opens a new connection and closes it after the response.
Leaving the app for the browser can suspend it or end its network connections.
Because no connection is kept between checks, returning to the app does not
reuse one that ended while the app was in the background. A new sign-in attempt
also never shares a connection with a check from an earlier attempt.

## Time limit and uncertain results

Each attempt has a five-minute deadline, including network waits.

If the result became uncertain after the one-time approval exchange, start a
new sign-in. Do not try to replay that exchange. The app does not retry this
case automatically.
case automatically. The result is uncertain when the connection failed after
the app had started sending a request, or when the server reported an approval
but its one-time response was malformed, incomplete, or contained invalid
account values. A connection failure before the request was sent is retried
instead.

## Save a login report

Expand Down Expand Up @@ -85,8 +98,16 @@ Account publication failures retain the existing credential rollback policy.

Synthetic regression tests in `LoginPollingTest` and `LoginAttemptStateTest`
exercise retry phases, timeout, cancellation, browser-handoff failure, and late
completion after a replacement attempt. They do not establish connectivity to
any particular server or device network.
completion after a replacement attempt. `JvmLoginPollConnectionTest` runs
approval checks against a local mock server while simulating connections that
end during a suspension. `JvmLoginPollIsolationTest` holds a check open on an
HTTP/2 mock server to cover cancellation and a replacement check. These tests
run on the desktop JVM. `AndroidLoginPollTransportInstrumentedTest` repeats the
new-connection, cancellation and refused-connection checks against a local TLS
mock server on the Android runtime. It runs only through the instrumented
emulator workflow in [CONTRIBUTING.md](../CONTRIBUTING.md), not in pull request
CI. None of these tests establish connectivity to any particular server or
device network, and they do not reproduce Android process freezing.

## Evidence and release status

Expand Down
4 changes: 2 additions & 2 deletions tools/kotlin-file-size-baseline.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4132
androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4129
androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985
androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900
contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1212
Expand Down Expand Up @@ -48,7 +48,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAppUpdates.kt|9
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt|1536
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|884
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|857
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6169
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6168
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1069
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ internal enum class LoginAttemptPhase(val message: String, val buttonLabel: Stri
"The server name could not be resolved. Check your network or VPN. Retrying...",
"Retrying connection...",
),
ReconnectingToServer(
"The server could not be reached. Check your network or VPN. Retrying...",
"Retrying connection...",
),
Completing("Finishing sign-in...", "Finishing sign-in..."),
}

Expand Down
Loading
Loading