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
24 changes: 18 additions & 6 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,24 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED" />
</intent-filter>
<meta-data
android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
<!--
NFC #7 fix (pre-demo 2026-06-03): the foreground TECH_DISCOVERED
intent-filter + @xml/nfc_tech_filter meta-data were REMOVED.

While the app is in the foreground we use enableReaderMode()
(MainActivity.onResume), which is the correct path for encrypted
MRTD/eID reads (FLAG_READER_SKIP_NDEF_CHECK). A foreground
TECH_DISCOVERED filter competes with reader mode: on some devices the
system tag-dispatcher wins, delivering the tag as a new Intent
(which only fires the OS chime/haptic) instead of routing it to the
reader callback — the exact "OS detects the card but the app stays
'ready to scan'" symptom. Reader mode already covers all the tag
technologies we read, so this filter is redundant.

NOTE: must still be verified on a physical device via `adb logcat`
(this host has no NFC hardware / emulator). Reversible: re-add the
<intent-filter> + <meta-data> block to restore foreground dispatch.
-->
</activity>
<!-- Firebase Cloud Messaging service for push notifications -->
<service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,17 @@ class AndroidNfcService(
* ([enableReaderMode]) so behaviour is identical regardless of detection mode.
*/
private fun processTag(tag: Tag) {
if (_scanState.value !is NfcScanState.WaitingForCard) return
// NFC #7 fix (pre-demo 2026-06-03): previously this dropped a tap unless
// the state was EXACTLY WaitingForCard. With reader mode always active in
// the foreground, a card tapped while the UI was Idle/Completed/Error was
// silently ignored (the "OS detects card but app stays idle" symptom). We
// now only reject a tap while a read is already IN FLIGHT (Reading), to
// avoid re-entrant/concurrent reads of the same tag; any other state
// accepts the tap and starts a fresh read.
//
// NOTE: still needs on-device `adb logcat` verification (no NFC hardware
// on the build host). Reversible: restore the WaitingForCard-only guard.
if (_scanState.value is NfcScanState.Reading) return

_scanState.value = NfcScanState.Reading()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ fun AppNavigation() {
onNavigateToRequestMembership = { navController.navigate(Screen.RequestMembership.route) },
onNavigateToCardScan = { navController.navigate(Screen.CardScan.route) },
onNavigateToNfcRead = { navController.navigate(Screen.NfcRead.route) },
onNavigateToApproveLogin = { navController.navigate(Screen.ApproveLogin.route) },
onNavigateBottom = { route ->
navController.navigate(route) {
launchSingleTop = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Contactless
import androidx.compose.material.icons.filled.CreditCard
// import androidx.compose.material.icons.filled.CreditCard // #8 fix: "Add card" QuickAction hidden (see below)
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Notifications
Expand Down Expand Up @@ -88,6 +88,7 @@ fun DashboardScreen(
onNavigateToRequestMembership: () -> Unit,
onNavigateToCardScan: () -> Unit,
onNavigateToNfcRead: () -> Unit = {},
onNavigateToApproveLogin: () -> Unit = {},
onNavigateBottom: (String) -> Unit,
sessionRepository: SessionRepository = koinInject(),
analyticsViewModel: AnalyticsViewModel = koinInject<AnalyticsViewModel>().disposeOnLeave()
Expand Down Expand Up @@ -128,20 +129,34 @@ fun DashboardScreen(
route = Screen.Profile.route,
anyPermissions = setOf(Permission.PROFILE_READ_SELF)
),
QuickAction(
id = "login-requests",
title = s(StringKey.DASH_LOGIN_REQUESTS),
icon = Icons.Default.CheckCircle,
route = Screen.ApproveLogin.route,
anyPermissions = setOf(Permission.PROFILE_READ_SELF)
),
QuickAction(
id = "request-membership",
title = s(StringKey.DASH_JOIN_TENANT),
icon = Icons.Default.PersonAdd,
route = Screen.RequestMembership.route,
anyPermissions = setOf(Permission.TENANT_MEMBERSHIP_REQUEST)
),
QuickAction(
id = "card-scan",
title = s(StringKey.DASH_ADD_CARD),
icon = Icons.Default.CreditCard,
route = Screen.CardScan.route,
anyPermissions = setOf(Permission.CARD_ADD_SELF)
),
// "Add card" (#8 fix, pre-demo 2026-06-03): HIDDEN. The CardScan flow is a
// camera photo wizard that captures front/back images but never uploads or
// persists them (CardScanScreen has no API/repository call) — a misleading
// dead-end for a demo. The screen + nav route are left intact (CardScanScreen,
// Screen.CardScan, onNavigateToCardScan) so this is fully reversible: restore
// the QuickAction below once the OCR/upload backend is wired.
//
// QuickAction(
// id = "card-scan",
// title = s(StringKey.DASH_ADD_CARD),
// icon = Icons.Default.CreditCard,
// route = Screen.CardScan.route,
// anyPermissions = setOf(Permission.CARD_ADD_SELF)
// ),
QuickAction(
id = "nfc-read",
title = s(StringKey.DASH_NFC_READER),
Expand All @@ -164,6 +179,7 @@ fun DashboardScreen(
Screen.RequestMembership.route -> onNavigateToRequestMembership()
Screen.CardScan.route -> onNavigateToCardScan()
Screen.NfcRead.route -> onNavigateToNfcRead()
Screen.ApproveLogin.route -> onNavigateToApproveLogin()
}
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -751,26 +751,42 @@ private fun VerifyAuthenticitySection(
}
}
is AuthenticityUiState.NotAuthentic -> {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
s(StringKey.NFC_AUTHENTICITY_NOT_AUTHENTIC),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
authState.reasonCode?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Known "the operator hasn't loaded the CSCA trust store yet" case —
// the server returns 422 with reasonCode=NO_TRUST_STORE (and errorCode
// NFC_PA_NOT_AUTHENTIC). This is NOT a forged chip; it just means the
// issuer's certificate isn't configured. Show a calm, non-scary message
// instead of a red "could not be confirmed" + a raw reason code. We do
// NOT claim the chip is authentic — we only stop alarming the user.
val isTrustStoreUnavailable = authState.reasonCode == "NO_TRUST_STORE" ||
authState.reasonCode == "NFC_PA_NOT_AUTHENTIC"
if (isTrustStoreUnavailable) {
Text(
s(StringKey.NFC_AUTHENTICITY_PA_UNAVAILABLE),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
s(StringKey.NFC_AUTHENTICITY_NOT_AUTHENTIC),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
authState.reasonCode?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package com.fivucsas.shared.data.local

import com.fivucsas.shared.domain.repository.AuthTokens
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlinx.datetime.Clock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull

/**
* Token Manager - Centralized token management
Expand Down Expand Up @@ -71,10 +78,59 @@ class TokenManager(
}

/**
* Check if user is authenticated
* Check if user is authenticated.
*
* Returns true when EITHER:
* - a non-expired access token is present, OR
* - the access token is missing/expired but a refresh token exists.
*
* The second case is what keeps a returning user signed in after the short
* (15-min) access-token lifetime: previously this checked token *presence*
* only, so the app routed to the dashboard and the first data call 401'd into
* a perceived logout; now the access token is allowed to be stale because the
* NetworkModule 401 handler transparently refreshes it and retries. We never
* grant access on a missing/expired access token WITHOUT a refresh token.
*/
fun isAuthenticated(): Boolean {
return getAccessToken() != null
val accessToken = getAccessToken() ?: return getRefreshToken() != null
if (!isAccessTokenExpired(accessToken)) return true
// Access token is expired — still authenticated if we can silently refresh.
return getRefreshToken() != null
}

/**
* True when the access token's `exp` claim is in the past (with a small skew
* margin). If the token can't be parsed as a JWT we treat it as NOT expired
* (fail-open) so opaque/unknown token formats keep working — the server's 401
* remains the ultimate authority and drives the refresh path.
*/
@OptIn(ExperimentalEncodingApi::class)
fun isAccessTokenExpired(token: String, skewSeconds: Long = 30): Boolean {
val expSeconds = jwtExpirySeconds(token) ?: return false
val nowSeconds = Clock.System.now().toEpochMilliseconds() / 1000
return nowSeconds >= (expSeconds - skewSeconds)
}

/**
* Decode a JWT's `exp` claim (epoch seconds) from its payload without
* verifying the signature — verification is the server's job; we only need
* the expiry to decide whether to pre-emptively refresh on startup. Returns
* null for non-JWT/unparseable tokens.
*/
@OptIn(ExperimentalEncodingApi::class)
private fun jwtExpirySeconds(token: String): Long? {
return try {
val parts = token.split(".")
if (parts.size < 2) return null
// JWT payloads are base64url, normally WITHOUT padding;
// ABSENT_OPTIONAL tolerates either padded or unpadded input.
val payloadBytes = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
.decode(parts[1])
val payload = Json.parseToJsonElement(payloadBytes.decodeToString()).jsonObject
payload["exp"]?.jsonPrimitive?.longOrNull
} catch (_: Exception) {
null
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import com.fivucsas.shared.data.remote.dto.ApproveLoginDecisionDto
import com.fivucsas.shared.data.remote.dto.PendingApproveLoginDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ResponseException
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess

class ApproveLoginApiImpl(
private val client: HttpClient
Expand All @@ -23,9 +26,21 @@ class ApproveLoginApiImpl(
}

override suspend fun decide(sessionId: String, decision: String, matchNumber: String?) {
client.post("$BASE_PATH/session/$sessionId/decide") {
// Mirrors the QR-approve fix: this call read no body, so a non-2xx
// (e.g. an expired bearer → 401/403) was silently swallowed — the phone
// showed the login as approved while the server session stayed PENDING
// and the web/originating login never advanced. Fail explicitly so the
// repository/ViewModel surface the real error (the 401 path also triggers
// a transparent refresh+retry in NetworkModule).
val response = client.post("$BASE_PATH/session/$sessionId/decide") {
contentType(ContentType.Application.Json)
setBody(ApproveLoginDecisionDto(decision = decision, matchNumber = matchNumber))
}
if (!response.status.isSuccess()) {
throw ResponseException(
response,
"Approve-login decision failed: ${response.status} ${response.bodyAsText()}",
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import com.fivucsas.shared.data.remote.dto.QrLoginCreateSessionRequestDto
import com.fivucsas.shared.data.remote.dto.QrLoginSessionResponseDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ResponseException
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess

class QrLoginApiImpl(
private val client: HttpClient
Expand All @@ -31,9 +34,26 @@ class QrLoginApiImpl(
}

override suspend fun approveSession(sessionId: String, request: QrLoginApproveRequestDto) {
client.post("$BASE_PATH/$sessionId/approve") {
// The web login poller only advances once the SERVER session flips to
// APPROVED. Unlike createSession/getSession (which call .body() and so
// throw on a non-2xx via the deserializer), this call read no body and
// silently swallowed a 401/403 (e.g. an expired bearer) — the phone UI
// flipped to "APPROVED" while the server stayed PENDING and the browser
// waited forever. Explicitly fail on a non-success status so the
// repository/ViewModel surface the real error (and the 401 path triggers
// a transparent refresh+retry in NetworkModule).
val response = client.post("$BASE_PATH/$sessionId/approve") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (!response.status.isSuccess()) {
// Include the numeric status in the message so the shared ErrorMapper
// (which matches on "401"/"403"/… substrings) maps it to a useful
// user message instead of a silent false "approved".
throw ResponseException(
response,
"QR approve failed: ${response.status} ${response.bodyAsText()}",
)
}
}
}
Loading
Loading