How to configure the application
Table of contents
- General configuration
- DeepLink Schemas configuration
- Scoped Issuance Document Configuration
- Batch Document Issuance Configuration
- Theme configuration
- Pin Storage configuration
- Analytics configuration
General configuration
All core network and trust settings are centralized in the WalletCoreConfig interface inside the
core-logic module:
interface WalletCoreConfig {
// 1. Issuing API
val vciConfig: List<OpenId4VciManager.Config>
// 2. Wallet Provider Host
val walletProviderHost: String
// 3. Trusted certificates / EudiWallet configuration
val config: EudiWalletConfig
// 4. Whether document keys require Android user authentication before creation or use
val userAuthenticationRequired: Boolean
// 5. Predefined set of document categories and their associated identifiers
val documentCategories: DocumentCategories
// 6. Interval at which revocations are checked
val revocationInterval: Duration
// 7. Document issuance rules (default rule + per-document overrides)
val documentIssuanceConfig: DocumentIssuanceConfig
// 8. Passport scanning issuer (separate endpoint for age verification documents)
val passportScanningIssuerConfig: OpenId4VciManager.Config?
// 9. Face match SDK model sources and thresholds
val faceMatchConfig: FaceMatchConfig
}
You configure these properties per flavor by providing a WalletCoreConfigImpl for each build
variant:
core-logic/src/demo/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.ktcore-logic/src/dev/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.kt
Each flavor can use different issuer URLs, wallet provider hosts, and trust stores.
- Issuing API
The Issuing API is configured via the vciConfig property:
```kotlin
override val vciConfig: List<OpenId4VciManager.Config>
get() = listOf(
OpenId4VciManager.Config.Builder()
.withIssuerUrl(issuerUrl = "https://test.issuer.dev.ageverification.dev")
.withClientAuthenticationType(OpenId4VciManager.ClientAuthenticationType.AttestationBased)
.withAuthFlowRedirectionURI(BuildConfig.ISSUE_AUTHORIZATION_DEEPLINK)
.withParUsage(OpenId4VciManager.Config.ParUsage.IF_SUPPORTED)
.withDPoPUsage(OpenId4VciManager.Config.DPoPUsage.IfSupported())
.build()
)
```
Adjust the configuration per flavor in the corresponding WalletCoreConfigImpl.
- Wallet Provider Host
The Wallet Provider Host is configured via the walletProviderHost property:
```kotlin
override val walletProviderHost: String
get() = "https://wallet-provider.ageverification.dev"
```
Again, set a different value per flavor in the corresponding WalletCoreConfigImpl.
- Trusted certificates
Trusted certificates are configured via the config property:
```kotlin
_config = EudiWalletConfig {
configureReaderTrustStore(context, R.raw.av_issuer_ca01)
}
```
The application's IACA certificates are located here
Configure EudiWalletConfig per flavor inside the appropriate WalletCoreConfigImpl.
- Preregistered Client Scheme
If you plan to use the ClientIdScheme.Preregistered for OpenId4VP configuration, please add the following to the configuration files.
```kotlin
const val OPENID4VP_VERIFIER_API_URI = "your_verifier_url"
const val OPENID4VP_VERIFIER_LEGAL_NAME = "your_verifier_legal_name"
const val OPENID4VP_VERIFIER_CLIENT_ID = "your_verifier_client_id"
configureOpenId4Vp {
withClientIdSchemes(
listOf(
ClientIdScheme.Preregistered(
listOf(
PreregisteredVerifier(
clientId = OPENID4VP_VERIFIER_CLIENT_ID,
verifierApi = OPENID4VP_VERIFIER_API_URI,
legalName = OPENID4VP_VERIFIER_LEGAL_NAME
)
)
)
)
)
}
```
DeepLink Schemas configuration
According to the specifications, issuance, presentation require deep-linking for the same device flows.
If you want to adjust any schema, you can alter the AndroidLibraryConventionPlugin inside the build-logic module.
val walletScheme = "av"
val walletHost = "*"
val eudiOpenId4VpScheme = "eudi-openid4vp"
val eudiOpenid4VpHost = "authorize"
val mdocOpenId4VpScheme = "mdoc-openid4vp"
val mdocOpenid4VpHost = "authorize"
val openId4VpScheme = "openid4vp"
val openid4VpHost = "authorize"
val avspScheme = "avsp"
val avspHost = "present"
val avScheme = "av"
val avHost = "*"
val credentialOfferScheme = "openid-credential-offer"
val credentialOfferHost = "credential_offer"
val openId4VciAuthorizationScheme = "eu.europa.ec.av"
val openId4VciAuthorizationHost = "authorization"
Let's assume you want to change the credential offer schema to custom-my-offer:// the AndroidLibraryConventionPlugin should look like this:
val walletScheme = "av"
val walletHost = "*"
val eudiOpenId4VpScheme = "eudi-openid4vp"
val eudiOpenid4VpHost = "authorize"
val mdocOpenId4VpScheme = "mdoc-openid4vp"
val mdocOpenid4VpHost = "authorize"
val openId4VpScheme = "openid4vp"
val openid4VpHost = "authorize"
val avspScheme = "avsp"
val avspHost = "present"
val avScheme = "av"
val avHost = "*"
val credentialOfferScheme = "custom-my-offer"
val credentialOfferHost = "credential_offer"
val openId4VciAuthorizationScheme = "eu.europa.ec.av"
val openId4VciAuthorizationHost = "authorization"
In case of an additive change, e.g., adding an extra credential offer schema, you must adjust the following.
AndroidLibraryConventionPlugin:
val credentialOfferScheme = "openid-credential-offer"
val credentialOfferHost = "credential_offer"
val myOwnCredentialOfferScheme = "custom-my-offer"
val myOwnCredentialOfferHost = "*"
// Manifest placeholders used for OpenId4VCI
manifestPlaceholders["credentialOfferHost"] = credentialOfferHost
manifestPlaceholders["credentialOfferScheme"] = credentialOfferScheme
manifestPlaceholders["myOwnCredentialOfferHost"] = myOwnCredentialOfferHost
manifestPlaceholders["myOwnCredentialOfferScheme"] = myOwnCredentialOfferScheme
addConfigField("CREDENTIAL_OFFER_SCHEME", credentialOfferScheme)
addConfigField("MY_OWN_CREDENTIAL_OFFER_SCHEME", myOwnCredentialOfferScheme)
Android Manifest (inside assembly-logic module):
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${credentialOfferHost}"
android:scheme="${credentialOfferScheme}" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${myOwnCredentialOfferHost}"
android:scheme="${myOwnCredentialOfferScheme}" />
</intent-filter>
DeepLinkType (DeepLinkHelper Object inside ui-logic module):
enum class DeepLinkType(val schemas: List<String>, val host: String? = null) {
OPENID4VP(
schemas = listOf(
BuildConfig.OPENID4VP_SCHEME,
BuildConfig.EUDI_OPENID4VP_SCHEME,
BuildConfig.MDOC_OPENID4VP_SCHEME,
BuildConfig.AVSP_SCHEME,
BuildConfig.AV_SCHEME
)
),
CREDENTIAL_OFFER(
schemas = listOf(
BuildConfig.CREDENTIAL_OFFER_SCHEME,
BuildConfig.MY_OWN_CREDENTIAL_OFFER_SCHEME
)
),
ISSUANCE(
schemas = listOf(BuildConfig.ISSUE_AUTHORIZATION_SCHEME),
host = BuildConfig.ISSUE_AUTHORIZATION_HOST
),
EXTERNAL(
emptyList()
),
DYNAMIC_PRESENTATION(
emptyList()
);
}
In the case of an additive change regarding OpenID4VP, you also need to update the EudiWalletConfig for each flavor inside the core-logic module.
configureOpenId4Vp {
withSchemes(
listOf(
BuildConfig.OPENID4VP_SCHEME,
BuildConfig.EUDI_OPENID4VP_SCHEME,
BuildConfig.MDOC_OPENID4VP_SCHEME,
BuildConfig.AVSP_SCHEME,
BuildConfig.AV_SCHEME,
BuildConfig.YOUR_OWN_OPENID4VP_SCHEME
)
)
}
Scoped Issuance Document Configuration
The credential configuration is derived directly from the issuer's metadata. The issuer URL is configured per flavor via the vciConfig property inside the core-logic module at src/demo/config/WalletCoreConfigImpl and src/dev/config/WalletCoreConfigImpl. If you want to add or adjust the displayed scoped documents, you must modify the issuer's metadata, and the wallet will automatically resolve your changes.
Passport Scanning Issuer Configuration
The application uses a dedicated passportScanningIssuerConfig property (declared on
WalletCoreConfig) to issue age verification documents after a passport scan. This is a separate
OpenId4VciManager.Config from the regular vciConfig entry — it is not the second element of
the vciConfig list.
The configuration is flavor-specific and defined in
core-logic/src/demo/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl and
core-logic/src/dev/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.
Example (Dev / Demo flavor):
/**
* Configuration for the passport scanning issuer.
*/
override val passportScanningIssuerConfig: OpenId4VciManager.Config =
OpenId4VciManager.Config.Builder()
.withIssuerUrl(issuerUrl = "https://passport.issuer.dev.ageverification.dev")
.withClientAuthenticationType(OpenId4VciManager.ClientAuthenticationType.AttestationBased)
.withAuthFlowRedirectionURI(BuildConfig.ISSUE_AUTHORIZATION_DEEPLINK)
.withParUsage(OpenId4VciManager.Config.ParUsage.NEVER)
.withDPoPUsage(OpenId4VciManager.Config.DPoPUsage.Disabled)
.build()
The passport scanning issuer configuration is optional. If the vciConfig list contains only one
issuer, passport scanning issuance will not be available.
Important Note: PAR (Pushed Authorization Request) and DPoP (Demonstration of Proof-of-Possession) are not supported in the AV profile. Both features must be disabled by setting:
parUsage = OpenId4VciManager.Config.ParUsage.NEVERuseDPoPIfSupported = false
Face Match Configuration
The application uses AI models for face liveness detection and face matching during passport
verification flows. These models are configured via the faceMatchConfig property in the
WalletCoreConfig interface.
The configuration is flavor-specific and defined in
core-logic/src/demo/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl and
core-logic/src/dev/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.
Configuration Structure:
Model sources are modeled by the FaceMatchModelSource sealed interface — a model is either an
asset bundled in the APK or a remote URL that is verified against a pinned SHA-256 digest. A
configuration cannot supply a remote URL without also pinning its hash; downloads whose hash does
not match are discarded.
sealed interface FaceMatchModelSource {
/** Model shipped inside the APK as an asset file. */
data class Asset(val filename: String) : FaceMatchModelSource
/**
* Model fetched from [url] (must be https) and verified against [sha256Hex]
* (case-insensitive hex digest). Downloads whose hash does not match are discarded.
*/
data class Remote(val url: String, val sha256Hex: String) : FaceMatchModelSource
}
data class FaceMatchConfig(
val faceDetectorModel: FaceMatchModelSource,
val embeddingExtractorModel: FaceMatchModelSource,
val livenessModel0: FaceMatchModelSource,
val livenessModel1: FaceMatchModelSource,
val livenessThreshold: Double, // Threshold for liveness detection (0.0-1.0)
/** Minimum cosine-similarity score to accept as the same subject. Recommended to be >= 0.8. */
val matchingThreshold: Double, // Threshold for face matching (0.0-1.0)
)
Example Configuration (Dev / Demo flavor):
override val faceMatchConfig: FaceMatchConfig = FaceMatchConfig(
faceDetectorModel = FaceMatchModelSource.Asset("mediapipe_long.onnx"),
embeddingExtractorModel = FaceMatchModelSource.Remote(
url = "https://github.com/eu-digital-identity-wallet/av-app-android-wallet-ui/releases/download/2025.10-2/glintr100.onnx",
sha256Hex = "a7933ea5330113b01c9b60351d8f4c33003f145d8470ac5f0e52ee2effe25c60",
),
livenessModel0 = FaceMatchModelSource.Asset("silentface40.onnx"),
livenessModel1 = FaceMatchModelSource.Asset("silentface27.onnx"),
livenessThreshold = 0.972017,
matchingThreshold = 0.5,
)
Model Source Options:
The model sources can be specified as:
FaceMatchModelSource.Asset(filename)— model bundled in the APK assetsFaceMatchModelSource.Remote(url, sha256Hex)— model downloaded over HTTPS at runtime and verified against the pinned SHA-256 digest
Threshold Configuration:
livenessThreshold: Controls the sensitivity of liveness detection. Higher values (e.g., 0.9) are more strict and may reject more legitimate faces, while lower values (e.g., 0.7) are more lenient but may accept more spoofing attempts.matchingThreshold: Controls the sensitivity of face matching between the passport photo and selfie. Higher values require a closer match, while lower values are more forgiving of lighting and angle differences.
⚠️ SECURITY WARNING:
The models referenced in the default configuration are currently hosted on GitHub Releases for development and testing purposes only. This is NOT recommended for production environments. Remote model sources must always be paired with a pinned
sha256Hexso a tampered or misissued download is rejected.
Batch Document Issuance Configuration
The app is configured to use batch document issuance by default, requesting a batch of credentials at once and handling them according to a defined policy.
You can configure the following aspects of batch document issuance in DocumentIssuanceRule:
- number of credentials (formerly batch size) - The number of credentials to be issued for the document at once
- Credential policy - whether to use each credential once or rotate through them
These settings are configured in your flavor's implementation of WalletCoreConfigImpl. For
example, in the demo flavor:
internal class WalletCoreConfigImpl(
private val context: Context
) : WalletCoreConfig {
// ...other configuration...
val documentIssuanceConfig: DocumentIssuanceConfig
get() = DocumentIssuanceConfig(
defaultRule = DocumentIssuanceRule(
policy = CredentialPolicy.OneTimeUse,
numberOfCredentials = 30
),
documentSpecificRules = mapOf()
)
}
Note that the batch size will be limited by the issuer's metadata configuration, so you may not be
able to request a batch larger than what the issuer allows. To understand the issuer's
configuration, you can check the issuer's metadata endpoint, which is usually available at
https://<issuer-url>/.well-known/openid-configuration. Specifically, look for the
credential_batch_size field in the metadata response.
Note that the batch size will be limited by the issuer's metadata configuration, so you may not be
able to request a batch larger than what the issuer allows. to understand the issuer's
configuration, you can check the issuer's metadata endpoint, which is usually available at
https://<issuer-url>/.well-known/openid-configuration. specifically, look for
the credential_batch_size field in the metadata response.
Theme configuration
The application allows the configuration of:
- Colors
- Images
- Shape
- Fonts
- Dimension
Via ThemeManager.Builder().
Pin Storage configuration
The application allows the configuration of the PIN storage. You can configure the following:
- Where the pin will be stored
- From where the pin will be retrieved
- Pin matching and validity
Via the StorageConfig inside the authentication-logic module.
interface StorageConfig {
val pinStorageProvider: PinStorageProvider
val biometryStorageProvider: BiometryStorageProvider
}
You can provide your storage implementation by implementing the PinStorageProvider interface and then setting it as the default to the StorageConfigImpl pinStorageProvider variable. The project utilizes Koin for Dependency Injection (DI), thus requiring adjustment of the LogicAuthenticationModule graph to provide the configuration.
The PinStorageProvider interface:
interface PinStorageProvider {
fun hasPin(): Boolean
fun setPin(pin: String)
fun isPinValid(pin: String): Boolean
fun getFailedAttempts(): Int
fun incrementFailedAttempts(): Int
fun resetFailedAttempts()
fun setLockoutForDuration(durationMillis: Long)
fun getLockoutUntil(): Long
fun isCurrentlyLockedOut(): Boolean
}
Implementation Example:
The default implementation is PinStorageProviderImpl (in the authentication-logic module). It
does not store a raw PIN hash. Instead it derives a per-PIN key with Argon2
(Argon2KeyDerivation), uses that key to AES-GCM-encrypt a random 32-byte vault key, and persists
the wrapped vault key plus the Argon2 parameters (m, t, parallelism) and salt in an AuthMetadata
record. On validation the PIN is re-derived through Argon2, the vault key is unwrapped with
AES-GCM (which doubles as an authenticity check), and the vault key is handed to the
VaultKeyProvider on success. PIN material and derived keys are zeroed after use.
class PinStorageProviderImpl(
private val authMetadataStore: AuthMetadataStore,
private val clock: ElapsedRealtimeClock,
private val bootIdProvider: BootIdProvider,
private val vaultKeyProvider: VaultKeyProvider,
) : PinStorageProvider {
override fun setPin(pin: String) {
val pinChars = pin.toCharArray()
try {
val pinSalt = ByteArray(Argon2KeyDerivation.SALT_LEN)
.also { SecureRandom().nextBytes(it) }
val kPin = Argon2KeyDerivation.derive(pinChars, pinSalt)
// ...create or rotate a 32-byte vault key, wrap it with kPin via AES-GCM...
val (wrappedVaultIv, wrappedVault) = aesGcmEncrypt(kPin, kVault, pinSalt)
kPin.fill(0)
runBlocking {
authMetadataStore.write(
AuthMetadata(
version = 0x01,
kdfAlgo = KDF_ALGO,
kdfM = Argon2KeyDerivation.M_COST_KIB,
kdfT = Argon2KeyDerivation.T_COST,
kdfP = Argon2KeyDerivation.PARALLELISM,
pinSalt = pinSalt,
wrappedVaultIv = wrappedVaultIv,
wrappedVault = wrappedVault,
bootId = bootIdProvider.currentBootId(),
)
)
}
} finally {
pinChars.fill('\u0000')
}
}
override fun isPinValid(pin: String): Boolean {
val meta = readWithReanchor() ?: return false
if (isLockedOutByMeta(meta)) return false
val pinChars = pin.toCharArray()
return try {
val kPin = Argon2KeyDerivation.derive(
pin = pinChars, salt = meta.pinSalt,
mCostKib = meta.kdfM, tCost = meta.kdfT, parallelism = meta.kdfP,
)
val kVault = try {
aesGcmDecrypt(kPin, meta.wrappedVault, meta.wrappedVaultIv, meta.pinSalt)
} catch (_: Exception) {
null
} finally {
kPin.fill(0)
}
if (kVault != null) {
vaultKeyProvider.unlock(kVault)
kVault.fill(0)
true
} else {
false
}
} finally {
pinChars.fill('\u0000')
}
}
// ...failed-attempt counters and reboot-aware lockout (see setLockoutForDuration /
// getLockoutUntil / isCurrentlyLockedOut)...
}
The default implementation uses Argon2 for PIN-key derivation with a random salt, AES-GCM to
wrap a random vault key (which doubles as an authenticity check on validation), zeroing of
sensitive byte arrays after use, and reboot-aware lockout via ElapsedRealtimeClock and
BootIdProvider.
⚠️ Note: PBKDF2 is intentionally not used in this project. A
checkNoPbkdf2Gradle task (build.gradle.kts) fails the build if the literalPBKDF2appears inauthentication-logic. Do not reintroduce PBKDF2-based PIN hashing.
Config Example:
class StorageConfigImpl(
private val pinImpl: PinStorageProvider,
private val biometryImpl: BiometryStorageProvider
) : StorageConfig {
override val pinStorageProvider: PinStorageProvider
get() = pinImpl
override val biometryStorageProvider: BiometryStorageProvider
get() = biometryImpl
}
Config Construction via Koin DI Example:
@Single
fun provideStorageConfig(
authMetadataStore: AuthMetadataStore,
clock: ElapsedRealtimeClock,
bootIdProvider: BootIdProvider,
vaultKeyProvider: VaultKeyProvider,
biometryStorageProvider: BiometryStorageProvider,
): StorageConfig = StorageConfigImpl(
pinImpl = PinStorageProviderImpl(authMetadataStore, clock, bootIdProvider, vaultKeyProvider),
biometryImpl = biometryStorageProvider
)
Analytics configuration
The application allows the configuration of multiple analytics providers. You can configure the following:
- Initializing the provider (e.g., Firebase, Appcenter, etc)
- Screen logging
- Event logging
Via the AnalyticsConfig inside the analytics-logic module.
interface AnalyticsConfig {
val analyticsProviders: Map<String, AnalyticsProvider>
get() = emptyMap()
}
You can provide your implementation by implementing the AnalyticsProvider interface and then adding it to your AnalyticsConfigImpl analyticsProviders variable.
You will also need the provider's token/key, thus requiring a Map
Implementation Example:
object AppCenterAnalyticsProvider : AnalyticsProvider {
override fun initialize(context: Application, key: String) {
AppCenter.start(
context,
key,
Analytics::class.java
)
}
override fun logScreen(name: String, arguments: Map<String, String>) {
logEvent(name, arguments)
}
override fun logEvent(event: String, arguments: Map<String, String>) {
if (Analytics.isEnabled().get()) {
Analytics.trackEvent(event, arguments)
}
}
}
Config Example:
class AnalyticsConfigImpl : AnalyticsConfig {
override val analyticsProviders: Map<String, AnalyticsProvider>
get() = mapOf("YOUR_OWN_KEY" to AppCenterAnalyticsProvider)
}
Config Construction via Koin DI Example:
@Single
fun provideAnalyticsConfig(): AnalyticsConfig = AnalyticsConfigImpl()