diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ae1a1fd..ddf87882d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * Performance: Request a status list once for credentials sharing it that are checked at the same time, and no longer let a status list being requested hold up cached status lists and other ones, which were all checked one after another * Performance: Do not request a status list, Trust List, or type metadata again for 5 minutes after a request without a response, e.g. offline or timed out, or with an invalid document, instead of waiting for it to fail on every check; give up connecting after 10 seconds and on a stalled response after 15 seconds instead of the platform's default. Issuing still checks the status of a received credential with a fresh status list, and “Refresh trust lists” still loads every list * Performance: Cache the HTTP response of each status list and Trust List on its own, so that revalidating one no longer decodes and encodes every cached response, e.g. all Trust Lists; the previous cache is dropped once + * Issuing: Show every issued credential on the result screen, also when the list of credentials has not yet been loaded again, which on a busy device left out all but the first one # Release 5.9.1: diff --git a/shared/src/commonMain/kotlin/at/asitplus/wallet/app/common/ProvisioningService.kt b/shared/src/commonMain/kotlin/at/asitplus/wallet/app/common/ProvisioningService.kt index f17153c3f..ccb59c020 100644 --- a/shared/src/commonMain/kotlin/at/asitplus/wallet/app/common/ProvisioningService.kt +++ b/shared/src/commonMain/kotlin/at/asitplus/wallet/app/common/ProvisioningService.kt @@ -338,7 +338,7 @@ class ProvisioningService( storeCredential(credential, issued.scheme, issued.refreshToken, underReview) } val needsReview = storageResults.any { it.getOrNull() == null } - val storedEntryIds = resolveNewStoreEntryIds(idsBefore, storageResults.count { it.getOrNull() != null }) + val storedEntryIds = storedEntryIds(idsBefore, storageResults.mapNotNull { it.getOrNull() }) Napier.d("resumeWithAuthCode resolved storeEntryIds=$storedEntryIds") if (storageResults.all { it.isSuccess }) { @@ -514,7 +514,7 @@ class ProvisioningService( storeCredential(credential, issued.scheme, underReview = underReview).getOrThrow() } if (underReview.isNotEmpty()) pendingCredentialReview.replace(underReview) - val storedEntryIds = resolveNewStoreEntryIds(idsBefore, storedEntries.count { it != null }) + val storedEntryIds = storedEntryIds(idsBefore, storedEntries.filterNotNull()) Napier.d("loadCredentialWithOffer resolved storeEntryIds=$storedEntryIds") StoredCredentialIssuanceResult( credentialIssuanceResult = result, @@ -534,8 +534,8 @@ class ProvisioningService( suspend fun keepCredentialsUnderReview(): List { val idsBefore = subjectCredentialStore.observeStoreContainer().first().credentials.map { it.first }.toSet() val entries = pendingCredentialReview.take() - entries.forEach { storeAfterUserConsent(it.prepared) } - return resolveNewStoreEntryIds(idsBefore, entries.size) + val stored = entries.map { storeAfterUserConsent(it.prepared) } + return storedEntryIds(idsBefore, stored) } /** Drops every credential under review, as the user decided not to keep it. */ @@ -598,6 +598,18 @@ class ProvisioningService( * Waits for the store to emit a container with all [count] entries stored since [idsBefore], as the session's hot * store may still show only some of them, returning those found so far on a timeout. */ + /** + * The IDs of [stored], as the store assigned them. The session's hot store shows new entries only once it decoded + * the whole container again, which took longer than [resolveNewStoreEntryIds] waits on a busy device, so the + * result screen showed only some of the issued credentials. + */ + private suspend fun storedEntryIds( + idsBefore: Set, + stored: List, + ): List = stored.map { subjectCredentialStore.storeEntryIdOf(it) } + .takeIf { ids -> ids.none { it == null } }?.filterNotNull() + ?: resolveNewStoreEntryIds(idsBefore, stored.size) + private suspend fun resolveNewStoreEntryIds(idsBefore: Set, count: Int): List { if (count == 0) return emptyList() var newIds = emptyList() diff --git a/shared/src/commonMain/kotlin/data/storage/HotWalletSubjectCredentialStore.kt b/shared/src/commonMain/kotlin/data/storage/HotWalletSubjectCredentialStore.kt index 539c2d6e0..c146a133d 100644 --- a/shared/src/commonMain/kotlin/data/storage/HotWalletSubjectCredentialStore.kt +++ b/shared/src/commonMain/kotlin/data/storage/HotWalletSubjectCredentialStore.kt @@ -30,6 +30,8 @@ class HotWalletSubjectCredentialStore( override suspend fun getCredentials(credentialSchemes: Collection?) = super.getCredentials(credentialSchemes) + override fun storeEntryIdOf(entry: SubjectCredentialStore.StoreEntry) = delegate.storeEntryIdOf(entry) + override suspend fun removeStoreEntryById( storeEntryId: StoreEntryId, ) = delegate.removeStoreEntryById(storeEntryId) diff --git a/shared/src/commonMain/kotlin/data/storage/PersistentSubjectCredentialStore.kt b/shared/src/commonMain/kotlin/data/storage/PersistentSubjectCredentialStore.kt index ca13ca705..fd48cf5d9 100644 --- a/shared/src/commonMain/kotlin/data/storage/PersistentSubjectCredentialStore.kt +++ b/shared/src/commonMain/kotlin/data/storage/PersistentSubjectCredentialStore.kt @@ -19,6 +19,8 @@ import at.asitplus.wallet.lib.data.VcJwtCredentialScheme import at.asitplus.wallet.lib.data.VerifiableCredentialJws import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt import io.github.aakira.napier.Napier +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -35,13 +37,25 @@ class PersistentSubjectCredentialStore( ) : SubjectCredentialStore, WalletSubjectCredentialStore { private val container = this.observeStoreContainer() + // The IDs of the entries stored last, by identity, see [storeEntryIdOf] + private val recentlyStored = ArrayDeque>() + private val recentlyStoredLock = SynchronizedObject() + private suspend fun addStoreEntry(storeEntry: SubjectCredentialStore.StoreEntry) { + val id = Random.nextLong() val newContainer = container.first().let { - it.copy(it.credentials + listOf(Random.nextLong() to storeEntry)) + it.copy(it.credentials + listOf(id to storeEntry)) } exportToDataStore(newContainer) + synchronized(recentlyStoredLock) { + recentlyStored.addLast(storeEntry to id) + while (recentlyStored.size > RECENTLY_STORED_LIMIT) recentlyStored.removeFirst() + } } + override fun storeEntryIdOf(entry: SubjectCredentialStore.StoreEntry): StoreEntryId? = + synchronized(recentlyStoredLock) { recentlyStored.lastOrNull { it.first === entry }?.second } + override suspend fun storeCredential( entry: SubjectCredentialStore.StoreEntry ): KmmResult = catching { @@ -252,6 +266,8 @@ class PersistentSubjectCredentialStore( typealias StoreEntryId = Long +private const val RECENTLY_STORED_LIMIT = 32 + @Serializable data class StoreContainer( val credentials: List>, diff --git a/shared/src/commonMain/kotlin/data/storage/WalletSubjectCredentialStore.kt b/shared/src/commonMain/kotlin/data/storage/WalletSubjectCredentialStore.kt index cf02f3199..2c3c8238a 100644 --- a/shared/src/commonMain/kotlin/data/storage/WalletSubjectCredentialStore.kt +++ b/shared/src/commonMain/kotlin/data/storage/WalletSubjectCredentialStore.kt @@ -12,6 +12,12 @@ interface WalletSubjectCredentialStore : SubjectCredentialStore { suspend fun removeStoreEntryById(storeEntryId: StoreEntryId) + /** + * The ID that [entry] has been stored with by this store instance recently, e.g. as returned by + * [at.asitplus.wallet.lib.agent.HolderAgent.storeAccepted], without waiting for [observeStoreContainer] to show it. + */ + fun storeEntryIdOf(entry: StoreEntry): StoreEntryId? = null + override suspend fun getCredentials( credentialSchemes: Collection?, ): KmmResult> { diff --git a/shared/src/commonTest/kotlin/data/storage/StoreEntryIdTest.kt b/shared/src/commonTest/kotlin/data/storage/StoreEntryIdTest.kt new file mode 100644 index 000000000..d278e505a --- /dev/null +++ b/shared/src/commonTest/kotlin/data/storage/StoreEntryIdTest.kt @@ -0,0 +1,51 @@ +package data.storage + +import at.asitplus.iso.IssuerSignedItem +import at.asitplus.openid.OidcUserInfo +import at.asitplus.openid.OidcUserInfoExtended +import at.asitplus.wallet.lib.agent.CredentialToBeIssued +import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert +import at.asitplus.wallet.lib.agent.HolderAgent +import at.asitplus.wallet.lib.agent.IssuerAgent +import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.data.IsoMdocFallbackCredentialScheme +import at.asitplus.wallet.lib.data.rfc3986.toUri +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +class StoreEntryIdTest { + private val keyMaterial = EphemeralKeyWithoutCert() + private val issuer = IssuerAgent( + keyMaterial = EphemeralKeyWithSelfSignedCert(), + identifier = "https://issuer.example.com/".toUri(), + ) + + @Test + fun knowsTheIdsOfStoredEntries() = runTest { + val store = PersistentSubjectCredentialStore(DummyDataStoreService()) + val holder = HolderAgent(keyMaterial = keyMaterial, subjectCredentialStore = store) + + val stored = listOf("Erika", "Max").map { holder.storeCredential(issue(it)).getOrThrow() } + + assertEquals( + store.observeStoreContainer().first().credentials.map { it.first }, + stored.map { store.storeEntryIdOf(it) }, + ) + } + + private suspend fun issue(givenName: String) = issuer.issueCredential( + CredentialToBeIssued.Iso( + issuerSignedItems = listOf(IssuerSignedItem(0U, Random.nextBytes(16), "given_name", givenName)), + expiration = Clock.System.now() + 10.minutes, + scheme = IsoMdocFallbackCredentialScheme("org.example.unknown.1"), + subjectPublicKey = keyMaterial.publicKey, + userInfo = OidcUserInfoExtended.fromOidcUserInfo(OidcUserInfo("subject")).getOrThrow(), + ) + ).getOrThrow().toStoreCredentialInput() +}