Skip to content

Commit db7e1a9

Browse files
committed
Support updating user-supplied VICAL and RICAL trust entries.
Introduce extension functions `updateTrustEntry()` and `updateEntries()` on `TrustManager` in `shared/` to check, download, and apply updates for user-supplied VICAL and RICAL entries that provide an update URL. When an update is fetched, signatures are verified and issue IDs are compared to ensure only newer lists are applied. Enhance `WalletClient.runPeriodicBookkeeping()` to accept a list of `TrustManager` instances and update all eligible trust entries during periodic background maintenance. In `TrustEntryScreen`, display a refresh button for user-supplied VICAL and RICAL entries with an update URL. When triggered, show a progress indicator and display the result in an info or error dialog, indicating whether the list was already up to date, successfully updated with its issue number, or failed with an error. Update `App` to expose user trust managers, and pass them to periodic bookkeeping in `PeriodicBookkeepingWorker` and `MainGraph`. Add unit tests in `TrustManagerExtTest` verifying VICAL and RICAL update scenarios against mock HTTP responses. Update `CODING-STYLE.md` to document exception logging requirements. Test: Executed `./gradlew :shared:allTests :androidApp:assembleDebug :androidApp:testDebugUnitTest` successfully. Signed-off-by: David Zeuthen <zeuthen@gmail.com>
1 parent 830bbac commit db7e1a9

10 files changed

Lines changed: 616 additions & 6 deletions

File tree

CODING-STYLE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,6 @@ with the following changes
6060
* **Document exceptions in KDoc:** Every function or method must explicitly document the
6161
exceptions it throws using the `@throws` (or `@exception`) tag in its KDoc. This ensures that
6262
readers of the code know exactly what edge cases they are expected to handle.
63+
64+
* **Log exceptions:** When catching an exception, always log it using `Logger.e()` or `Logger.w()`
65+
depending on what is appropriate.

androidApp/src/main/java/org/multipaz/wallet/android/App.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,11 @@ class App private constructor() {
128128
lateinit var walletClient: WalletClient
129129
private set
130130

131-
private lateinit var userIssuerTrustManager: TrustManager
131+
lateinit var userIssuerTrustManager: TrustManager
132+
private set
132133
private lateinit var issuerTrustManager: CompositeTrustManager
133-
private lateinit var userReaderTrustManager: TrustManager
134+
lateinit var userReaderTrustManager: TrustManager
135+
private set
134136
private lateinit var readerTrustManager: CompositeTrustManager
135137
private lateinit var userIssuerTrustManagerModel: TrustManagerModel
136138
private lateinit var backendIssuerTrustManagerModel: TrustManagerModel

androidApp/src/main/java/org/multipaz/wallet/android/navigation/MainGraph.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import org.multipaz.provisioning.ProvisioningModel
5252
import org.multipaz.securearea.SecureArea
5353
import org.multipaz.storage.Storage
5454
import org.multipaz.trustmanagement.CompositeTrustManager
55+
import org.multipaz.trustmanagement.TrustManager
5556
import org.multipaz.util.Logger
5657
import org.multipaz.util.fromBase64Url
5758
import org.multipaz.wallet.android.R
@@ -1046,6 +1047,10 @@ fun mainGraph(
10461047
val success = walletClient.runPeriodicBookkeeping(
10471048
documentStore = documentStore,
10481049
provisioningModel = provisioningModel,
1050+
trustManagers = listOfNotNull(
1051+
userIssuerTrustManagerModel.trustManager as? TrustManager,
1052+
userReaderTrustManagerModel.trustManager as? TrustManager
1053+
),
10491054
eventLogger = eventLogger
10501055
)
10511056
val msg = if (success) {
@@ -1325,6 +1330,22 @@ fun mainGraph(
13251330
)
13261331
)
13271332
},
1333+
onShowInfoDialog = { title, textMarkdown ->
1334+
backStack.add(
1335+
InfoDialogDestination(
1336+
title = title,
1337+
textMarkdown = textMarkdown
1338+
)
1339+
)
1340+
},
1341+
onShowErrorDialog = { title, textMarkdown ->
1342+
backStack.add(
1343+
ErrorDialogDestination(
1344+
title = title,
1345+
textMarkdown = textMarkdown
1346+
)
1347+
)
1348+
},
13281349
onBackClicked = { backStack.removeAt(backStack.size - 1) },
13291350
showToast = showToast,
13301351
)

androidApp/src/main/java/org/multipaz/wallet/android/ui/settings/TrustEntryScreen.kt

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ package org.multipaz.wallet.android.ui.settings
33
import androidx.compose.foundation.layout.Column
44
import androidx.compose.foundation.layout.fillMaxSize
55
import androidx.compose.foundation.layout.padding
6+
import androidx.compose.foundation.layout.size
67
import androidx.compose.foundation.rememberScrollState
78
import androidx.compose.foundation.verticalScroll
89
import androidx.compose.material.icons.Icons
910
import androidx.compose.material.icons.automirrored.filled.ArrowBack
10-
import androidx.compose.material.icons.outlined.Delete
1111
import androidx.compose.material.icons.filled.Edit
12+
import androidx.compose.material.icons.outlined.Delete
13+
import androidx.compose.material.icons.outlined.Refresh
1214
import androidx.compose.material3.AlertDialog
15+
import androidx.compose.material3.CircularProgressIndicator
1316
import androidx.compose.material3.ExperimentalMaterial3Api
1417
import androidx.compose.material3.Icon
1518
import androidx.compose.material3.IconButton
@@ -28,19 +31,26 @@ import androidx.compose.runtime.rememberCoroutineScope
2831
import androidx.compose.runtime.setValue
2932
import androidx.compose.ui.Modifier
3033
import androidx.compose.ui.input.nestedscroll.nestedScroll
34+
import androidx.compose.ui.platform.LocalContext
3135
import androidx.compose.ui.res.stringResource
3236
import androidx.compose.ui.unit.dp
3337
import coil3.ImageLoader
38+
import kotlinx.coroutines.CancellationException
3439
import kotlinx.coroutines.launch
3540
import org.jetbrains.compose.resources.ExperimentalResourceApi
36-
import org.multipaz.wallet.android.ui.trustmanagement.TrustEntryViewer
3741
import org.multipaz.compose.trustmanagement.TrustManagerModel
3842
import org.multipaz.crypto.X509CertChain
3943
import org.multipaz.trustmanagement.TrustEntryRical
4044
import org.multipaz.trustmanagement.TrustEntryVical
4145
import org.multipaz.trustmanagement.TrustEntryX509Cert
4246
import org.multipaz.trustmanagement.TrustManager
47+
import org.multipaz.util.Logger
4348
import org.multipaz.wallet.android.R
49+
import org.multipaz.wallet.android.ui.trustmanagement.TrustEntryViewer
50+
import org.multipaz.wallet.client.TrustEntryUpdateResult
51+
import org.multipaz.wallet.client.updateTrustEntry
52+
53+
private const val TAG = "TrustEntryScreen"
4454

4555
@OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class)
4656
@Composable
@@ -53,17 +63,27 @@ fun TrustEntryScreen(
5363
onViewVicalEntry: (vicalCertNum: Int) -> Unit,
5464
onViewRicalEntry: (ricalCertNum: Int) -> Unit,
5565
onEditClicked: () -> Unit,
66+
onShowInfoDialog: (title: String, textMarkdown: String) -> Unit,
67+
onShowErrorDialog: (title: String, textMarkdown: String) -> Unit,
5668
onBackClicked: () -> Unit,
5769
showToast: (message: String) -> Unit,
5870
) {
71+
val context = LocalContext.current
5972
val coroutineScope = rememberCoroutineScope()
6073
val scrollState = rememberScrollState()
6174
var showDeleteConfirmationDialog by remember { mutableStateOf(false) }
75+
var isCheckingForUpdate by remember { mutableStateOf(false) }
6276

6377
val info = trustManagerModel.trustManagerInfos.collectAsState().value?.find {
6478
it.entry.identifier == trustEntryId
6579
} ?: return
6680

81+
val updateUrl = when (info.entry) {
82+
is TrustEntryVical -> info.signedVical?.vical?.vicalUrl
83+
is TrustEntryRical -> info.signedRical?.rical?.latestRicalUrl
84+
else -> null
85+
}
86+
6787
if (showDeleteConfirmationDialog) {
6888
AlertDialog(
6989
onDismissRequest = { showDeleteConfirmationDialog = false },
@@ -134,6 +154,70 @@ fun TrustEntryScreen(
134154
},
135155
actions = {
136156
if (trustManagerModel.trustManager is TrustManager) {
157+
if (!updateUrl.isNullOrBlank()) {
158+
IconButton(
159+
onClick = {
160+
coroutineScope.launch {
161+
val trustManager = trustManagerModel.trustManager as? TrustManager ?: return@launch
162+
isCheckingForUpdate = true
163+
try {
164+
when (val result = trustManager.updateTrustEntry(entry = info.entry)) {
165+
is TrustEntryUpdateResult.AlreadyUpToDate -> {
166+
onShowInfoDialog(
167+
context.getString(R.string.trust_entry_update_already_latest_title),
168+
context.getString(R.string.trust_entry_update_already_latest_text)
169+
)
170+
}
171+
is TrustEntryUpdateResult.Updated -> {
172+
val msg = if (result.issueId != null) {
173+
context.getString(
174+
R.string.trust_entry_update_success_text_with_issue,
175+
result.listType,
176+
result.issueId
177+
)
178+
} else {
179+
context.getString(
180+
R.string.trust_entry_update_success_text,
181+
result.listType
182+
)
183+
}
184+
onShowInfoDialog(
185+
context.getString(R.string.trust_entry_update_success_title),
186+
msg
187+
)
188+
}
189+
is TrustEntryUpdateResult.NoUpdateUrl -> {}
190+
}
191+
} catch (e: Exception) {
192+
if (e is CancellationException) throw e
193+
Logger.w(TAG, "Error checking for update from $updateUrl", e)
194+
onShowErrorDialog(
195+
context.getString(R.string.trust_entry_update_failed_title),
196+
context.getString(
197+
R.string.trust_entry_update_failed_text,
198+
e.message ?: e.toString()
199+
)
200+
)
201+
} finally {
202+
isCheckingForUpdate = false
203+
}
204+
}
205+
},
206+
enabled = !isCheckingForUpdate
207+
) {
208+
if (isCheckingForUpdate) {
209+
CircularProgressIndicator(
210+
modifier = Modifier.size(24.dp),
211+
strokeWidth = 2.dp
212+
)
213+
} else {
214+
Icon(
215+
imageVector = Icons.Outlined.Refresh,
216+
contentDescription = stringResource(R.string.trust_entry_check_for_update)
217+
)
218+
}
219+
}
220+
}
137221
IconButton(
138222
onClick = { onEditClicked() }
139223
) {

androidApp/src/main/java/org/multipaz/wallet/android/worker/PeriodicBookkeepingWorker.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class PeriodicBookkeepingWorker(
1616
val success = app.walletClient.runPeriodicBookkeeping(
1717
documentStore = app.documentStore,
1818
provisioningModel = app.provisioningModel,
19+
trustManagers = listOf(app.userIssuerTrustManager, app.userReaderTrustManager),
1920
eventLogger = app.eventLogger
2021
)
2122
return if (success) Result.success() else Result.retry()

androidApp/src/main/res/values/strings.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,14 @@
290290
<string name="trust_entry_edit_discard_text">You have unsaved changes that will be lost if you leave this page.</string>
291291
<string name="trust_entry_edit_title">Edit entry</string>
292292
<string name="trust_entry_edit_back">Back</string>
293+
<string name="trust_entry_check_for_update">Check for update</string>
294+
<string name="trust_entry_update_already_latest_title">Already Up to Date</string>
295+
<string name="trust_entry_update_already_latest_text">We already have the latest list.</string>
296+
<string name="trust_entry_update_failed_title">Update Failed</string>
297+
<string name="trust_entry_update_failed_text">An update was downloaded but something was wrong with it:\n\n%1$s</string>
298+
<string name="trust_entry_update_success_title">Trust List Updated</string>
299+
<string name="trust_entry_update_success_text">The %1$s was successfully updated.</string>
300+
<string name="trust_entry_update_success_text_with_issue">The %1$s was updated to issue #%2$d.</string>
293301

294302
<string name="app_navigation_error_importing_pass_title">Error importing pass</string>
295303
<string name="app_navigation_error_importing_pass_already_in_wallet">This pass is already in your wallet</string>

shared/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ kotlin {
8585
val xcf = XCFramework()
8686
val iosTargets = listOf(iosX64(), iosArm64(), iosSimulatorArm64())
8787
iosTargets.forEach {
88+
it.binaries.withType(org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable::class.java) {
89+
linkerOpts("-lsqlite3", "-Wl,-rpath,/usr/lib/swift")
90+
}
8891
it.binaries.framework {
8992
export(libs.multipaz)
9093
export(libs.multipaz.dcapi)

shared/src/commonMain/kotlin/org/multipaz/wallet/client/PeriodicBookkeeping.kt

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package org.multipaz.wallet.client
22

3+
import io.ktor.client.HttpClient
34
import kotlinx.coroutines.CancellationException
45
import kotlinx.io.bytestring.ByteString
56
import org.multipaz.document.Document
67
import org.multipaz.document.DocumentStore
78
import org.multipaz.eventlogger.EventLogger
89
import org.multipaz.eventlogger.EventSimple
910
import org.multipaz.provisioning.ProvisioningModel
11+
import org.multipaz.trustmanagement.TrustManager
1012
import org.multipaz.util.Logger
1113
import org.multipaz.wallet.shared.Domains
1214
import org.multipaz.wallet.shared.WalletBackendNotSignedInException
@@ -20,16 +22,21 @@ private const val TAG = "PeriodicBookkeeping"
2022
* 2. Refreshing shared data and syncing document store
2123
* 3. Refreshing document credentials for all provisioned documents
2224
* 4. Refreshing reader keys
23-
* 5. Logging a [EventSimple] with [PeriodicBookkeepingEventDetails] stored in [EventSimple.appData] via [eventLogger], if provided.
25+
* 5. Refreshing RICAL and VICAL entries in provided [trustManagers]
26+
* 6. Logging a [EventSimple] with [PeriodicBookkeepingEventDetails] stored in [EventSimple.appData] via [eventLogger], if provided.
2427
*
2528
* @param documentStore the [DocumentStore] containing provisioned documents.
2629
* @param provisioningModel the [ProvisioningModel] used to refresh OpenID4VCI credentials.
30+
* @param trustManagers list of [TrustManager] instances whose VICAL/RICAL entries should be checked for updates.
31+
* @param httpClient optional [HttpClient] used to fetch trust list updates over HTTP/HTTPS.
2732
* @param eventLogger optional [EventLogger] to record the bookkeeping event.
2833
* @return `true` if all tasks completed without error, or `false` if one or more tasks failed/were unreachable.
2934
*/
3035
suspend fun WalletClient.runPeriodicBookkeeping(
3136
documentStore: DocumentStore,
3237
provisioningModel: ProvisioningModel,
38+
trustManagers: List<TrustManager> = emptyList(),
39+
httpClient: HttpClient = HttpClient(),
3340
eventLogger: EventLogger? = null,
3441
): Boolean {
3542
Logger.i(TAG, "Starting periodic bookkeeping...")
@@ -128,11 +135,28 @@ suspend fun WalletClient.runPeriodicBookkeeping(
128135
Logger.w(TAG, "Failed refreshing reader keys", e)
129136
}
130137

138+
// 5. Refresh RICAL and VICAL entries in trust managers
139+
for (trustManager in trustManagers) {
140+
try {
141+
Logger.i(TAG, "Refreshing trust entries in '${trustManager.identifier}'...")
142+
val updatedCount = trustManager.updateEntries(httpClient = httpClient)
143+
if (updatedCount > 0) {
144+
Logger.i(TAG, "Updated $updatedCount trust entries in '${trustManager.identifier}'")
145+
} else {
146+
Logger.i(TAG, "Trust entries in '${trustManager.identifier}' are already up to date")
147+
}
148+
} catch (e: Exception) {
149+
if (e is CancellationException) throw e
150+
Logger.w(TAG, "Failed refreshing trust entries in '${trustManager.identifier}'", e)
151+
success = false
152+
}
153+
}
154+
131155
val endTime = Clock.System.now()
132156
val runtimeDuration = endTime - startTime
133157
val runtimeDurationMs = runtimeDuration.inWholeMilliseconds
134158

135-
// 5. Log EventSimple containing PeriodicBookkeepingEventDetails in appData
159+
// 6. Log EventSimple containing PeriodicBookkeepingEventDetails in appData
136160
if (eventLogger != null) {
137161
try {
138162
val details = PeriodicBookkeepingEventDetails(

0 commit comments

Comments
 (0)